@johnnyboy11234/esg-idea-contest 0.2.4 → 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 (111) 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/file_test.go +52 -0
  78. package/backend/test/matrix/load_test.go +62 -0
  79. package/backend/test/matrix/matrix_integration_test.go +291 -0
  80. package/backend/test/migration/pending_file_deletion_test.go +46 -0
  81. package/docs/API/api-endpoints.md +1 -1
  82. package/docs/API/error-codes.md +1 -1
  83. package/frontend/public/auth-hero-login.jpg +0 -0
  84. package/frontend/public/auth-hero-register.jpg +0 -0
  85. package/frontend/src/app/App.test.tsx +2 -42
  86. package/frontend/src/app/App.tsx +11 -2
  87. package/frontend/src/app/index.css +4 -4
  88. package/frontend/src/app/layouts.tsx +7 -135
  89. package/frontend/src/app/pages/JoinRequests.tsx +2 -2
  90. package/frontend/src/app/pages/NotFound.tsx +1 -1
  91. package/frontend/src/app/pages/admin.tsx +1 -1
  92. package/frontend/src/app/pages/auth.tsx +53 -25
  93. package/frontend/src/app/pages/ideas.tsx +7 -7
  94. package/frontend/src/app/pages/login.test.tsx +3 -54
  95. package/frontend/src/app/pages/register.test.tsx +1 -1
  96. package/frontend/src/app/router.tsx +2 -6
  97. package/frontend/src/features/auth/IdleWarning.test.tsx +82 -0
  98. package/frontend/src/features/auth/IdleWarning.tsx +66 -0
  99. package/frontend/src/features/auth/LoginForm.tsx +8 -1
  100. package/frontend/src/shared/api/activity.ts +20 -0
  101. package/frontend/src/shared/api/http.ts +2 -0
  102. package/frontend/src/shared/api/index.ts +1 -0
  103. package/frontend/src/shared/i18n/locales/en.ts +1 -0
  104. package/frontend/src/shared/i18n/locales/zh-TW.ts +1 -0
  105. package/package.json +1 -1
  106. package/testdata/expected-filename.json +93 -0
  107. package/testdata/password.json +425 -0
  108. package/testdata/permission-matrix.json +157 -0
  109. package/testdata/schema/expected-filename.schema.json +32 -0
  110. package/testdata/schema/password.schema.json +59 -0
  111. package/testdata/schema/permission-matrix.schema.json +73 -0
@@ -0,0 +1,67 @@
1
+ //go:build integration
2
+
3
+ package deadline_test
4
+
5
+ import (
6
+ "context"
7
+ "database/sql"
8
+ "errors"
9
+ "testing"
10
+
11
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/deadline"
12
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/db"
13
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
14
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
15
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
16
+ )
17
+
18
+ // TestGateAssertOpenInTx runs the authoritative check inside a real write
19
+ // transaction for NOT_OPEN, OPEN and CLOSED, for a User and an Admin.
20
+ func TestGateAssertOpenInTx(t *testing.T) {
21
+ ctx := context.Background()
22
+ sdb := dbtest.New(t)
23
+ g := deadline.Gate{Store: deadline.SQLStore{DB: sdb}}
24
+ user := security.Principal{Role: security.RoleUser}
25
+ admin := security.Principal{Role: security.RoleAdmin}
26
+
27
+ assert := func(actor security.Principal) error {
28
+ _, err := db.InTx(ctx, sdb, func(ctx context.Context, tx *sql.Tx) error {
29
+ return g.AssertOpenInTx(ctx, tx, actor)
30
+ })
31
+ return err
32
+ }
33
+ set := func(t *testing.T, expr string) {
34
+ t.Helper()
35
+ if _, err := sdb.ExecContext(ctx, "UPDATE contest_setting SET deadline = "+expr+" WHERE id = 1"); err != nil {
36
+ t.Fatal(err)
37
+ }
38
+ }
39
+ code := func(err error) web.ErrorCode {
40
+ var e *web.Error
41
+ if errors.As(err, &e) {
42
+ return e.Code
43
+ }
44
+ return ""
45
+ }
46
+
47
+ if got := code(assert(user)); got != web.CodeSubmissionNotOpen {
48
+ t.Fatalf("NOT_OPEN user: %q", got)
49
+ }
50
+ if err := assert(admin); err != nil {
51
+ t.Fatalf("NOT_OPEN admin: %v", err)
52
+ }
53
+ set(t, "NOW() + INTERVAL 1 HOUR")
54
+ if err := assert(user); err != nil {
55
+ t.Fatalf("OPEN user: %v", err)
56
+ }
57
+ set(t, "NOW() - INTERVAL 1 HOUR")
58
+ if got := code(assert(user)); got != web.CodeSubmissionClosed {
59
+ t.Fatalf("CLOSED user: %q", got)
60
+ }
61
+ if err := assert(admin); err != nil {
62
+ t.Fatalf("CLOSED admin: %v", err)
63
+ }
64
+ if got := code(g.Precheck(ctx, user)); got != web.CodeSubmissionClosed {
65
+ t.Fatalf("CLOSED precheck: %q", got)
66
+ }
67
+ }
@@ -0,0 +1,61 @@
1
+ package deadline_test
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "testing"
7
+ "time"
8
+
9
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/deadline"
10
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
11
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
12
+ )
13
+
14
+ func TestGatePrecheck(t *testing.T) {
15
+ now := time.Date(2026, 11, 8, 12, 0, 0, 0, time.UTC)
16
+ future, past := now.Add(time.Hour), now.Add(-time.Hour)
17
+ user := security.Principal{Role: security.RoleUser}
18
+ admin := security.Principal{Role: security.RoleAdmin}
19
+
20
+ tests := []struct {
21
+ name string
22
+ d *time.Time
23
+ actor security.Principal
24
+ want web.ErrorCode
25
+ }{
26
+ {"user NOT_OPEN", nil, user, web.CodeSubmissionNotOpen},
27
+ {"user OPEN", &future, user, ""},
28
+ {"user CLOSED", &past, user, web.CodeSubmissionClosed},
29
+ {"user at the deadline instant is CLOSED", &now, user, web.CodeSubmissionClosed},
30
+ {"admin NOT_OPEN", nil, admin, ""},
31
+ {"admin CLOSED", &past, admin, ""},
32
+ }
33
+ for _, tc := range tests {
34
+ t.Run(tc.name, func(t *testing.T) {
35
+ g := deadline.Gate{Store: fakeStore{snap: deadline.Snapshot{Deadline: tc.d, Now: now}}}
36
+ err := g.Precheck(context.Background(), tc.actor)
37
+ if tc.want == "" {
38
+ if err != nil {
39
+ t.Fatalf("got %v", err)
40
+ }
41
+ return
42
+ }
43
+ var e *web.Error
44
+ if !errors.As(err, &e) || e.Code != tc.want || e.Status != 403 {
45
+ t.Fatalf("got %v, want %s/403", err, tc.want)
46
+ }
47
+ })
48
+ }
49
+ }
50
+
51
+ func TestGatePrecheckStoreError(t *testing.T) {
52
+ boom := errors.New("db down")
53
+ g := deadline.Gate{Store: fakeStore{err: boom}}
54
+ if err := g.Precheck(context.Background(), security.Principal{Role: security.RoleUser}); !errors.Is(err, boom) {
55
+ t.Fatalf("got %v", err)
56
+ }
57
+ // Admin is exempt, so the store is not even read.
58
+ if err := g.Precheck(context.Background(), security.Principal{Role: security.RoleAdmin}); err != nil {
59
+ t.Fatalf("admin: %v", err)
60
+ }
61
+ }
@@ -0,0 +1,352 @@
1
+ package idea
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "encoding/hex"
7
+ "encoding/json"
8
+ "errors"
9
+ "fmt"
10
+ "io"
11
+ "net/http"
12
+ "net/netip"
13
+ "slices"
14
+
15
+ "github.com/google/uuid"
16
+
17
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
18
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/idea/internal/store"
19
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/db"
20
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
21
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/text"
22
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
23
+ )
24
+
25
+ // Text limits in code points (PRD 6.6).
26
+ const (
27
+ maxIdeaName = 75
28
+ maxSummary = 300
29
+ )
30
+
31
+ // Field names as they appear in errors[]; the order is the form order.
32
+ const (
33
+ fieldCategory = "category"
34
+ fieldOrg = "org"
35
+ fieldDirection = "direction"
36
+ fieldName = "name"
37
+ fieldSummary = "summary"
38
+ fieldSlides = "slides"
39
+ )
40
+
41
+ // Audit reasons of a refused create (PRD 6.11: role and state are DENIED).
42
+ const (
43
+ auditReasonRole = "ROLE"
44
+ auditReasonState = "STATE"
45
+ )
46
+
47
+ // contentTypes are the stored object types per accepted extension.
48
+ var contentTypes = map[string]string{
49
+ "pdf": "application/pdf",
50
+ "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
51
+ }
52
+
53
+ // SlideFile is the spooled slides part as the storage rules need it.
54
+ type SlideFile struct {
55
+ Filename string // as sent
56
+ Size int64
57
+ SHA256 [32]byte
58
+ Data io.ReaderAt // random access for the validator
59
+ Open func() io.Reader // a fresh reader from the start, for the upload
60
+ }
61
+
62
+ // SlideRules is the storage-side slide logic (storage.ExpectedFilenameBase,
63
+ // CheckFilenameLength, Validate). It is injected by main because idea must not
64
+ // import storage (D29); every function returns *web.Error for client faults.
65
+ type SlideRules struct {
66
+ ExpectedBase func(category, org, leaderName, leaderEmpNo, ideaName string) (string, error)
67
+ CheckLength func(base string) error
68
+ // Validate runs PRD 6.8 steps 2–5 and returns the extension ("pdf"/"pptx").
69
+ Validate func(f SlideFile, expectedBase string) (ext string, err error)
70
+ }
71
+
72
+ // ObjectStore is the part of the MinIO port create needs (storage.SlideStore).
73
+ type ObjectStore interface {
74
+ Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
75
+ }
76
+
77
+ // UploadAborter compensates an uploaded object whose transaction did not
78
+ // commit (storage.PendingDeletion.AbortUpload, Sequence.md §2): delete it now
79
+ // after a known rollback, keep it and record UPLOAD_ABORTED after an unknown
80
+ // outcome. It is best effort and never fails the request.
81
+ type UploadAborter interface {
82
+ AbortUpload(ctx context.Context, key string, outcome db.Outcome)
83
+ }
84
+
85
+ // CreateCommand is the raw, unvalidated form. The Leader is the caller.
86
+ type CreateCommand struct {
87
+ Category, Org, Direction, Name, Summary string
88
+ Slides SlideFile
89
+ }
90
+
91
+ // Request is the transport facts the audit row needs.
92
+ type Request struct {
93
+ Method, Route, UserAgent string
94
+ IP netip.Addr
95
+ }
96
+
97
+ // validText is the cleaned, validated text of a create.
98
+ type validText struct {
99
+ category, org, direction, name, summary string
100
+ }
101
+
102
+ // Create runs POST /ideas after the role check and Precheck (both done by the
103
+ // handler before the body is read). Order (PRD 6.1, 6.8, Sequence.md §3):
104
+ // text 422 (incl. FILENAME_TOO_LONG) → file 413/422/415/422 → duplicate
105
+ // pre-check 409 → upload (503) → transaction: AssertOpenInTx, INSERT,
106
+ // IDEA_CREATE. The returned Idea is read back inside the same transaction.
107
+ // A transaction that does not commit is compensated by its outcome (B3-09);
108
+ // an unknown outcome answers 503 SERVICE_UNAVAILABLE.
109
+ func (s *Service) Create(ctx context.Context, caller security.Principal, cmd CreateCommand, req Request) (Idea, error) {
110
+ leaderName, err := store.LeaderName(ctx, s.DB, caller.UserID)
111
+ if err != nil {
112
+ return Idea{}, err
113
+ }
114
+ v, base, err := s.validateText(cmd, leaderName, caller.EmpNo)
115
+ if err != nil {
116
+ return Idea{}, err
117
+ }
118
+ ext, err := s.Slides.Validate(cmd.Slides, base)
119
+ if err != nil {
120
+ return Idea{}, err
121
+ }
122
+ // 422 wins over 409 (PRD 6.1), so the pre-check runs after every validation.
123
+ taken, err := store.NameTaken(ctx, s.DB, caller.UserID, v.name)
124
+ if err != nil {
125
+ return Idea{}, err
126
+ }
127
+ if taken {
128
+ return Idea{}, duplicateErr(nil)
129
+ }
130
+
131
+ n := store.NewIdea{
132
+ ID: uuid.New(), LeaderID: caller.UserID,
133
+ Category: v.category, Org: v.org, Direction: v.direction, Name: v.name, Summary: v.summary,
134
+ }
135
+ n.Slides = store.Slides{
136
+ ObjectKey: "slides/" + n.ID.String() + "/" + uuid.NewString() + "." + ext,
137
+ Ext: ext,
138
+ Size: cmd.Slides.Size,
139
+ SHA256: hex.EncodeToString(cmd.Slides.SHA256[:]),
140
+ }
141
+ // A failed upload leaves nothing: no row exists and no later step runs.
142
+ if err := s.Objects.Put(ctx, n.Slides.ObjectKey, cmd.Slides.Open(), n.Slides.Size, contentTypes[ext]); err != nil {
143
+ return Idea{}, err
144
+ }
145
+
146
+ after, err := createAfter(n)
147
+ if err != nil {
148
+ return Idea{}, err
149
+ }
150
+ var got Idea
151
+ out, err := db.InTx(ctx, s.DB, func(ctx context.Context, tx *sql.Tx) error {
152
+ if err := s.Gate.AssertOpenInTx(ctx, tx, caller); err != nil {
153
+ return denied(err, auditReasonState)
154
+ }
155
+ if err := store.InsertIdea(ctx, tx, n); err != nil {
156
+ return err
157
+ }
158
+ if err := s.Audit.Record(ctx, tx, audit.Event{
159
+ Action: audit.IdeaCreate, Outcome: audit.Success,
160
+ TargetType: audit.TargetIdea, TargetID: n.ID,
161
+ Method: req.Method, Route: req.Route, Status: http.StatusCreated,
162
+ IP: req.IP, UserAgent: req.UserAgent,
163
+ After: after,
164
+ }); err != nil {
165
+ return err
166
+ }
167
+ got, err = store.GetVisible(ctx, tx, n.ID, caller.UserID, caller.IsAdmin())
168
+ return err
169
+ })
170
+ if out == db.Committed {
171
+ return got, nil
172
+ }
173
+ // Known rollback: the object is deleted now. Unknown: the COMMIT may have
174
+ // been applied, so the object stays and the clean-up job keeps it if an
175
+ // idea row references the key.
176
+ s.Aborter.AbortUpload(ctx, n.Slides.ObjectKey, out)
177
+ if out == db.Unknown {
178
+ e := web.NewError(web.CodeServiceUnavailable, "The result of the request is unknown; check your Ideas before retrying.")
179
+ e.Cause = err
180
+ return Idea{}, e
181
+ }
182
+ if _, dup := db.DuplicateKey(err); dup {
183
+ return Idea{}, duplicateErr(err)
184
+ }
185
+ return Idea{}, err
186
+ }
187
+
188
+ // validateText cleans and checks every text field, collecting each field's
189
+ // first error in form order (PRD 6.2). When category, org and name are valid,
190
+ // the expected filename is built and its length checked; FILENAME_TOO_LONG is
191
+ // reported in the name slot. On success it returns the expected base.
192
+ func (s *Service) validateText(cmd CreateCommand, leaderName, leaderEmpNo string) (validText, string, error) {
193
+ var (
194
+ v validText
195
+ fields []web.FieldError
196
+ )
197
+ add := func(field string, code web.ErrorCode) {
198
+ fields = append(fields, web.FieldError{Field: field, Code: code})
199
+ }
200
+ enum := func(field, raw string, allowed []string, dst *string) {
201
+ // Codes are compared exactly, never cleaned up into a match; only
202
+ // "empty after clean-up" is REQUIRED (PRD 6.1).
203
+ switch {
204
+ case text.Trim(raw) == "":
205
+ add(field, web.CodeRequired)
206
+ case !slices.Contains(allowed, raw):
207
+ add(field, web.CodeInvalidEnum)
208
+ default:
209
+ *dst = raw
210
+ }
211
+ }
212
+ enum(fieldCategory, cmd.Category, categories, &v.category)
213
+ enum(fieldOrg, cmd.Org, orgs, &v.org)
214
+ enum(fieldDirection, cmd.Direction, directions, &v.direction)
215
+
216
+ var base string
217
+ name, p := text.CheckName(cmd.Name, maxIdeaName)
218
+ switch {
219
+ case p != text.None:
220
+ add(fieldName, web.ErrorCode(p))
221
+ case v.category != "" && v.org != "":
222
+ b, err := s.Slides.ExpectedBase(v.category, v.org, leaderName, leaderEmpNo, name)
223
+ if err != nil {
224
+ return validText{}, "", err
225
+ }
226
+ if err := s.Slides.CheckLength(b); err != nil {
227
+ var e *web.Error
228
+ if !errors.As(err, &e) {
229
+ return validText{}, "", err
230
+ }
231
+ add(fieldName, e.Code)
232
+ } else {
233
+ base = b
234
+ }
235
+ }
236
+ v.name = name
237
+
238
+ summary, p := text.CheckSummary(cmd.Summary, maxSummary)
239
+ if p != text.None {
240
+ add(fieldSummary, web.ErrorCode(p))
241
+ }
242
+ v.summary = summary
243
+
244
+ if len(fields) > 0 {
245
+ e := web.NewError(topCode(fields), "the request has invalid fields")
246
+ e.Fields = fields
247
+ return validText{}, "", e
248
+ }
249
+ return v, base, nil
250
+ }
251
+
252
+ // topCode is the response code of a 422: the first field error's code when it
253
+ // is a specific registered code, else VALIDATION_FAILED (error-codes.md §2).
254
+ func topCode(fields []web.FieldError) web.ErrorCode {
255
+ switch c := fields[0].Code; c {
256
+ case web.CodeInvalidNameChars, web.CodeFilenameTooLong:
257
+ return c
258
+ }
259
+ return web.CodeValidationFailed
260
+ }
261
+
262
+ // createAfter is the IDEA_CREATE after document: every stored field, with the
263
+ // slide metadata but never the content (PRD 6.11).
264
+ func createAfter(n store.NewIdea) (json.RawMessage, error) {
265
+ b, err := json.Marshal(map[string]any{
266
+ fieldCategory: n.Category, fieldOrg: n.Org, fieldDirection: n.Direction,
267
+ fieldName: n.Name, fieldSummary: n.Summary,
268
+ fieldSlides: map[string]any{"ext": n.Slides.Ext, "size": n.Slides.Size, "sha256": n.Slides.SHA256},
269
+ })
270
+ if err != nil {
271
+ return nil, fmt.Errorf("marshal IDEA_CREATE after: %w", err)
272
+ }
273
+ return b, nil
274
+ }
275
+
276
+ func duplicateErr(cause error) *web.Error {
277
+ e := web.NewError(web.CodeIdeaDuplicate, "You already lead an Idea with this name.")
278
+ e.Cause = cause
279
+ return e
280
+ }
281
+
282
+ // denied marks a role or state refusal of a create for the DENIED audit row
283
+ // (track B, written by the central error adapter). Other errors pass through.
284
+ func denied(err error, reason string) error {
285
+ var e *web.Error
286
+ if errors.As(err, &e) && (e.Code == web.CodeForbidden ||
287
+ e.Code == web.CodeSubmissionNotOpen || e.Code == web.CodeSubmissionClosed) {
288
+ e.AuditAction = string(audit.IdeaCreate)
289
+ e.AuditReason = reason
290
+ }
291
+ return err
292
+ }
293
+
294
+ // maxCreateBody is the application's body limit for a create: one 5 MiB file
295
+ // plus 64 KiB for the text parts and multipart framing (PRD 6.8). The exact
296
+ // 5 MiB file boundary is enforced by the validator, not by this limit.
297
+ const maxCreateBody = 5*1024*1024 + 64*1024
298
+
299
+ // createSpec is the one multipart shape of POST /ideas. Any other part (for
300
+ // example a client-sent leader) is drained and ignored (PRD 6.6).
301
+ var createSpec = web.MultipartSpec{
302
+ FileField: fieldSlides,
303
+ TextFields: []string{fieldCategory, fieldOrg, fieldDirection, fieldName, fieldSummary},
304
+ TooLarge: web.CodeFileTooLarge,
305
+ }
306
+
307
+ // CreateIdea serves POST /ideas: 201 + Location + IdeaDetail. Role 403 and the
308
+ // state pre-check run before the body is read (Backend.md §3).
309
+ func (h Handler) CreateIdea(w http.ResponseWriter, r *http.Request) {
310
+ p, ok := security.PrincipalFrom(r.Context())
311
+ if !ok {
312
+ h.Fail(w, r, web.NewError(web.CodeUnauthenticated, "authentication required"))
313
+ return
314
+ }
315
+ if p.IsAdmin() {
316
+ h.Fail(w, r, denied(web.NewError(web.CodeForbidden, "Admins cannot create Ideas."), auditReasonRole))
317
+ return
318
+ }
319
+ if err := h.Service.Gate.Precheck(r.Context(), p); err != nil {
320
+ h.Fail(w, r, denied(err, auditReasonState))
321
+ return
322
+ }
323
+ if r.ContentLength > maxCreateBody {
324
+ h.Fail(w, r, web.NewError(web.CodeFileTooLarge, "Request body too large."))
325
+ return
326
+ }
327
+ r.Body = http.MaxBytesReader(w, r.Body, maxCreateBody)
328
+ form, err := web.ReadMultipart(r, createSpec)
329
+ if err != nil {
330
+ h.Fail(w, r, err)
331
+ return
332
+ }
333
+ defer func() { _ = form.Close() }()
334
+
335
+ f := form.File
336
+ got, err := h.Service.Create(r.Context(), p, CreateCommand{
337
+ Category: form.Fields[fieldCategory], Org: form.Fields[fieldOrg], Direction: form.Fields[fieldDirection],
338
+ Name: form.Fields[fieldName], Summary: form.Fields[fieldSummary],
339
+ Slides: SlideFile{Filename: f.Filename, Size: f.Size, SHA256: f.SHA256, Data: f.ReaderAt(), Open: f.Open},
340
+ }, Request{Method: r.Method, Route: web.RoutePattern(r), UserAgent: r.UserAgent(), IP: web.ClientIP(r)})
341
+ if err != nil {
342
+ h.Fail(w, r, err)
343
+ return
344
+ }
345
+ body, err := h.toDetail(got, p)
346
+ if err != nil {
347
+ h.Fail(w, r, err)
348
+ return
349
+ }
350
+ w.Header().Set("Location", "/api/v1/ideas/"+got.ID.String())
351
+ web.WriteJSON(w, http.StatusCreated, body)
352
+ }