@johnnyboy11234/esg-idea-contest 0.3.0 → 0.3.2

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 (138) hide show
  1. package/backend/api/openapi.yaml +119 -0
  2. package/backend/cmd/server/main.go +36 -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/internal/passwords/NOTICE +36 -0
  13. package/backend/internal/account/internal/passwords/common-passwords.txt +10001 -0
  14. package/backend/internal/account/internal/passwords/passwords.go +37 -0
  15. package/backend/internal/account/internal/passwords/passwords_test.go +35 -0
  16. package/backend/internal/account/keyboard.go +75 -0
  17. package/backend/internal/account/keyboard_test.go +43 -0
  18. package/backend/internal/account/logging_integration_test.go +1 -1
  19. package/backend/internal/account/password.go +131 -0
  20. package/backend/internal/account/sequence.go +54 -0
  21. package/backend/internal/account/sequence_test.go +38 -0
  22. package/backend/internal/account/validate.go +4 -21
  23. package/backend/internal/api/api.gen.go +262 -0
  24. package/backend/internal/auth/internal/store/login_attempt.go +53 -0
  25. package/backend/internal/auth/internal/store/login_lock.go +77 -0
  26. package/backend/internal/auth/internal/store/login_throttle_audit.go +42 -0
  27. package/backend/internal/auth/internal/store/session.go +15 -7
  28. package/backend/internal/auth/login.go +16 -4
  29. package/backend/internal/auth/login_integration_test.go +14 -1
  30. package/backend/internal/auth/login_ip_integration_test.go +153 -0
  31. package/backend/internal/auth/login_lock_integration_test.go +205 -0
  32. package/backend/internal/auth/login_test.go +15 -0
  33. package/backend/internal/auth/login_throttle_audit_integration_test.go +88 -0
  34. package/backend/internal/auth/purge.go +93 -0
  35. package/backend/internal/auth/purge_integration_test.go +220 -0
  36. package/backend/internal/auth/purge_test.go +51 -0
  37. package/backend/internal/auth/session.go +0 -6
  38. package/backend/internal/auth/session_integration_test.go +3 -3
  39. package/backend/internal/auth/throttle.go +122 -0
  40. package/backend/internal/deadline/gate.go +80 -0
  41. package/backend/internal/deadline/gate_integration_test.go +67 -0
  42. package/backend/internal/deadline/gate_test.go +61 -0
  43. package/backend/internal/idea/create.go +356 -0
  44. package/backend/internal/idea/create_integration_test.go +680 -0
  45. package/backend/internal/idea/download.go +193 -0
  46. package/backend/internal/idea/download_integration_test.go +311 -0
  47. package/backend/internal/idea/download_test.go +25 -0
  48. package/backend/internal/idea/handler.go +151 -0
  49. package/backend/internal/idea/handler_integration_test.go +107 -0
  50. package/backend/internal/idea/integration_test.go +149 -0
  51. package/backend/internal/idea/internal/store/list.go +146 -0
  52. package/backend/internal/idea/internal/store/list_test.go +33 -0
  53. package/backend/internal/idea/internal/store/store.go +166 -0
  54. package/backend/internal/idea/list.go +166 -0
  55. package/backend/internal/idea/list_integration_test.go +252 -0
  56. package/backend/internal/idea/list_test.go +96 -0
  57. package/backend/internal/idea/service.go +54 -0
  58. package/backend/internal/server/api.go +22 -0
  59. package/backend/internal/server/binding.go +4 -0
  60. package/backend/internal/server/public.go +1 -0
  61. package/backend/internal/server/server.go +20 -1
  62. package/backend/internal/server/slides.go +21 -0
  63. package/backend/internal/server/slides_test.go +82 -0
  64. package/backend/internal/shared/config/config.go +44 -1
  65. package/backend/internal/shared/config/config_test.go +21 -13
  66. package/backend/internal/shared/db/intx_integration_test.go +179 -0
  67. package/backend/internal/shared/dbtest/dbtest.go +21 -3
  68. package/backend/internal/shared/dbtest/faults.go +162 -0
  69. package/backend/internal/shared/job/job.go +54 -0
  70. package/backend/internal/shared/job/job_test.go +105 -0
  71. package/backend/internal/shared/web/multipart.go +175 -0
  72. package/backend/internal/shared/web/multipart_test.go +169 -0
  73. package/backend/internal/shared/web/problem.go +1 -0
  74. package/backend/internal/shared/web/request.go +42 -1
  75. package/backend/internal/shared/web/transport.go +4 -3
  76. package/backend/internal/shared/web/web_test.go +36 -0
  77. package/backend/internal/storage/expectedfilename.go +43 -0
  78. package/backend/internal/storage/expectedfilename_test.go +16 -0
  79. package/backend/internal/storage/internal/store/pending.go +41 -0
  80. package/backend/internal/storage/minio.go +139 -0
  81. package/backend/internal/storage/minio_integration_test.go +87 -0
  82. package/backend/internal/storage/minio_test.go +84 -0
  83. package/backend/internal/storage/pending.go +144 -0
  84. package/backend/internal/storage/pending_integration_test.go +324 -0
  85. package/backend/internal/storage/pending_test.go +23 -0
  86. package/backend/internal/storage/validator.go +100 -0
  87. package/backend/internal/storage/validator_test.go +95 -0
  88. package/backend/test/arch/arch_test.go +5 -1
  89. package/backend/test/contract/expected_filename_test.go +60 -0
  90. package/backend/test/contract/password_test.go +46 -0
  91. package/backend/test/matrix/load_test.go +12 -0
  92. package/backend/test/matrix/matrix_integration_test.go +92 -5
  93. package/backend/test/migration/pending_file_deletion_test.go +46 -0
  94. package/docs/API/api-endpoints.md +1 -1
  95. package/docs/API/error-codes.md +1 -1
  96. package/docs/Architecture/Architecture.md +1 -1
  97. package/docs/Architecture/Backend.md +2 -1
  98. package/frontend/Dockerfile +2 -0
  99. package/frontend/package-lock.json +1 -0
  100. package/frontend/package.json +1 -0
  101. package/frontend/src/app/App.tsx +11 -2
  102. package/frontend/src/app/pages/ideaCreate.test.tsx +308 -0
  103. package/frontend/src/app/pages/ideas.tsx +23 -9
  104. package/frontend/src/app/pages/login.test.tsx +38 -1
  105. package/frontend/src/app/pages/register.test.tsx +32 -0
  106. package/frontend/src/features/auth/IdleWarning.test.tsx +82 -0
  107. package/frontend/src/features/auth/IdleWarning.tsx +66 -0
  108. package/frontend/src/features/auth/LoginForm.tsx +21 -3
  109. package/frontend/src/features/auth/PasswordChecklist.test.tsx +73 -0
  110. package/frontend/src/features/auth/PasswordChecklist.tsx +96 -0
  111. package/frontend/src/features/auth/RegisterForm.tsx +11 -2
  112. package/frontend/src/features/auth/common-passwords.ts +31 -0
  113. package/frontend/src/features/auth/password-policy.test.ts +64 -0
  114. package/frontend/src/features/auth/password-policy.ts +168 -0
  115. package/frontend/src/features/auth/useCountdown.test.tsx +33 -0
  116. package/frontend/src/features/auth/useCountdown.ts +28 -0
  117. package/frontend/src/features/idea/CreateIdeaForm.tsx +279 -0
  118. package/frontend/src/features/idea/create.ts +123 -0
  119. package/frontend/src/shared/api/activity.ts +20 -0
  120. package/frontend/src/shared/api/http.ts +2 -0
  121. package/frontend/src/shared/api/index.ts +1 -0
  122. package/frontend/src/shared/api/types.generated.ts +319 -1
  123. package/frontend/src/shared/i18n/locales/en.ts +46 -4
  124. package/frontend/src/shared/i18n/locales/zh-TW.ts +46 -4
  125. package/frontend/src/shared/slides/index.ts +74 -0
  126. package/frontend/src/shared/slides/slides.test.ts +51 -0
  127. package/frontend/src/shared/ui/copy-button.tsx +52 -0
  128. package/frontend/src/shared/ui/native-select.tsx +27 -0
  129. package/frontend/src/shared/ui/textarea.tsx +19 -0
  130. package/frontend/src/test-setup.ts +37 -2
  131. package/frontend/vite.config.ts +3 -1
  132. package/package.json +1 -1
  133. package/testdata/expected-filename.json +93 -0
  134. package/testdata/password.json +582 -0
  135. package/testdata/permission-matrix.json +71 -0
  136. package/testdata/schema/expected-filename.schema.json +32 -0
  137. package/testdata/schema/password.schema.json +59 -0
  138. package/testdata/schema/permission-matrix.schema.json +22 -1
@@ -0,0 +1,166 @@
1
+ package idea
2
+
3
+ import (
4
+ "math"
5
+ "net/http"
6
+ "regexp"
7
+ "slices"
8
+ "time"
9
+
10
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/api"
11
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
12
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
13
+ )
14
+
15
+ const (
16
+ defaultPageSize = 20
17
+ maxPageSize = 100
18
+ maxPage = math.MaxInt32 // keeps (page-1)*size far from int overflow
19
+ )
20
+
21
+ // Closed value sets (PRD 6.6); the idea table's CHECKs list the same values.
22
+ var (
23
+ categories = []string{"COOL_IDEA", "OUTSTANDING_IMPACT"}
24
+ orgs = []string{"AAID", "BSID", "ICSD", "TSID", "PLED", "DEID"}
25
+ directions = []string{"GREEN_MANUFACTURING", "RESPONSIBLE_SUPPLY_CHAIN", "INCLUSIVE_WORKPLACE",
26
+ "TALENT_DEVELOPMENT", "CARING_FOR_DISADVANTAGED"}
27
+ sorts = []string{"createdAt", "name"}
28
+ order = []string{"asc", "desc"}
29
+ empNoFormat = regexp.MustCompile(`^[0-9]{6}$`)
30
+ )
31
+
32
+ type summary struct {
33
+ ID string `json:"id"`
34
+ Name string `json:"name"`
35
+ Category string `json:"category"`
36
+ Org string `json:"org"`
37
+ Direction string `json:"direction"`
38
+ Leader person `json:"leader"`
39
+ MemberCount int `json:"memberCount"`
40
+ MyRelation any `json:"myRelation"`
41
+ CreatedAt string `json:"createdAt"`
42
+ SlidesUpdatedAt string `json:"slidesUpdatedAt"`
43
+ }
44
+
45
+ type page struct {
46
+ Items []summary `json:"items"`
47
+ Page int `json:"page"`
48
+ Size int `json:"size"`
49
+ Total int `json:"total"`
50
+ }
51
+
52
+ // listQuery is the validated query of GET /ideas.
53
+ type listQuery struct {
54
+ page, size int
55
+ filter ListFilter
56
+ }
57
+
58
+ // parseList validates every parameter and reports all failures at once, in
59
+ // parameter order, each parameter with its first error (PRD 6.2, 6.6). No
60
+ // invalid value is ignored.
61
+ func parseList(p api.ListIdeasParams) (listQuery, []web.FieldError) {
62
+ q := listQuery{page: 1, size: defaultPageSize}
63
+ var errs []web.FieldError
64
+ bad := func(field string, code web.ErrorCode) {
65
+ errs = append(errs, web.FieldError{Field: field, Code: code})
66
+ }
67
+ if p.Page != nil {
68
+ if *p.Page < 1 || *p.Page > maxPage {
69
+ bad("page", web.CodeOutOfRange)
70
+ } else {
71
+ q.page = *p.Page
72
+ }
73
+ }
74
+ if p.Size != nil {
75
+ if *p.Size < 1 || *p.Size > maxPageSize {
76
+ bad("size", web.CodeOutOfRange)
77
+ } else {
78
+ q.size = *p.Size
79
+ }
80
+ }
81
+ enum := func(field string, v *string, allowed []string, dst *string) {
82
+ if v == nil {
83
+ return
84
+ }
85
+ if !slices.Contains(allowed, *v) {
86
+ bad(field, web.CodeInvalidEnum)
87
+ return
88
+ }
89
+ *dst = *v
90
+ }
91
+ enum("org", p.Org, orgs, &q.filter.Org)
92
+ enum("category", p.Category, categories, &q.filter.Category)
93
+ enum("direction", p.Direction, directions, &q.filter.Direction)
94
+ if p.LeaderEmpNo != nil {
95
+ if !empNoFormat.MatchString(*p.LeaderEmpNo) {
96
+ bad("leaderEmpNo", web.CodeInvalidFormat)
97
+ } else {
98
+ q.filter.LeaderEmpNo = *p.LeaderEmpNo
99
+ }
100
+ }
101
+ instant := func(field string, v *string) *time.Time {
102
+ if v == nil {
103
+ return nil
104
+ }
105
+ // RFC 3339 requires the offset or Z, so a bare date or local time fails.
106
+ t, err := time.Parse(time.RFC3339, *v)
107
+ if err != nil {
108
+ bad(field, web.CodeInvalidFormat)
109
+ return nil
110
+ }
111
+ return &t
112
+ }
113
+ q.filter.CreatedFrom = instant("createdFrom", p.CreatedFrom)
114
+ q.filter.CreatedTo = instant("createdTo", p.CreatedTo)
115
+ if p.Q != nil {
116
+ q.filter.Q = *p.Q
117
+ }
118
+ sort := "createdAt"
119
+ enum("sort", p.Sort, sorts, &sort)
120
+ q.filter.SortName = sort == "name"
121
+ dir := "desc"
122
+ if q.filter.SortName {
123
+ dir = "asc"
124
+ }
125
+ enum("order", p.Order, order, &dir)
126
+ q.filter.Desc = dir == "desc"
127
+ return q, errs
128
+ }
129
+
130
+ // ListIdeas serves GET /ideas.
131
+ func (h Handler) ListIdeas(w http.ResponseWriter, r *http.Request, params api.ListIdeasParams) {
132
+ p, ok := security.PrincipalFrom(r.Context())
133
+ if !ok {
134
+ h.Fail(w, r, web.NewError(web.CodeUnauthenticated, "authentication required"))
135
+ return
136
+ }
137
+ q, errs := parseList(params)
138
+ if len(errs) > 0 {
139
+ e := web.NewError(web.CodeValidationFailed, "invalid query parameter")
140
+ e.Fields = errs
141
+ h.Fail(w, r, e)
142
+ return
143
+ }
144
+ rows, total, err := h.Service.List(r.Context(), p, q.filter, q.size, (q.page-1)*q.size)
145
+ if err != nil {
146
+ h.Fail(w, r, err)
147
+ return
148
+ }
149
+ out := page{Items: make([]summary, 0, len(rows)), Page: q.page, Size: q.size, Total: total}
150
+ for _, s := range rows {
151
+ var rel any // null for Admin
152
+ if !p.IsAdmin() {
153
+ rel = "MEMBER"
154
+ if s.LeaderID == p.UserID {
155
+ rel = "LEADER"
156
+ }
157
+ }
158
+ out.Items = append(out.Items, summary{
159
+ ID: s.ID.String(), Name: s.Name, Category: s.Category, Org: s.Org, Direction: s.Direction,
160
+ Leader: person{EmpNo: s.LeaderEmpNo, Name: s.LeaderName},
161
+ MemberCount: s.MemberCount, MyRelation: rel,
162
+ CreatedAt: web.FormatTime(s.CreatedAt), SlidesUpdatedAt: web.FormatTime(s.SlidesUpdatedAt),
163
+ })
164
+ }
165
+ web.WriteJSON(w, http.StatusOK, out)
166
+ }
@@ -0,0 +1,252 @@
1
+ //go:build integration
2
+
3
+ package idea_test
4
+
5
+ import (
6
+ "context"
7
+ "encoding/json"
8
+ "fmt"
9
+ "net/http"
10
+ "net/http/httptest"
11
+ "net/url"
12
+ "slices"
13
+ "testing"
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 TestListIdeasHandler(t *testing.T) {
24
+ d := dbtest.New(t)
25
+ a := addUser(t, d, "100001", "USER")
26
+ b := addUser(t, d, "100002", "USER")
27
+ admin := addUser(t, d, "900001", "ADMIN")
28
+
29
+ // a owns 3 Ideas, b owns 1 (different org/category). created_at is set explicitly.
30
+ mk := func(leader security.Principal, name, org, cat, at string) string {
31
+ n := newIdea(leader.UserID, name)
32
+ n.Org, n.Category = org, cat
33
+ if err := insert(d, n); err != nil {
34
+ t.Fatal(err)
35
+ }
36
+ if _, err := d.Exec(`UPDATE idea SET created_at = ? WHERE id = ?`, at, n.ID.String()); err != nil {
37
+ t.Fatal(err)
38
+ }
39
+ return n.ID.String()
40
+ }
41
+ a1 := mk(a, "A1", "BSID", "COOL_IDEA", "2026-03-01 00:00:00.000000")
42
+ a2 := mk(a, "A2", "BSID", "COOL_IDEA", "2026-03-02 00:00:00.000000")
43
+ a3 := mk(a, "A3", "AAID", "OUTSTANDING_IMPACT", "2026-03-03 00:00:00.000000")
44
+ b1 := mk(b, "B1", "AAID", "COOL_IDEA", "2026-03-04 00:00:00.000000")
45
+
46
+ aud := audit.New(d, nil)
47
+ errs := server.ErrorAdapter{Audit: aud}
48
+ h := idea.Handler{Service: &idea.Service{DB: d, Audit: aud}, ExpectedFilename: expectedBase, Fail: errs.Write}
49
+
50
+ call := func(p security.Principal, query string) (int, map[string]any) {
51
+ v, _ := url.ParseQuery(query)
52
+ var params api.ListIdeasParams
53
+ get := func(k string) *string {
54
+ if !v.Has(k) {
55
+ return nil
56
+ }
57
+ s := v.Get(k)
58
+ return &s
59
+ }
60
+ geti := func(k string) *int {
61
+ if !v.Has(k) {
62
+ return nil
63
+ }
64
+ var n int
65
+ _, _ = fmt.Sscan(v.Get(k), &n)
66
+ return &n
67
+ }
68
+ params.Page, params.Size = geti("page"), geti("size")
69
+ params.Org, params.Category, params.Direction = get("org"), get("category"), get("direction")
70
+ params.LeaderEmpNo, params.CreatedFrom, params.CreatedTo = get("leaderEmpNo"), get("createdFrom"), get("createdTo")
71
+ params.Q, params.Sort, params.Order = get("q"), get("sort"), get("order")
72
+ req := httptest.NewRequest(http.MethodGet, "/ideas?"+query, nil)
73
+ req = req.WithContext(security.WithPrincipal(context.Background(), p))
74
+ rec := httptest.NewRecorder()
75
+ h.ListIdeas(rec, req, params)
76
+ var body map[string]any
77
+ _ = json.Unmarshal(rec.Body.Bytes(), &body)
78
+ return rec.Code, body
79
+ }
80
+ ids := func(body map[string]any) (out []string) {
81
+ for _, it := range body["items"].([]any) {
82
+ out = append(out, it.(map[string]any)["id"].(string))
83
+ }
84
+ return out
85
+ }
86
+ eq := func(got, want []string) bool {
87
+ if len(got) != len(want) {
88
+ return false
89
+ }
90
+ for i := range got {
91
+ if got[i] != want[i] {
92
+ return false
93
+ }
94
+ }
95
+ return true
96
+ }
97
+
98
+ // User sees only own Ideas, newest first; myRelation LEADER; envelope.
99
+ code, body := call(a, "")
100
+ if code != 200 || !eq(ids(body), []string{a3, a2, a1}) || body["total"].(float64) != 3 ||
101
+ body["page"].(float64) != 1 || body["size"].(float64) != 20 {
102
+ t.Fatalf("user list: %d %v", code, body)
103
+ }
104
+ if rel := body["items"].([]any)[0].(map[string]any)["myRelation"]; rel != "LEADER" {
105
+ t.Errorf("myRelation = %v", rel)
106
+ }
107
+ // Admin sees all, myRelation null.
108
+ code, body = call(admin, "")
109
+ if code != 200 || !eq(ids(body), []string{b1, a3, a2, a1}) {
110
+ t.Fatalf("admin list: %d %v", code, body)
111
+ }
112
+ if rel, ok := body["items"].([]any)[0].(map[string]any)["myRelation"]; !ok || rel != nil {
113
+ t.Errorf("admin myRelation = %v", rel)
114
+ }
115
+ // A user with nothing gets [] not null.
116
+ if _, body = call(addUser(t, d, "100003", "USER"), ""); body["items"] == nil || body["total"].(float64) != 0 {
117
+ t.Errorf("empty: %v", body)
118
+ }
119
+
120
+ // Paging: size 2, page 2; beyond last page is 200 and empty with total intact.
121
+ _, body = call(admin, "size=2&page=2")
122
+ if !eq(ids(body), []string{a2, a1}) || body["total"].(float64) != 4 {
123
+ t.Errorf("page 2: %v", body)
124
+ }
125
+ code, body = call(admin, "size=2&page=3")
126
+ if code != 200 || len(ids(body)) != 0 || body["total"].(float64) != 4 {
127
+ t.Errorf("page 3: %d %v", code, body)
128
+ }
129
+
130
+ // Filters.
131
+ for q, want := range map[string][]string{
132
+ "org=BSID": {a2, a1},
133
+ "category=OUTSTANDING_IMPACT": {a3},
134
+ "leaderEmpNo=100002": {b1},
135
+ "org=AAID&category=COOL_IDEA": {b1},
136
+ "direction=TALENT_DEVELOPMENT": {b1, a3, a2, a1},
137
+ "direction=GREEN_MANUFACTURING": nil,
138
+ // [from, to) is half-open: 03-02 is included, 03-04 (== to) is not.
139
+ "createdFrom=2026-03-02T00:00:00Z&createdTo=2026-03-04T00:00:00Z": {a3, a2},
140
+ // Offset is honoured: 08:00+08:00 is 00:00Z.
141
+ "createdFrom=2026-03-04T08:00:00%2B08:00": {b1},
142
+ } {
143
+ if _, body = call(admin, q); !eq(ids(body), want) {
144
+ t.Errorf("%s: got %v want %v", q, ids(body), want)
145
+ }
146
+ }
147
+ // Filters never widen visibility.
148
+ if _, body = call(a, "leaderEmpNo=100002"); len(ids(body)) != 0 {
149
+ t.Errorf("user saw another Leader's Idea: %v", body)
150
+ }
151
+
152
+ // 422 with the right field code; top-level VALIDATION_FAILED.
153
+ for q, field := range map[string]string{
154
+ "size=101": "size", "page=0": "page", "org=XXXX": "org", "category=": "category",
155
+ "createdFrom=2026-03-01": "createdFrom", "leaderEmpNo=1": "leaderEmpNo",
156
+ "sort=id": "sort", "sort=": "sort", "order=up": "order", "order=ASC": "order",
157
+ } {
158
+ code, body = call(admin, q)
159
+ if code != 422 || body["code"] != "VALIDATION_FAILED" ||
160
+ body["errors"].([]any)[0].(map[string]any)["field"] != field {
161
+ t.Errorf("%s: %d %v", q, code, body)
162
+ }
163
+ }
164
+ if _, body = call(admin, "org=X"); body["errors"].([]any)[0].(map[string]any)["code"] != "INVALID_ENUM" {
165
+ t.Errorf("org code: %v", body)
166
+ }
167
+ }
168
+
169
+ func TestListIdeasSearchSort(t *testing.T) {
170
+ d := dbtest.New(t)
171
+ u := addUser(t, d, "100001", "USER")
172
+ admin := addUser(t, d, "900001", "ADMIN")
173
+ svc := &idea.Service{DB: d}
174
+
175
+ mk := func(name, at string) string {
176
+ n := newIdea(u.UserID, name)
177
+ if err := insert(d, n); err != nil {
178
+ t.Fatal(err)
179
+ }
180
+ if _, err := d.Exec(`UPDATE idea SET created_at = ? WHERE id = ?`, at, n.ID.String()); err != nil {
181
+ t.Fatal(err)
182
+ }
183
+ return n.ID.String()
184
+ }
185
+ pct := mk("100% green", "2026-03-01 00:00:00.000000")
186
+ und := mk("a_b", "2026-03-02 00:00:00.000000")
187
+ axb := mk("axb", "2026-03-03 00:00:00.000000")
188
+ bang := mk("Hi!", "2026-03-04 00:00:00.000000")
189
+ upper := mk("ZETA Solar", "2026-03-05 00:00:00.000000")
190
+ zh := mk("綠能創意", "2026-03-06 00:00:00.000000")
191
+
192
+ names := func(f idea.ListFilter) (out []string) {
193
+ rows, _, err := svc.List(context.Background(), admin, f, 100, 0)
194
+ if err != nil {
195
+ t.Fatal(err)
196
+ }
197
+ for _, r := range rows {
198
+ out = append(out, r.ID.String())
199
+ }
200
+ return out
201
+ }
202
+ same := func(got, want []string) bool { return fmt.Sprint(got) == fmt.Sprint(want) }
203
+
204
+ for _, tc := range []struct {
205
+ q string
206
+ want []string
207
+ }{
208
+ {"%", []string{pct}}, // not a wildcard
209
+ {"_", []string{und}}, // not a wildcard: axb does not match
210
+ {"a_b", []string{und}}, // axb does not match
211
+ {"!", []string{bang}}, // the escape character itself is literal
212
+ {"zeta", []string{upper}}, // case-insensitive
213
+ {"SOLAR", []string{upper}},
214
+ {"綠能", []string{zh}},
215
+ {"nothing", nil},
216
+ {"", []string{zh, upper, bang, axb, und, pct}}, // empty term is no filter
217
+ } {
218
+ if got := names(idea.ListFilter{Q: tc.q, Desc: true}); !same(got, tc.want) {
219
+ t.Errorf("q=%q: got %v want %v", tc.q, got, tc.want)
220
+ }
221
+ }
222
+
223
+ // created_at both ways; name by code point (digits < upper < lower < CJK).
224
+ if got := names(idea.ListFilter{Desc: false}); !same(got, []string{pct, und, axb, bang, upper, zh}) {
225
+ t.Errorf("createdAt asc: %v", got)
226
+ }
227
+ if got := names(idea.ListFilter{SortName: true}); !same(got, []string{pct, bang, upper, und, axb, zh}) {
228
+ t.Errorf("name asc: %v", got)
229
+ }
230
+ if got := names(idea.ListFilter{SortName: true, Desc: true}); !same(got, []string{zh, axb, und, upper, bang, pct}) {
231
+ t.Errorf("name desc: %v", got)
232
+ }
233
+
234
+ // Equal sort keys fall back to id (MariaDB UUID order, not text order), in the sort direction. Same created_at for all.
235
+ if _, err := d.Exec(`UPDATE idea SET created_at = '2026-04-01 00:00:00.000000'`); err != nil {
236
+ t.Fatal(err)
237
+ }
238
+ asc, desc := names(idea.ListFilter{}), names(idea.ListFilter{Desc: true})
239
+ if len(asc) != 6 {
240
+ t.Errorf("tie-break asc: %v", asc)
241
+ }
242
+ slices.Reverse(desc)
243
+ if !same(asc, desc) {
244
+ t.Errorf("tie-break desc is not the reverse of asc: %v vs %v", asc, desc)
245
+ }
246
+
247
+ // A User's search never reaches another Leader's Ideas.
248
+ other := addUser(t, d, "100002", "USER")
249
+ if rows, _, err := svc.List(context.Background(), other, idea.ListFilter{Q: "zeta"}, 10, 0); err != nil || len(rows) != 0 {
250
+ t.Errorf("search widened visibility: %v %v", rows, err)
251
+ }
252
+ }
@@ -0,0 +1,96 @@
1
+ package idea
2
+
3
+ import (
4
+ "testing"
5
+ "time"
6
+
7
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/api"
8
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
9
+ )
10
+
11
+ func ptr[T any](v T) *T { return &v }
12
+
13
+ func TestParseListDefaults(t *testing.T) {
14
+ q, errs := parseList(api.ListIdeasParams{})
15
+ if len(errs) != 0 || q.page != 1 || q.size != 20 {
16
+ t.Fatalf("q=%+v errs=%v", q, errs)
17
+ }
18
+ }
19
+
20
+ func TestParseListErrors(t *testing.T) {
21
+ tests := []struct {
22
+ name string
23
+ p api.ListIdeasParams
24
+ want []web.FieldError
25
+ }{
26
+ {"page 0", api.ListIdeasParams{Page: ptr(0)}, []web.FieldError{{Field: "page", Code: web.CodeOutOfRange}}},
27
+ {"size 101", api.ListIdeasParams{Size: ptr(101)}, []web.FieldError{{Field: "size", Code: web.CodeOutOfRange}}},
28
+ {"size 0", api.ListIdeasParams{Size: ptr(0)}, []web.FieldError{{Field: "size", Code: web.CodeOutOfRange}}},
29
+ {"bad org", api.ListIdeasParams{Org: ptr("XXXX")}, []web.FieldError{{Field: "org", Code: web.CodeInvalidEnum}}},
30
+ {"empty org not ignored", api.ListIdeasParams{Org: ptr("")}, []web.FieldError{{Field: "org", Code: web.CodeInvalidEnum}}},
31
+ {"lower-case category", api.ListIdeasParams{Category: ptr("cool_idea")}, []web.FieldError{{Field: "category", Code: web.CodeInvalidEnum}}},
32
+ {"bad direction", api.ListIdeasParams{Direction: ptr("X")}, []web.FieldError{{Field: "direction", Code: web.CodeInvalidEnum}}},
33
+ {"short emp no", api.ListIdeasParams{LeaderEmpNo: ptr("12345")}, []web.FieldError{{Field: "leaderEmpNo", Code: web.CodeInvalidFormat}}},
34
+ {"date only", api.ListIdeasParams{CreatedFrom: ptr("2026-01-01")}, []web.FieldError{{Field: "createdFrom", Code: web.CodeInvalidFormat}}},
35
+ {"no offset", api.ListIdeasParams{CreatedTo: ptr("2026-01-01T00:00:00")}, []web.FieldError{{Field: "createdTo", Code: web.CodeInvalidFormat}}},
36
+ {"all, in parameter order", api.ListIdeasParams{Size: ptr(500), Page: ptr(-1), Org: ptr("x"), CreatedTo: ptr("nope")},
37
+ []web.FieldError{{Field: "page", Code: web.CodeOutOfRange}, {Field: "size", Code: web.CodeOutOfRange},
38
+ {Field: "org", Code: web.CodeInvalidEnum}, {Field: "createdTo", Code: web.CodeInvalidFormat}}},
39
+ }
40
+ for _, tc := range tests {
41
+ t.Run(tc.name, func(t *testing.T) {
42
+ _, got := parseList(tc.p)
43
+ if len(got) != len(tc.want) {
44
+ t.Fatalf("got %v, want %v", got, tc.want)
45
+ }
46
+ for i := range got {
47
+ if got[i] != tc.want[i] {
48
+ t.Errorf("[%d] got %v, want %v", i, got[i], tc.want[i])
49
+ }
50
+ }
51
+ })
52
+ }
53
+ }
54
+
55
+ func TestParseListValid(t *testing.T) {
56
+ q, errs := parseList(api.ListIdeasParams{
57
+ Page: ptr(3), Size: ptr(100), Org: ptr("BSID"), Category: ptr("COOL_IDEA"), Direction: ptr("TALENT_DEVELOPMENT"),
58
+ LeaderEmpNo: ptr("000123"), CreatedFrom: ptr("2026-01-01T08:00:00+08:00"), CreatedTo: ptr("2026-02-01T00:00:00.5Z"),
59
+ })
60
+ if len(errs) != 0 {
61
+ t.Fatal(errs)
62
+ }
63
+ if q.page != 3 || q.size != 100 || q.filter.Org != "BSID" || q.filter.LeaderEmpNo != "000123" {
64
+ t.Errorf("q = %+v", q)
65
+ }
66
+ if !q.filter.CreatedFrom.Equal(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) {
67
+ t.Errorf("from = %v", q.filter.CreatedFrom)
68
+ }
69
+ }
70
+
71
+ func TestParseListSortOrder(t *testing.T) {
72
+ for _, tc := range []struct {
73
+ sort, order *string
74
+ name, desc bool
75
+ }{
76
+ {nil, nil, false, true},
77
+ {ptr("createdAt"), nil, false, true},
78
+ {ptr("createdAt"), ptr("asc"), false, false},
79
+ {ptr("name"), nil, true, false},
80
+ {ptr("name"), ptr("desc"), true, true},
81
+ {nil, ptr("asc"), false, false},
82
+ } {
83
+ q, errs := parseList(api.ListIdeasParams{Sort: tc.sort, Order: tc.order})
84
+ if len(errs) != 0 || q.filter.SortName != tc.name || q.filter.Desc != tc.desc {
85
+ t.Errorf("%v %v: %+v %v", tc.sort, tc.order, q.filter, errs)
86
+ }
87
+ }
88
+ _, errs := parseList(api.ListIdeasParams{Sort: ptr("id"), Order: ptr("DESC"), Q: ptr("x")})
89
+ if len(errs) != 2 || errs[0] != (web.FieldError{Field: "sort", Code: web.CodeInvalidEnum}) ||
90
+ errs[1] != (web.FieldError{Field: "order", Code: web.CodeInvalidEnum}) {
91
+ t.Errorf("errs = %v", errs)
92
+ }
93
+ if q, _ := parseList(api.ListIdeasParams{Q: ptr("50%_")}); q.filter.Q != "50%_" {
94
+ t.Errorf("q = %q", q.filter.Q)
95
+ }
96
+ }
@@ -0,0 +1,54 @@
1
+ // Package idea owns the idea table and is the only way to read an Idea
2
+ // (Backend.md 8): every read goes through the visibility query.
3
+ package idea
4
+
5
+ import (
6
+ "context"
7
+ "database/sql"
8
+ "errors"
9
+
10
+ "github.com/google/uuid"
11
+
12
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
13
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/deadline"
14
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/idea/internal/store"
15
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
16
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
17
+ )
18
+
19
+ // Idea is the module's read model of one Idea.
20
+ type Idea = store.Idea
21
+
22
+ // Service reads and creates Ideas.
23
+ type Service struct {
24
+ DB *sql.DB
25
+ Audit *audit.Service
26
+ // Gate, Slides and Aborter are needed by Create only; Objects by Create
27
+ // and Download.
28
+ Gate deadline.SubmissionGate
29
+ Slides SlideRules
30
+ Objects ObjectStore
31
+ Aborter UploadAborter
32
+ }
33
+
34
+ // Get returns the Idea if the caller may see it. A missing Idea and a hidden
35
+ // one both return the same NOT_FOUND error; the handler audits it as DENIED.
36
+ func (s *Service) Get(ctx context.Context, id uuid.UUID, caller security.Principal) (Idea, error) {
37
+ got, err := store.GetVisible(ctx, s.DB, id, caller.UserID, caller.IsAdmin())
38
+ if errors.Is(err, store.ErrNotFound) {
39
+ return Idea{}, web.NewError(web.CodeNotFound, "")
40
+ }
41
+ return got, err
42
+ }
43
+
44
+ // Summary is the module's read model of one list row.
45
+ type Summary = store.Summary
46
+
47
+ // ListFilter is the validated filter set of the list.
48
+ type ListFilter = store.ListFilter
49
+
50
+ // List returns one page of the Ideas the caller may see and the total match
51
+ // count. limit and offset are already validated by the handler.
52
+ func (s *Service) List(ctx context.Context, caller security.Principal, f ListFilter, limit, offset int) ([]Summary, int, error) {
53
+ return store.ListVisible(ctx, s.DB, caller.UserID, caller.IsAdmin(), f, limit, offset)
54
+ }
@@ -7,6 +7,7 @@ import (
7
7
  "github.com/brian-huanggg/AwardRegistration/backend/internal/api"
8
8
  "github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
9
9
  "github.com/brian-huanggg/AwardRegistration/backend/internal/deadline"
10
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/idea"
10
11
  )
11
12
 
12
13
  // API composes the module handlers into api.ServerInterface. Operations whose
@@ -19,6 +20,7 @@ type API struct {
19
20
  Me auth.MeHandler
20
21
  Account account.Handler
21
22
  Deadline deadline.Handler
23
+ Ideas idea.Handler
22
24
  }
23
25
 
24
26
  // GetDeadline serves GET /deadline.
@@ -42,3 +44,23 @@ func (a API) Logout(w http.ResponseWriter, r *http.Request, _ api.LogoutParams)
42
44
 
43
45
  // GetMe serves GET /auth/me.
44
46
  func (a API) GetMe(w http.ResponseWriter, r *http.Request) { a.Me.GetMe(w, r) }
47
+
48
+ // GetIdea serves GET /ideas/{id}.
49
+ func (a API) GetIdea(w http.ResponseWriter, r *http.Request, id api.IdeaId) {
50
+ a.Ideas.GetIdea(w, r, id)
51
+ }
52
+
53
+ // CreateIdea serves POST /ideas.
54
+ func (a API) CreateIdea(w http.ResponseWriter, r *http.Request, _ api.CreateIdeaParams) {
55
+ a.Ideas.CreateIdea(w, r)
56
+ }
57
+
58
+ // ListIdeas serves GET /ideas.
59
+ func (a API) ListIdeas(w http.ResponseWriter, r *http.Request, params api.ListIdeasParams) {
60
+ a.Ideas.ListIdeas(w, r, params)
61
+ }
62
+
63
+ // DownloadSlides serves GET /ideas/{id}/slides.
64
+ func (a API) DownloadSlides(w http.ResponseWriter, r *http.Request, id api.IdeaId) {
65
+ a.Ideas.DownloadSlides(w, r, id)
66
+ }
@@ -18,6 +18,10 @@ func Mount(si api.ServerInterface) func(chi.Router) {
18
18
  BaseRouter: r,
19
19
  ErrorHandlerFunc: BindingError,
20
20
  })
21
+ // HEAD on the download must never run the GET handler, which writes an
22
+ // audit row (D47). chi already answers 405; the explicit route keeps
23
+ // the rule independent of that router default (D59).
24
+ r.Head(SlidesPath, web.MethodNotAllowed)
21
25
  }
22
26
  }
23
27
 
@@ -31,6 +31,7 @@ var WriteActions = map[string]string{
31
31
  http.MethodPost + " /api/v1/auth/login": "AUTH_LOGIN",
32
32
  http.MethodPost + " /api/v1/auth/register": "AUTH_REGISTER",
33
33
  http.MethodPost + " /api/v1/auth/logout": "AUTH_LOGOUT",
34
+ http.MethodPost + " /api/v1/ideas": "IDEA_CREATE",
34
35
  }
35
36
 
36
37
  // ActionFor returns the audit action of r, or "" when unknown.
@@ -6,6 +6,7 @@ import (
6
6
  "database/sql"
7
7
  "log/slog"
8
8
  "net/http"
9
+ "strings"
9
10
  "sync/atomic"
10
11
  "time"
11
12
 
@@ -32,13 +33,31 @@ func NewPublic(log *slog.Logger, mount func(chi.Router), extra ...func(http.Hand
32
33
  }
33
34
  // The chain wraps the router instead of using r.Use: chi skips r.Use
34
35
  // middleware (also for NotFound) while no route is registered.
35
- h := web.AcceptCheck(r)
36
+ h := web.AcceptCheck(r, IsFileDownload)
36
37
  for i := len(extra) - 1; i >= 0; i-- {
37
38
  h = extra[i](h)
38
39
  }
39
40
  return web.RequestID(web.AccessLog(log)(web.Recover(log)(h)))
40
41
  }
41
42
 
43
+ // SlidesPath is the slide download route under /api/v1.
44
+ const SlidesPath = "/api/v1/ideas/{id}/slides"
45
+
46
+ // IsFileDownload reports whether r targets the slide download, which serves a
47
+ // file and therefore skips the JSON Accept check. HEAD is included so that it
48
+ // answers 405, never 406. Errors on this route are still problem+json.
49
+ func IsFileDownload(r *http.Request) bool {
50
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
51
+ return false
52
+ }
53
+ rest, ok := strings.CutPrefix(r.URL.Path, "/api/v1/ideas/")
54
+ if !ok {
55
+ return false
56
+ }
57
+ id, tail, ok := strings.Cut(rest, "/")
58
+ return ok && id != "" && tail == "slides"
59
+ }
60
+
42
61
  // Check reports whether one dependency is usable; a non-nil error makes
43
62
  // readiness answer 503. Only dependencies a request cannot do without belong
44
63
  // here: the database yes, MinIO no (PRD §8.3, Architecture §12).