@johnnyboy11234/esg-idea-contest 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/backend/api/openapi.yaml +83 -0
  2. package/backend/cmd/server/main.go +35 -2
  3. package/backend/db/migration/V005__idea.sql +49 -0
  4. package/backend/db/migration/V006__login_lock.sql +15 -0
  5. package/backend/db/migration/V007__login_attempt.sql +15 -0
  6. package/backend/db/migration/V008__pending_file_deletion.sql +27 -0
  7. package/backend/db/migration/V009__login_throttle_audit.sql +13 -0
  8. package/backend/go.mod +17 -3
  9. package/backend/go.sum +48 -7
  10. package/backend/internal/account/account_test.go +6 -6
  11. package/backend/internal/account/integration_test.go +45 -9
  12. package/backend/internal/account/keyboard.go +75 -0
  13. package/backend/internal/account/keyboard_test.go +43 -0
  14. package/backend/internal/account/logging_integration_test.go +1 -1
  15. package/backend/internal/account/password.go +125 -0
  16. package/backend/internal/account/validate.go +4 -21
  17. package/backend/internal/api/api.gen.go +224 -0
  18. package/backend/internal/auth/internal/store/login_attempt.go +53 -0
  19. package/backend/internal/auth/internal/store/login_lock.go +77 -0
  20. package/backend/internal/auth/internal/store/login_throttle_audit.go +42 -0
  21. package/backend/internal/auth/internal/store/session.go +15 -7
  22. package/backend/internal/auth/login.go +16 -4
  23. package/backend/internal/auth/login_integration_test.go +14 -1
  24. package/backend/internal/auth/login_ip_integration_test.go +153 -0
  25. package/backend/internal/auth/login_lock_integration_test.go +205 -0
  26. package/backend/internal/auth/login_test.go +15 -0
  27. package/backend/internal/auth/login_throttle_audit_integration_test.go +88 -0
  28. package/backend/internal/auth/purge.go +93 -0
  29. package/backend/internal/auth/purge_integration_test.go +220 -0
  30. package/backend/internal/auth/purge_test.go +51 -0
  31. package/backend/internal/auth/session.go +0 -6
  32. package/backend/internal/auth/session_integration_test.go +3 -3
  33. package/backend/internal/auth/throttle.go +122 -0
  34. package/backend/internal/deadline/gate.go +80 -0
  35. package/backend/internal/deadline/gate_integration_test.go +67 -0
  36. package/backend/internal/deadline/gate_test.go +61 -0
  37. package/backend/internal/idea/create.go +352 -0
  38. package/backend/internal/idea/create_integration_test.go +650 -0
  39. package/backend/internal/idea/handler.go +148 -0
  40. package/backend/internal/idea/handler_integration_test.go +107 -0
  41. package/backend/internal/idea/integration_test.go +149 -0
  42. package/backend/internal/idea/internal/store/list.go +146 -0
  43. package/backend/internal/idea/internal/store/list_test.go +33 -0
  44. package/backend/internal/idea/internal/store/store.go +166 -0
  45. package/backend/internal/idea/list.go +166 -0
  46. package/backend/internal/idea/list_integration_test.go +252 -0
  47. package/backend/internal/idea/list_test.go +96 -0
  48. package/backend/internal/idea/service.go +53 -0
  49. package/backend/internal/server/api.go +17 -0
  50. package/backend/internal/server/public.go +1 -0
  51. package/backend/internal/server/slides.go +21 -0
  52. package/backend/internal/shared/config/config.go +44 -1
  53. package/backend/internal/shared/config/config_test.go +21 -13
  54. package/backend/internal/shared/db/intx_integration_test.go +179 -0
  55. package/backend/internal/shared/dbtest/dbtest.go +21 -3
  56. package/backend/internal/shared/dbtest/faults.go +162 -0
  57. package/backend/internal/shared/job/job.go +54 -0
  58. package/backend/internal/shared/job/job_test.go +105 -0
  59. package/backend/internal/shared/web/multipart.go +175 -0
  60. package/backend/internal/shared/web/multipart_test.go +169 -0
  61. package/backend/internal/shared/web/problem.go +1 -0
  62. package/backend/internal/shared/web/request.go +42 -1
  63. package/backend/internal/shared/web/web_test.go +36 -0
  64. package/backend/internal/storage/expectedfilename.go +43 -0
  65. package/backend/internal/storage/expectedfilename_test.go +16 -0
  66. package/backend/internal/storage/internal/store/pending.go +41 -0
  67. package/backend/internal/storage/minio.go +139 -0
  68. package/backend/internal/storage/minio_integration_test.go +87 -0
  69. package/backend/internal/storage/minio_test.go +84 -0
  70. package/backend/internal/storage/pending.go +144 -0
  71. package/backend/internal/storage/pending_integration_test.go +324 -0
  72. package/backend/internal/storage/pending_test.go +23 -0
  73. package/backend/internal/storage/validator.go +100 -0
  74. package/backend/internal/storage/validator_test.go +95 -0
  75. package/backend/test/contract/expected_filename_test.go +60 -0
  76. package/backend/test/contract/password_test.go +46 -0
  77. package/backend/test/matrix/load_test.go +12 -0
  78. package/backend/test/matrix/matrix_integration_test.go +86 -5
  79. package/backend/test/migration/pending_file_deletion_test.go +46 -0
  80. package/docs/API/api-endpoints.md +1 -1
  81. package/docs/API/error-codes.md +1 -1
  82. package/frontend/src/app/App.tsx +11 -2
  83. package/frontend/src/features/auth/IdleWarning.test.tsx +82 -0
  84. package/frontend/src/features/auth/IdleWarning.tsx +66 -0
  85. package/frontend/src/shared/api/activity.ts +20 -0
  86. package/frontend/src/shared/api/http.ts +2 -0
  87. package/frontend/src/shared/api/index.ts +1 -0
  88. package/frontend/src/shared/i18n/locales/en.ts +1 -0
  89. package/frontend/src/shared/i18n/locales/zh-TW.ts +1 -0
  90. package/package.json +1 -1
  91. package/testdata/expected-filename.json +93 -0
  92. package/testdata/password.json +425 -0
  93. package/testdata/permission-matrix.json +48 -0
  94. package/testdata/schema/expected-filename.schema.json +32 -0
  95. package/testdata/schema/password.schema.json +59 -0
  96. package/testdata/schema/permission-matrix.schema.json +21 -0
@@ -0,0 +1,148 @@
1
+ package idea
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "errors"
7
+ "net/http"
8
+
9
+ "github.com/google/uuid"
10
+
11
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/api"
12
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
13
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/idea/internal/store"
14
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/db"
15
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
16
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
17
+ )
18
+
19
+ // auditReasonHidden is the one reason stored for a missing and a hidden Idea
20
+ // alike: the log must not tell them apart either (PRD 6.6).
21
+ const auditReasonHidden = "NOT_FOUND"
22
+
23
+ // View returns the Idea for GET /ideas/{id}. An Admin's successful view writes
24
+ // IDEA_VIEW in the same transaction as the read (fail-closed, PRD 6.11).
25
+ func (s *Service) View(ctx context.Context, id uuid.UUID, caller security.Principal) (Idea, error) {
26
+ if !caller.IsAdmin() {
27
+ return s.Get(ctx, id, caller)
28
+ }
29
+ var got Idea
30
+ _, err := db.InTx(ctx, s.DB, func(ctx context.Context, tx *sql.Tx) error {
31
+ var err error
32
+ if got, err = store.GetVisible(ctx, tx, id, caller.UserID, true); err != nil {
33
+ return err
34
+ }
35
+ return s.Audit.Record(ctx, tx, audit.Event{
36
+ Action: audit.IdeaView, Outcome: audit.Success,
37
+ TargetType: audit.TargetIdea, TargetID: id,
38
+ })
39
+ })
40
+ if errors.Is(err, store.ErrNotFound) {
41
+ return Idea{}, web.NewError(web.CodeNotFound, "")
42
+ }
43
+ return got, err
44
+ }
45
+
46
+ // Handler serves the Idea endpoints.
47
+ type Handler struct {
48
+ Service *Service
49
+ // ExpectedFilename builds the expected slide filename without extension
50
+ // (storage.ExpectedFilenameBase; injected because idea must not import
51
+ // storage, D29).
52
+ ExpectedFilename func(category, org, leaderName, leaderEmpNo, ideaName string) (string, error)
53
+ Fail func(w http.ResponseWriter, r *http.Request, err error)
54
+ }
55
+
56
+ type person struct {
57
+ EmpNo string `json:"empNo"`
58
+ Name string `json:"name"`
59
+ }
60
+
61
+ type member struct {
62
+ EmpNo string `json:"empNo"`
63
+ Name string `json:"name"`
64
+ JoinedAt string `json:"joinedAt"`
65
+ }
66
+
67
+ type pending struct {
68
+ EmpNo string `json:"empNo"`
69
+ Name string `json:"name"`
70
+ RequestedAt string `json:"requestedAt"`
71
+ }
72
+
73
+ type slides struct {
74
+ Ext string `json:"ext"`
75
+ Size int64 `json:"size"`
76
+ SHA256 string `json:"sha256"`
77
+ UpdatedAt string `json:"updatedAt"`
78
+ DownloadFilename string `json:"downloadFilename"`
79
+ }
80
+
81
+ // detail is IdeaDetail (docs/API/response-bodies.md). It is local so that
82
+ // timestamps use web.FormatTime (fixed milliseconds) instead of time.Time.
83
+ type detail struct {
84
+ ID uuid.UUID `json:"id"`
85
+ Category string `json:"category"`
86
+ Org string `json:"org"`
87
+ Direction string `json:"direction"`
88
+ Name string `json:"name"`
89
+ Summary string `json:"summary"`
90
+ Leader person `json:"leader"`
91
+ Members []member `json:"members"`
92
+ PendingRequests *[]pending `json:"pendingRequests"`
93
+ Slides slides `json:"slides"`
94
+ ExpectedFilenameBase string `json:"expectedFilenameBase"`
95
+ CreatedAt string `json:"createdAt"`
96
+ UpdatedAt string `json:"updatedAt"`
97
+ Version int64 `json:"version"`
98
+ }
99
+
100
+ // GetIdea serves GET /ideas/{id}.
101
+ func (h Handler) GetIdea(w http.ResponseWriter, r *http.Request, id api.IdeaId) {
102
+ p, ok := security.PrincipalFrom(r.Context())
103
+ if !ok {
104
+ h.Fail(w, r, web.NewError(web.CodeUnauthenticated, "authentication required"))
105
+ return
106
+ }
107
+ got, err := h.Service.View(r.Context(), uuid.UUID(id), p)
108
+ if err != nil {
109
+ var e *web.Error
110
+ if errors.As(err, &e) && e.Code == web.CodeNotFound {
111
+ e.AuditAction = string(audit.IdeaView)
112
+ e.AuditReason = auditReasonHidden
113
+ e.AuditTargetType = string(audit.TargetIdea)
114
+ e.AuditTargetID = uuid.UUID(id)
115
+ }
116
+ h.Fail(w, r, err)
117
+ return
118
+ }
119
+ body, err := h.toDetail(got, p)
120
+ if err != nil {
121
+ h.Fail(w, r, err)
122
+ return
123
+ }
124
+ web.WriteJSON(w, http.StatusOK, body)
125
+ }
126
+
127
+ func (h Handler) toDetail(i Idea, caller security.Principal) (detail, error) {
128
+ base, err := h.ExpectedFilename(i.Category, i.Org, i.LeaderName, i.LeaderEmpNo, i.Name)
129
+ if err != nil {
130
+ return detail{}, err
131
+ }
132
+ d := detail{
133
+ ID: i.ID, Category: i.Category, Org: i.Org, Direction: i.Direction, Name: i.Name, Summary: i.Summary,
134
+ Leader: person{EmpNo: i.LeaderEmpNo, Name: i.LeaderName},
135
+ // TODO(B5-01): approved Members and pending requests come from idea_member.
136
+ Members: []member{},
137
+ Slides: slides{Ext: i.Slides.Ext, Size: i.Slides.Size, SHA256: i.Slides.SHA256,
138
+ UpdatedAt: web.FormatTime(i.SlidesUpdatedAt), DownloadFilename: base + "." + i.Slides.Ext},
139
+ ExpectedFilenameBase: base,
140
+ CreatedAt: web.FormatTime(i.CreatedAt),
141
+ UpdatedAt: web.FormatTime(i.UpdatedAt),
142
+ Version: int64(i.Version),
143
+ }
144
+ if caller.IsAdmin() || caller.UserID == i.LeaderID {
145
+ d.PendingRequests = &[]pending{}
146
+ }
147
+ return d, nil
148
+ }
@@ -0,0 +1,107 @@
1
+ //go:build integration
2
+
3
+ package idea_test
4
+
5
+ import (
6
+ "context"
7
+ "encoding/json"
8
+ "net/http"
9
+ "net/http/httptest"
10
+ "testing"
11
+
12
+ "github.com/go-chi/chi/v5"
13
+ "github.com/google/uuid"
14
+
15
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/api"
16
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
17
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/idea"
18
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/server"
19
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
20
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
21
+ )
22
+
23
+ func TestGetIdeaHandler(t *testing.T) {
24
+ d := dbtest.New(t)
25
+ leader := addUser(t, d, "100001", "USER")
26
+ other := addUser(t, d, "100002", "USER")
27
+ admin := addUser(t, d, "900001", "ADMIN")
28
+ n := newIdea(leader.UserID, "Idea A")
29
+ if err := insert(d, n); err != nil {
30
+ t.Fatal(err)
31
+ }
32
+ aud := audit.New(d, nil)
33
+ errs := server.ErrorAdapter{Audit: aud}
34
+ h := idea.Handler{Service: &idea.Service{DB: d, Audit: aud}, ExpectedFilename: expectedBase, Fail: errs.Write}
35
+
36
+ call := func(p security.Principal, id uuid.UUID) *httptest.ResponseRecorder {
37
+ r := chi.NewRouter()
38
+ r.Get("/ideas/{id}", func(w http.ResponseWriter, req *http.Request) { h.GetIdea(w, req, api.IdeaId(id)) })
39
+ req := httptest.NewRequest(http.MethodGet, "/ideas/"+id.String(), nil)
40
+ req = req.WithContext(security.WithPrincipal(context.Background(), p))
41
+ rec := httptest.NewRecorder()
42
+ r.ServeHTTP(rec, req)
43
+ return rec
44
+ }
45
+ count := func(where string) (c int) {
46
+ if err := d.QueryRow(`SELECT COUNT(*) FROM audit_log WHERE ` + where).Scan(&c); err != nil {
47
+ t.Fatal(err)
48
+ }
49
+ return c
50
+ }
51
+
52
+ // Leader: 200, pendingRequests is [] (not null), expected filename.
53
+ rec := call(leader, n.ID)
54
+ if rec.Code != 200 {
55
+ t.Fatalf("leader: %d %s", rec.Code, rec.Body)
56
+ }
57
+ var body map[string]any
58
+ _ = json.Unmarshal(rec.Body.Bytes(), &body)
59
+ if body["expectedFilenameBase"] != "I_BSID_User 100001_100001_Idea A" ||
60
+ body["slides"].(map[string]any)["downloadFilename"] != "I_BSID_User 100001_100001_Idea A.pdf" {
61
+ t.Errorf("filename: %v", body)
62
+ }
63
+ if pr, ok := body["pendingRequests"].([]any); !ok || len(pr) != 0 {
64
+ t.Errorf("pendingRequests = %v", body["pendingRequests"])
65
+ }
66
+ if count(`action = 'IDEA_VIEW'`) != 0 {
67
+ t.Error("a Leader view must not write IDEA_VIEW")
68
+ }
69
+
70
+ // Admin: 200 and one IDEA_VIEW SUCCESS row.
71
+ if rec := call(admin, n.ID); rec.Code != 200 {
72
+ t.Fatalf("admin: %d", rec.Code)
73
+ }
74
+ if count(`action = 'IDEA_VIEW' AND outcome = 'SUCCESS' AND actor_role = 'ADMIN'`) != 1 {
75
+ t.Error("admin view: want one IDEA_VIEW SUCCESS row")
76
+ }
77
+
78
+ // Hidden and missing: identical 404 (bar the request ID), both audited DENIED.
79
+ hidden, missing := call(other, n.ID), call(other, uuid.New())
80
+ if hidden.Code != 404 || missing.Code != 404 {
81
+ t.Fatalf("codes %d %d", hidden.Code, missing.Code)
82
+ }
83
+ strip := func(rr *httptest.ResponseRecorder) (map[string]any, http.Header) {
84
+ var m map[string]any
85
+ _ = json.Unmarshal(rr.Body.Bytes(), &m)
86
+ delete(m, "requestId")
87
+ return m, rr.Header()
88
+ }
89
+ hb, hh := strip(hidden)
90
+ mb, mh := strip(missing)
91
+ if len(hb) != len(mb) || hb["code"] != mb["code"] || hb["detail"] != mb["detail"] {
92
+ t.Errorf("bodies differ: %v vs %v", hb, mb)
93
+ }
94
+ for k := range hh {
95
+ if hh.Get(k) != mh.Get(k) {
96
+ t.Errorf("header %s differs: %q vs %q", k, hh.Get(k), mh.Get(k))
97
+ }
98
+ }
99
+ if got := count(`action = 'IDEA_VIEW' AND outcome = 'DENIED' AND status = 404 AND error_code = 'NOT_FOUND'`); got != 2 {
100
+ t.Errorf("DENIED rows = %d, want 2", got)
101
+ }
102
+ }
103
+
104
+ // expectedBase mirrors storage.ExpectedFilenameBase (idea must not import storage).
105
+ func expectedBase(category, org, leaderName, leaderEmpNo, ideaName string) (string, error) {
106
+ return "I_" + org + "_" + leaderName + "_" + leaderEmpNo + "_" + ideaName, nil
107
+ }
@@ -0,0 +1,149 @@
1
+ //go:build integration
2
+
3
+ package idea_test
4
+
5
+ import (
6
+ "context"
7
+ "database/sql"
8
+ "errors"
9
+ "strings"
10
+ "testing"
11
+
12
+ "github.com/google/uuid"
13
+
14
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/idea"
15
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/idea/internal/store"
16
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/db"
17
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
18
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
19
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
20
+ )
21
+
22
+ const hash = "$2a$10$" + "abcdefghijklmnopqrstuuabcdefghijklmnopqrstuuabcdefghi" // 7 + 53 chars
23
+
24
+ func addUser(t *testing.T, d *sql.DB, empNo, role string) security.Principal {
25
+ t.Helper()
26
+ id := uuid.New()
27
+ var email any
28
+ if role == "USER" {
29
+ email = empNo + "@example.com"
30
+ }
31
+ if _, err := d.Exec(`INSERT INTO app_user (id, emp_no, name, email, password_hash, role) VALUES (?,?,?,?,?,?)`,
32
+ id.String(), empNo, "User "+empNo, email, hash, role); err != nil {
33
+ t.Fatal(err)
34
+ }
35
+ return security.Principal{UserID: id, EmpNo: empNo, Role: security.Role(role)}
36
+ }
37
+
38
+ func newIdea(leader uuid.UUID, name string) store.NewIdea {
39
+ id := uuid.New()
40
+ return store.NewIdea{
41
+ ID: id, LeaderID: leader, Category: "COOL_IDEA", Org: "BSID", Direction: "TALENT_DEVELOPMENT",
42
+ Name: name, Summary: "s",
43
+ Slides: store.Slides{ObjectKey: "slides/" + id.String() + "/" + uuid.NewString() + ".pdf",
44
+ Ext: "pdf", Size: 1024, SHA256: strings.Repeat("ab", 32)},
45
+ }
46
+ }
47
+
48
+ func insert(d *sql.DB, n store.NewIdea) error {
49
+ tx, err := d.Begin()
50
+ if err != nil {
51
+ return err
52
+ }
53
+ if err := store.InsertIdea(context.Background(), tx, n); err != nil {
54
+ _ = tx.Rollback()
55
+ return err
56
+ }
57
+ return tx.Commit()
58
+ }
59
+
60
+ func TestVisibilityPerRole(t *testing.T) {
61
+ d := dbtest.New(t)
62
+ leader := addUser(t, d, "100001", "USER")
63
+ other := addUser(t, d, "100002", "USER")
64
+ admin := addUser(t, d, "900001", "ADMIN")
65
+ n := newIdea(leader.UserID, "Idea A")
66
+ if err := insert(d, n); err != nil {
67
+ t.Fatal(err)
68
+ }
69
+ svc := &idea.Service{DB: d}
70
+ ctx := context.Background()
71
+
72
+ for _, tc := range []struct {
73
+ who string
74
+ p security.Principal
75
+ visible bool
76
+ }{{"leader", leader, true}, {"admin", admin, true}, {"other user", other, false}} {
77
+ got, err := svc.Get(ctx, n.ID, tc.p)
78
+ if tc.visible {
79
+ if err != nil || got.ID != n.ID || got.LeaderEmpNo != "100001" || got.Version != 0 || got.Slides.Ext != "pdf" {
80
+ t.Errorf("%s: got %+v, %v", tc.who, got, err)
81
+ }
82
+ continue
83
+ }
84
+ if !isNotFound(err) {
85
+ t.Errorf("%s: want NOT_FOUND, got %v", tc.who, err)
86
+ }
87
+ }
88
+
89
+ // A missing Idea is indistinguishable from a hidden one.
90
+ _, errMissing := svc.Get(ctx, uuid.New(), leader)
91
+ _, errHidden := svc.Get(ctx, n.ID, other)
92
+ if !isNotFound(errMissing) || errMissing.Error() != errHidden.Error() {
93
+ t.Errorf("missing %v vs hidden %v", errMissing, errHidden)
94
+ }
95
+ }
96
+
97
+ func isNotFound(err error) bool {
98
+ var we *web.Error
99
+ return errors.As(err, &we) && we.Error() == "NOT_FOUND"
100
+ }
101
+
102
+ func TestUniqueLeaderName(t *testing.T) {
103
+ d := dbtest.New(t)
104
+ a := addUser(t, d, "100001", "USER")
105
+ b := addUser(t, d, "100002", "USER")
106
+ if err := insert(d, newIdea(a.UserID, "Same")); err != nil {
107
+ t.Fatal(err)
108
+ }
109
+ err := insert(d, newIdea(a.UserID, "Same"))
110
+ if key, ok := db.DuplicateKey(err); !ok || key != "uq_idea_leader_name" {
111
+ t.Errorf("same leader, same name: key %q ok %v err %v", key, ok, err)
112
+ }
113
+ if err := insert(d, newIdea(a.UserID, "same")); err != nil {
114
+ t.Errorf("name is case-sensitive: %v", err)
115
+ }
116
+ if err := insert(d, newIdea(b.UserID, "Same")); err != nil {
117
+ t.Errorf("names are unique per leader, not globally: %v", err)
118
+ }
119
+ }
120
+
121
+ func TestChecksRejectBadRows(t *testing.T) {
122
+ d := dbtest.New(t)
123
+ a := addUser(t, d, "100001", "USER")
124
+ for name, mut := range map[string]func(*store.NewIdea){
125
+ "category": func(n *store.NewIdea) { n.Category = "X" },
126
+ "org": func(n *store.NewIdea) { n.Org = "ZZZZ" },
127
+ "direction": func(n *store.NewIdea) { n.Direction = "X" },
128
+ "empty name": func(n *store.NewIdea) { n.Name = "" },
129
+ "slash name": func(n *store.NewIdea) { n.Name = "a/b" },
130
+ "long name": func(n *store.NewIdea) { n.Name = strings.Repeat("好", 76) },
131
+ "empty summary": func(n *store.NewIdea) { n.Summary = "" },
132
+ "ext": func(n *store.NewIdea) { n.Slides.Ext = "docx" },
133
+ "size zero": func(n *store.NewIdea) { n.Slides.Size = 0 },
134
+ "size over 5MiB": func(n *store.NewIdea) { n.Slides.Size = 5*1024*1024 + 1 },
135
+ "sha upper": func(n *store.NewIdea) { n.Slides.SHA256 = strings.Repeat("AB", 32) },
136
+ "no leader": func(n *store.NewIdea) { n.LeaderID = uuid.New() },
137
+ } {
138
+ n := newIdea(a.UserID, "ok")
139
+ mut(&n)
140
+ if err := insert(d, n); err == nil {
141
+ t.Errorf("%s: row accepted", name)
142
+ }
143
+ }
144
+ n := newIdea(a.UserID, strings.Repeat("好", 75))
145
+ n.Slides.Size = 5 * 1024 * 1024
146
+ if err := insert(d, n); err != nil {
147
+ t.Errorf("boundary values rejected: %v", err)
148
+ }
149
+ }
@@ -0,0 +1,146 @@
1
+ package store
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "fmt"
7
+ "strings"
8
+ "time"
9
+
10
+ "github.com/google/uuid"
11
+ )
12
+
13
+ // ListFilter is the validated filter set of GET /ideas (PRD 6.6). A nil or
14
+ // empty field means "no filter". CreatedFrom is inclusive, CreatedTo exclusive.
15
+ type ListFilter struct {
16
+ Org, Category, Direction string
17
+ LeaderEmpNo string
18
+ CreatedFrom, CreatedTo *time.Time
19
+ // Q is a case-insensitive "name contains" term; %, _ and the escape
20
+ // character are literal.
21
+ Q string
22
+ // SortName sorts by name (code point order) instead of created_at; Desc is
23
+ // the direction. The handler resolves the per-field default direction.
24
+ SortName, Desc bool
25
+ }
26
+
27
+ // likeEscape is the ESCAPE character of the name search. It is not a backslash,
28
+ // so the SQL literal does not depend on NO_BACKSLASH_ESCAPES.
29
+ const likeEscape = "!"
30
+
31
+ // containsPattern turns a user term into a LIKE pattern matching names that
32
+ // contain it, with every wildcard escaped.
33
+ func containsPattern(q string) string {
34
+ r := strings.NewReplacer(likeEscape, likeEscape+likeEscape, "%", likeEscape+"%", "_", likeEscape+"_")
35
+ return "%" + r.Replace(q) + "%"
36
+ }
37
+
38
+ func (f ListFilter) orderBy() string {
39
+ dir := "ASC"
40
+ if f.Desc {
41
+ dir = "DESC"
42
+ }
43
+ col := "i.created_at"
44
+ if f.SortName {
45
+ col = "i.name"
46
+ }
47
+ return col + " " + dir + ", i.id " + dir
48
+ }
49
+
50
+ // Summary is one row of the list (IdeaSummary: no summary text).
51
+ type Summary struct {
52
+ ID uuid.UUID
53
+ Name string
54
+ Category string
55
+ Org string
56
+ Direction string
57
+ LeaderID uuid.UUID
58
+ LeaderEmpNo string
59
+ LeaderName string
60
+ MemberCount int
61
+ CreatedAt time.Time
62
+ SlidesUpdatedAt time.Time
63
+ }
64
+
65
+ // dbTime is the DATETIME(6) literal of an instant; the database stores UTC.
66
+ func dbTime(t time.Time) string { return t.UTC().Format("2006-01-02 15:04:05.000000") }
67
+
68
+ // ListVisible returns one page of the Ideas the viewer may see, in the requested order
69
+ // (id is the final tie-breaker), and the total number of matches. It is built
70
+ // on VisibleWhere like every other read. A page beyond the last is empty.
71
+ func ListVisible(ctx context.Context, q interface {
72
+ QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
73
+ QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
74
+ }, viewer uuid.UUID, isAdmin bool, f ListFilter, limit, offset int) ([]Summary, int, error) {
75
+ admin := 0
76
+ if isAdmin {
77
+ admin = 1
78
+ }
79
+ where := []string{VisibleWhere}
80
+ args := []any{admin, viewer.String()}
81
+ add := func(cond string, v any) {
82
+ where = append(where, cond)
83
+ args = append(args, v)
84
+ }
85
+ if f.Org != "" {
86
+ add("i.org = ?", f.Org)
87
+ }
88
+ if f.Category != "" {
89
+ add("i.category = ?", f.Category)
90
+ }
91
+ if f.Direction != "" {
92
+ add("i.direction = ?", f.Direction)
93
+ }
94
+ if f.LeaderEmpNo != "" {
95
+ add("u.emp_no = ?", f.LeaderEmpNo)
96
+ }
97
+ if f.Q != "" {
98
+ add("LOWER(i.name) LIKE LOWER(?) ESCAPE '"+likeEscape+"'", containsPattern(f.Q))
99
+ }
100
+ if f.CreatedFrom != nil {
101
+ add("i.created_at >= ?", dbTime(*f.CreatedFrom))
102
+ }
103
+ if f.CreatedTo != nil {
104
+ add("i.created_at < ?", dbTime(*f.CreatedTo))
105
+ }
106
+ cond := strings.Join(where, " AND ")
107
+
108
+ var total int
109
+ if err := q.QueryRowContext(ctx,
110
+ `SELECT COUNT(*) FROM idea i JOIN app_user u ON u.id = i.leader_id WHERE `+cond, args...).Scan(&total); err != nil {
111
+ return nil, 0, fmt.Errorf("count ideas: %w", err)
112
+ }
113
+
114
+ // TODO(B5-01): memberCount counts ACCEPTED idea_member rows (Leader excluded).
115
+ rows, err := q.QueryContext(ctx,
116
+ `SELECT i.id, i.name, i.category, i.org, i.direction, i.leader_id, u.emp_no, u.name,
117
+ 0, i.created_at, i.slides_updated_at
118
+ FROM idea i JOIN app_user u ON u.id = i.leader_id
119
+ WHERE `+cond+`
120
+ ORDER BY `+f.orderBy()+`
121
+ LIMIT ? OFFSET ?`, append(args, limit, offset)...)
122
+ if err != nil {
123
+ return nil, 0, fmt.Errorf("list ideas: %w", err)
124
+ }
125
+ defer func() { _ = rows.Close() }()
126
+ out := []Summary{}
127
+ for rows.Next() {
128
+ var s Summary
129
+ var id, lid string
130
+ if err := rows.Scan(&id, &s.Name, &s.Category, &s.Org, &s.Direction, &lid, &s.LeaderEmpNo,
131
+ &s.LeaderName, &s.MemberCount, &s.CreatedAt, &s.SlidesUpdatedAt); err != nil {
132
+ return nil, 0, fmt.Errorf("scan idea: %w", err)
133
+ }
134
+ if s.ID, err = uuid.Parse(id); err != nil {
135
+ return nil, 0, fmt.Errorf("idea id: %w", err)
136
+ }
137
+ if s.LeaderID, err = uuid.Parse(lid); err != nil {
138
+ return nil, 0, fmt.Errorf("idea leader id: %w", err)
139
+ }
140
+ out = append(out, s)
141
+ }
142
+ if err := rows.Err(); err != nil {
143
+ return nil, 0, fmt.Errorf("list ideas: %w", err)
144
+ }
145
+ return out, total, nil
146
+ }
@@ -0,0 +1,33 @@
1
+ package store
2
+
3
+ import "testing"
4
+
5
+ func TestContainsPattern(t *testing.T) {
6
+ for in, want := range map[string]string{
7
+ "abc": "%abc%",
8
+ "50%": "%50!%%",
9
+ "a_b": "%a!_b%",
10
+ "hi!": "%hi!!%",
11
+ "%_!": "%!%!_!!%",
12
+ `a\b`: `%a\b%`,
13
+ "綠能": "%綠能%",
14
+ "": "%%",
15
+ } {
16
+ if got := containsPattern(in); got != want {
17
+ t.Errorf("containsPattern(%q) = %q, want %q", in, got, want)
18
+ }
19
+ }
20
+ }
21
+
22
+ func TestOrderBy(t *testing.T) {
23
+ for f, want := range map[ListFilter]string{
24
+ {}: "i.created_at ASC, i.id ASC",
25
+ {Desc: true}: "i.created_at DESC, i.id DESC",
26
+ {SortName: true}: "i.name ASC, i.id ASC",
27
+ {SortName: true, Desc: true}: "i.name DESC, i.id DESC",
28
+ } {
29
+ if got := f.orderBy(); got != want {
30
+ t.Errorf("%+v: %q, want %q", f, got, want)
31
+ }
32
+ }
33
+ }