@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,356 @@
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 idea needs (storage.SlideStore):
73
+ // Put for create, Get for download. Outages are *web.Error
74
+ // STORAGE_UNAVAILABLE; any other Get error (a missing object) answers 500.
75
+ type ObjectStore interface {
76
+ Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
77
+ // Get opens key; the caller closes the reader. size is the object length.
78
+ Get(ctx context.Context, key string) (rc io.ReadCloser, size int64, err error)
79
+ }
80
+
81
+ // UploadAborter compensates an uploaded object whose transaction did not
82
+ // commit (storage.PendingDeletion.AbortUpload, Sequence.md §2): delete it now
83
+ // after a known rollback, keep it and record UPLOAD_ABORTED after an unknown
84
+ // outcome. It is best effort and never fails the request.
85
+ type UploadAborter interface {
86
+ AbortUpload(ctx context.Context, key string, outcome db.Outcome)
87
+ }
88
+
89
+ // CreateCommand is the raw, unvalidated form. The Leader is the caller.
90
+ type CreateCommand struct {
91
+ Category, Org, Direction, Name, Summary string
92
+ Slides SlideFile
93
+ }
94
+
95
+ // Request is the transport facts the audit row needs.
96
+ type Request struct {
97
+ Method, Route, UserAgent string
98
+ IP netip.Addr
99
+ }
100
+
101
+ // validText is the cleaned, validated text of a create.
102
+ type validText struct {
103
+ category, org, direction, name, summary string
104
+ }
105
+
106
+ // Create runs POST /ideas after the role check and Precheck (both done by the
107
+ // handler before the body is read). Order (PRD 6.1, 6.8, Sequence.md §3):
108
+ // text 422 (incl. FILENAME_TOO_LONG) → file 413/422/415/422 → duplicate
109
+ // pre-check 409 → upload (503) → transaction: AssertOpenInTx, INSERT,
110
+ // IDEA_CREATE. The returned Idea is read back inside the same transaction.
111
+ // A transaction that does not commit is compensated by its outcome (B3-09);
112
+ // an unknown outcome answers 503 SERVICE_UNAVAILABLE.
113
+ func (s *Service) Create(ctx context.Context, caller security.Principal, cmd CreateCommand, req Request) (Idea, error) {
114
+ leaderName, err := store.LeaderName(ctx, s.DB, caller.UserID)
115
+ if err != nil {
116
+ return Idea{}, err
117
+ }
118
+ v, base, err := s.validateText(cmd, leaderName, caller.EmpNo)
119
+ if err != nil {
120
+ return Idea{}, err
121
+ }
122
+ ext, err := s.Slides.Validate(cmd.Slides, base)
123
+ if err != nil {
124
+ return Idea{}, err
125
+ }
126
+ // 422 wins over 409 (PRD 6.1), so the pre-check runs after every validation.
127
+ taken, err := store.NameTaken(ctx, s.DB, caller.UserID, v.name)
128
+ if err != nil {
129
+ return Idea{}, err
130
+ }
131
+ if taken {
132
+ return Idea{}, duplicateErr(nil)
133
+ }
134
+
135
+ n := store.NewIdea{
136
+ ID: uuid.New(), LeaderID: caller.UserID,
137
+ Category: v.category, Org: v.org, Direction: v.direction, Name: v.name, Summary: v.summary,
138
+ }
139
+ n.Slides = store.Slides{
140
+ ObjectKey: "slides/" + n.ID.String() + "/" + uuid.NewString() + "." + ext,
141
+ Ext: ext,
142
+ Size: cmd.Slides.Size,
143
+ SHA256: hex.EncodeToString(cmd.Slides.SHA256[:]),
144
+ }
145
+ // A failed upload leaves nothing: no row exists and no later step runs.
146
+ if err := s.Objects.Put(ctx, n.Slides.ObjectKey, cmd.Slides.Open(), n.Slides.Size, contentTypes[ext]); err != nil {
147
+ return Idea{}, err
148
+ }
149
+
150
+ after, err := createAfter(n)
151
+ if err != nil {
152
+ return Idea{}, err
153
+ }
154
+ var got Idea
155
+ out, err := db.InTx(ctx, s.DB, func(ctx context.Context, tx *sql.Tx) error {
156
+ if err := s.Gate.AssertOpenInTx(ctx, tx, caller); err != nil {
157
+ return denied(err, auditReasonState)
158
+ }
159
+ if err := store.InsertIdea(ctx, tx, n); err != nil {
160
+ return err
161
+ }
162
+ if err := s.Audit.Record(ctx, tx, audit.Event{
163
+ Action: audit.IdeaCreate, Outcome: audit.Success,
164
+ TargetType: audit.TargetIdea, TargetID: n.ID,
165
+ Method: req.Method, Route: req.Route, Status: http.StatusCreated,
166
+ IP: req.IP, UserAgent: req.UserAgent,
167
+ After: after,
168
+ }); err != nil {
169
+ return err
170
+ }
171
+ got, err = store.GetVisible(ctx, tx, n.ID, caller.UserID, caller.IsAdmin())
172
+ return err
173
+ })
174
+ if out == db.Committed {
175
+ return got, nil
176
+ }
177
+ // Known rollback: the object is deleted now. Unknown: the COMMIT may have
178
+ // been applied, so the object stays and the clean-up job keeps it if an
179
+ // idea row references the key.
180
+ s.Aborter.AbortUpload(ctx, n.Slides.ObjectKey, out)
181
+ if out == db.Unknown {
182
+ e := web.NewError(web.CodeServiceUnavailable, "The result of the request is unknown; check your Ideas before retrying.")
183
+ e.Cause = err
184
+ return Idea{}, e
185
+ }
186
+ if _, dup := db.DuplicateKey(err); dup {
187
+ return Idea{}, duplicateErr(err)
188
+ }
189
+ return Idea{}, err
190
+ }
191
+
192
+ // validateText cleans and checks every text field, collecting each field's
193
+ // first error in form order (PRD 6.2). When category, org and name are valid,
194
+ // the expected filename is built and its length checked; FILENAME_TOO_LONG is
195
+ // reported in the name slot. On success it returns the expected base.
196
+ func (s *Service) validateText(cmd CreateCommand, leaderName, leaderEmpNo string) (validText, string, error) {
197
+ var (
198
+ v validText
199
+ fields []web.FieldError
200
+ )
201
+ add := func(field string, code web.ErrorCode) {
202
+ fields = append(fields, web.FieldError{Field: field, Code: code})
203
+ }
204
+ enum := func(field, raw string, allowed []string, dst *string) {
205
+ // Codes are compared exactly, never cleaned up into a match; only
206
+ // "empty after clean-up" is REQUIRED (PRD 6.1).
207
+ switch {
208
+ case text.Trim(raw) == "":
209
+ add(field, web.CodeRequired)
210
+ case !slices.Contains(allowed, raw):
211
+ add(field, web.CodeInvalidEnum)
212
+ default:
213
+ *dst = raw
214
+ }
215
+ }
216
+ enum(fieldCategory, cmd.Category, categories, &v.category)
217
+ enum(fieldOrg, cmd.Org, orgs, &v.org)
218
+ enum(fieldDirection, cmd.Direction, directions, &v.direction)
219
+
220
+ var base string
221
+ name, p := text.CheckName(cmd.Name, maxIdeaName)
222
+ switch {
223
+ case p != text.None:
224
+ add(fieldName, web.ErrorCode(p))
225
+ case v.category != "" && v.org != "":
226
+ b, err := s.Slides.ExpectedBase(v.category, v.org, leaderName, leaderEmpNo, name)
227
+ if err != nil {
228
+ return validText{}, "", err
229
+ }
230
+ if err := s.Slides.CheckLength(b); err != nil {
231
+ var e *web.Error
232
+ if !errors.As(err, &e) {
233
+ return validText{}, "", err
234
+ }
235
+ add(fieldName, e.Code)
236
+ } else {
237
+ base = b
238
+ }
239
+ }
240
+ v.name = name
241
+
242
+ summary, p := text.CheckSummary(cmd.Summary, maxSummary)
243
+ if p != text.None {
244
+ add(fieldSummary, web.ErrorCode(p))
245
+ }
246
+ v.summary = summary
247
+
248
+ if len(fields) > 0 {
249
+ e := web.NewError(topCode(fields), "the request has invalid fields")
250
+ e.Fields = fields
251
+ return validText{}, "", e
252
+ }
253
+ return v, base, nil
254
+ }
255
+
256
+ // topCode is the response code of a 422: the first field error's code when it
257
+ // is a specific registered code, else VALIDATION_FAILED (error-codes.md §2).
258
+ func topCode(fields []web.FieldError) web.ErrorCode {
259
+ switch c := fields[0].Code; c {
260
+ case web.CodeInvalidNameChars, web.CodeFilenameTooLong:
261
+ return c
262
+ }
263
+ return web.CodeValidationFailed
264
+ }
265
+
266
+ // createAfter is the IDEA_CREATE after document: every stored field, with the
267
+ // slide metadata but never the content (PRD 6.11).
268
+ func createAfter(n store.NewIdea) (json.RawMessage, error) {
269
+ b, err := json.Marshal(map[string]any{
270
+ fieldCategory: n.Category, fieldOrg: n.Org, fieldDirection: n.Direction,
271
+ fieldName: n.Name, fieldSummary: n.Summary,
272
+ fieldSlides: map[string]any{"ext": n.Slides.Ext, "size": n.Slides.Size, "sha256": n.Slides.SHA256},
273
+ })
274
+ if err != nil {
275
+ return nil, fmt.Errorf("marshal IDEA_CREATE after: %w", err)
276
+ }
277
+ return b, nil
278
+ }
279
+
280
+ func duplicateErr(cause error) *web.Error {
281
+ e := web.NewError(web.CodeIdeaDuplicate, "You already lead an Idea with this name.")
282
+ e.Cause = cause
283
+ return e
284
+ }
285
+
286
+ // denied marks a role or state refusal of a create for the DENIED audit row
287
+ // (track B, written by the central error adapter). Other errors pass through.
288
+ func denied(err error, reason string) error {
289
+ var e *web.Error
290
+ if errors.As(err, &e) && (e.Code == web.CodeForbidden ||
291
+ e.Code == web.CodeSubmissionNotOpen || e.Code == web.CodeSubmissionClosed) {
292
+ e.AuditAction = string(audit.IdeaCreate)
293
+ e.AuditReason = reason
294
+ }
295
+ return err
296
+ }
297
+
298
+ // maxCreateBody is the application's body limit for a create: one 5 MiB file
299
+ // plus 64 KiB for the text parts and multipart framing (PRD 6.8). The exact
300
+ // 5 MiB file boundary is enforced by the validator, not by this limit.
301
+ const maxCreateBody = 5*1024*1024 + 64*1024
302
+
303
+ // createSpec is the one multipart shape of POST /ideas. Any other part (for
304
+ // example a client-sent leader) is drained and ignored (PRD 6.6).
305
+ var createSpec = web.MultipartSpec{
306
+ FileField: fieldSlides,
307
+ TextFields: []string{fieldCategory, fieldOrg, fieldDirection, fieldName, fieldSummary},
308
+ TooLarge: web.CodeFileTooLarge,
309
+ }
310
+
311
+ // CreateIdea serves POST /ideas: 201 + Location + IdeaDetail. Role 403 and the
312
+ // state pre-check run before the body is read (Backend.md §3).
313
+ func (h Handler) CreateIdea(w http.ResponseWriter, r *http.Request) {
314
+ p, ok := security.PrincipalFrom(r.Context())
315
+ if !ok {
316
+ h.Fail(w, r, web.NewError(web.CodeUnauthenticated, "authentication required"))
317
+ return
318
+ }
319
+ if p.IsAdmin() {
320
+ h.Fail(w, r, denied(web.NewError(web.CodeForbidden, "Admins cannot create Ideas."), auditReasonRole))
321
+ return
322
+ }
323
+ if err := h.Service.Gate.Precheck(r.Context(), p); err != nil {
324
+ h.Fail(w, r, denied(err, auditReasonState))
325
+ return
326
+ }
327
+ if r.ContentLength > maxCreateBody {
328
+ h.Fail(w, r, web.NewError(web.CodeFileTooLarge, "Request body too large."))
329
+ return
330
+ }
331
+ r.Body = http.MaxBytesReader(w, r.Body, maxCreateBody)
332
+ form, err := web.ReadMultipart(r, createSpec)
333
+ if err != nil {
334
+ h.Fail(w, r, err)
335
+ return
336
+ }
337
+ defer func() { _ = form.Close() }()
338
+
339
+ f := form.File
340
+ got, err := h.Service.Create(r.Context(), p, CreateCommand{
341
+ Category: form.Fields[fieldCategory], Org: form.Fields[fieldOrg], Direction: form.Fields[fieldDirection],
342
+ Name: form.Fields[fieldName], Summary: form.Fields[fieldSummary],
343
+ Slides: SlideFile{Filename: f.Filename, Size: f.Size, SHA256: f.SHA256, Data: f.ReaderAt(), Open: f.Open},
344
+ }, Request{Method: r.Method, Route: web.RoutePattern(r), UserAgent: r.UserAgent(), IP: web.ClientIP(r)})
345
+ if err != nil {
346
+ h.Fail(w, r, err)
347
+ return
348
+ }
349
+ body, err := h.toDetail(got, p)
350
+ if err != nil {
351
+ h.Fail(w, r, err)
352
+ return
353
+ }
354
+ w.Header().Set("Location", "/api/v1/ideas/"+got.ID.String())
355
+ web.WriteJSON(w, http.StatusCreated, body)
356
+ }