@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.
- package/backend/api/openapi.yaml +119 -0
- package/backend/cmd/server/main.go +36 -2
- package/backend/db/migration/V005__idea.sql +49 -0
- package/backend/db/migration/V006__login_lock.sql +15 -0
- package/backend/db/migration/V007__login_attempt.sql +15 -0
- package/backend/db/migration/V008__pending_file_deletion.sql +27 -0
- package/backend/db/migration/V009__login_throttle_audit.sql +13 -0
- package/backend/go.mod +17 -3
- package/backend/go.sum +48 -7
- package/backend/internal/account/account_test.go +6 -6
- package/backend/internal/account/integration_test.go +45 -9
- package/backend/internal/account/internal/passwords/NOTICE +36 -0
- package/backend/internal/account/internal/passwords/common-passwords.txt +10001 -0
- package/backend/internal/account/internal/passwords/passwords.go +37 -0
- package/backend/internal/account/internal/passwords/passwords_test.go +35 -0
- package/backend/internal/account/keyboard.go +75 -0
- package/backend/internal/account/keyboard_test.go +43 -0
- package/backend/internal/account/logging_integration_test.go +1 -1
- package/backend/internal/account/password.go +131 -0
- package/backend/internal/account/sequence.go +54 -0
- package/backend/internal/account/sequence_test.go +38 -0
- package/backend/internal/account/validate.go +4 -21
- package/backend/internal/api/api.gen.go +262 -0
- package/backend/internal/auth/internal/store/login_attempt.go +53 -0
- package/backend/internal/auth/internal/store/login_lock.go +77 -0
- package/backend/internal/auth/internal/store/login_throttle_audit.go +42 -0
- package/backend/internal/auth/internal/store/session.go +15 -7
- package/backend/internal/auth/login.go +16 -4
- package/backend/internal/auth/login_integration_test.go +14 -1
- package/backend/internal/auth/login_ip_integration_test.go +153 -0
- package/backend/internal/auth/login_lock_integration_test.go +205 -0
- package/backend/internal/auth/login_test.go +15 -0
- package/backend/internal/auth/login_throttle_audit_integration_test.go +88 -0
- package/backend/internal/auth/purge.go +93 -0
- package/backend/internal/auth/purge_integration_test.go +220 -0
- package/backend/internal/auth/purge_test.go +51 -0
- package/backend/internal/auth/session.go +0 -6
- package/backend/internal/auth/session_integration_test.go +3 -3
- package/backend/internal/auth/throttle.go +122 -0
- package/backend/internal/deadline/gate.go +80 -0
- package/backend/internal/deadline/gate_integration_test.go +67 -0
- package/backend/internal/deadline/gate_test.go +61 -0
- package/backend/internal/idea/create.go +356 -0
- package/backend/internal/idea/create_integration_test.go +680 -0
- package/backend/internal/idea/download.go +193 -0
- package/backend/internal/idea/download_integration_test.go +311 -0
- package/backend/internal/idea/download_test.go +25 -0
- package/backend/internal/idea/handler.go +151 -0
- package/backend/internal/idea/handler_integration_test.go +107 -0
- package/backend/internal/idea/integration_test.go +149 -0
- package/backend/internal/idea/internal/store/list.go +146 -0
- package/backend/internal/idea/internal/store/list_test.go +33 -0
- package/backend/internal/idea/internal/store/store.go +166 -0
- package/backend/internal/idea/list.go +166 -0
- package/backend/internal/idea/list_integration_test.go +252 -0
- package/backend/internal/idea/list_test.go +96 -0
- package/backend/internal/idea/service.go +54 -0
- package/backend/internal/server/api.go +22 -0
- package/backend/internal/server/binding.go +4 -0
- package/backend/internal/server/public.go +1 -0
- package/backend/internal/server/server.go +20 -1
- package/backend/internal/server/slides.go +21 -0
- package/backend/internal/server/slides_test.go +82 -0
- package/backend/internal/shared/config/config.go +44 -1
- package/backend/internal/shared/config/config_test.go +21 -13
- package/backend/internal/shared/db/intx_integration_test.go +179 -0
- package/backend/internal/shared/dbtest/dbtest.go +21 -3
- package/backend/internal/shared/dbtest/faults.go +162 -0
- package/backend/internal/shared/job/job.go +54 -0
- package/backend/internal/shared/job/job_test.go +105 -0
- package/backend/internal/shared/web/multipart.go +175 -0
- package/backend/internal/shared/web/multipart_test.go +169 -0
- package/backend/internal/shared/web/problem.go +1 -0
- package/backend/internal/shared/web/request.go +42 -1
- package/backend/internal/shared/web/transport.go +4 -3
- package/backend/internal/shared/web/web_test.go +36 -0
- package/backend/internal/storage/expectedfilename.go +43 -0
- package/backend/internal/storage/expectedfilename_test.go +16 -0
- package/backend/internal/storage/internal/store/pending.go +41 -0
- package/backend/internal/storage/minio.go +139 -0
- package/backend/internal/storage/minio_integration_test.go +87 -0
- package/backend/internal/storage/minio_test.go +84 -0
- package/backend/internal/storage/pending.go +144 -0
- package/backend/internal/storage/pending_integration_test.go +324 -0
- package/backend/internal/storage/pending_test.go +23 -0
- package/backend/internal/storage/validator.go +100 -0
- package/backend/internal/storage/validator_test.go +95 -0
- package/backend/test/arch/arch_test.go +5 -1
- package/backend/test/contract/expected_filename_test.go +60 -0
- package/backend/test/contract/password_test.go +46 -0
- package/backend/test/matrix/load_test.go +12 -0
- package/backend/test/matrix/matrix_integration_test.go +92 -5
- package/backend/test/migration/pending_file_deletion_test.go +46 -0
- package/docs/API/api-endpoints.md +1 -1
- package/docs/API/error-codes.md +1 -1
- package/docs/Architecture/Architecture.md +1 -1
- package/docs/Architecture/Backend.md +2 -1
- package/frontend/Dockerfile +2 -0
- package/frontend/package-lock.json +1 -0
- package/frontend/package.json +1 -0
- package/frontend/src/app/App.tsx +11 -2
- package/frontend/src/app/pages/ideaCreate.test.tsx +308 -0
- package/frontend/src/app/pages/ideas.tsx +23 -9
- package/frontend/src/app/pages/login.test.tsx +38 -1
- package/frontend/src/app/pages/register.test.tsx +32 -0
- package/frontend/src/features/auth/IdleWarning.test.tsx +82 -0
- package/frontend/src/features/auth/IdleWarning.tsx +66 -0
- package/frontend/src/features/auth/LoginForm.tsx +21 -3
- package/frontend/src/features/auth/PasswordChecklist.test.tsx +73 -0
- package/frontend/src/features/auth/PasswordChecklist.tsx +96 -0
- package/frontend/src/features/auth/RegisterForm.tsx +11 -2
- package/frontend/src/features/auth/common-passwords.ts +31 -0
- package/frontend/src/features/auth/password-policy.test.ts +64 -0
- package/frontend/src/features/auth/password-policy.ts +168 -0
- package/frontend/src/features/auth/useCountdown.test.tsx +33 -0
- package/frontend/src/features/auth/useCountdown.ts +28 -0
- package/frontend/src/features/idea/CreateIdeaForm.tsx +279 -0
- package/frontend/src/features/idea/create.ts +123 -0
- package/frontend/src/shared/api/activity.ts +20 -0
- package/frontend/src/shared/api/http.ts +2 -0
- package/frontend/src/shared/api/index.ts +1 -0
- package/frontend/src/shared/api/types.generated.ts +319 -1
- package/frontend/src/shared/i18n/locales/en.ts +46 -4
- package/frontend/src/shared/i18n/locales/zh-TW.ts +46 -4
- package/frontend/src/shared/slides/index.ts +74 -0
- package/frontend/src/shared/slides/slides.test.ts +51 -0
- package/frontend/src/shared/ui/copy-button.tsx +52 -0
- package/frontend/src/shared/ui/native-select.tsx +27 -0
- package/frontend/src/shared/ui/textarea.tsx +19 -0
- package/frontend/src/test-setup.ts +37 -2
- package/frontend/vite.config.ts +3 -1
- package/package.json +1 -1
- package/testdata/expected-filename.json +93 -0
- package/testdata/password.json +582 -0
- package/testdata/permission-matrix.json +71 -0
- package/testdata/schema/expected-filename.schema.json +32 -0
- package/testdata/schema/password.schema.json +59 -0
- 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).
|