@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
@@ -21,6 +21,26 @@ export interface paths {
21
21
  patch?: never;
22
22
  trace?: never;
23
23
  };
24
+ "/auth/login": {
25
+ parameters: {
26
+ query?: never;
27
+ header?: never;
28
+ path?: never;
29
+ cookie?: never;
30
+ };
31
+ get?: never;
32
+ put?: never;
33
+ /**
34
+ * Log in with employee ID and password (issues a new session)
35
+ * @description Sets the session cookie and returns the caller. Unknown, malformed and disabled accounts and wrong passwords all answer the same 401. The CSRF token changes with the session, so the client refetches it.
36
+ */
37
+ post: operations["login"];
38
+ delete?: never;
39
+ options?: never;
40
+ head?: never;
41
+ patch?: never;
42
+ trace?: never;
43
+ };
24
44
  "/auth/logout": {
25
45
  parameters: {
26
46
  query?: never;
@@ -38,6 +58,43 @@ export interface paths {
38
58
  patch?: never;
39
59
  trace?: never;
40
60
  };
61
+ "/auth/me": {
62
+ parameters: {
63
+ query?: never;
64
+ header?: never;
65
+ path?: never;
66
+ cookie?: never;
67
+ };
68
+ /** The caller (also the idle-timeout "continue" call) */
69
+ get: operations["getMe"];
70
+ put?: never;
71
+ post?: never;
72
+ delete?: never;
73
+ options?: never;
74
+ head?: never;
75
+ patch?: never;
76
+ trace?: never;
77
+ };
78
+ "/auth/register": {
79
+ parameters: {
80
+ query?: never;
81
+ header?: never;
82
+ path?: never;
83
+ cookie?: never;
84
+ };
85
+ get?: never;
86
+ put?: never;
87
+ /**
88
+ * Self-service registration (no session is created)
89
+ * @description Validation order: format, password policy, uniqueness (PRD 6.3). Sets no cookie.
90
+ */
91
+ post: operations["register"];
92
+ delete?: never;
93
+ options?: never;
94
+ head?: never;
95
+ patch?: never;
96
+ trace?: never;
97
+ };
41
98
  "/deadline": {
42
99
  parameters: {
43
100
  query?: never;
@@ -65,7 +122,11 @@ export interface paths {
65
122
  /** List Ideas visible to the caller */
66
123
  get: operations["listIdeas"];
67
124
  put?: never;
68
- post?: never;
125
+ /**
126
+ * Create an Idea with its slides (User only)
127
+ * @description One atomic multipart request (PRD 6.6). Order: role 403, state 403, 400 (missing or repeated part), 422 text fields (incl. FILENAME_TOO_LONG), file 413 / 422 EMPTY_FILE / 415 / 422 FILENAME_MISMATCH, 409 IDEA_DUPLICATE, then upload (503 STORAGE_UNAVAILABLE). A transaction whose outcome is unknown answers 503 SERVICE_UNAVAILABLE (the Idea may exist). The Leader is the caller; any leader value in the form is ignored. The body is read by the handler, so the generated wrapper does not bind it.
128
+ */
129
+ post: operations["createIdea"];
69
130
  delete?: never;
70
131
  options?: never;
71
132
  head?: never;
@@ -89,6 +150,26 @@ export interface paths {
89
150
  patch?: never;
90
151
  trace?: never;
91
152
  };
153
+ "/ideas/{id}/slides": {
154
+ parameters: {
155
+ query?: never;
156
+ header?: never;
157
+ path?: never;
158
+ cookie?: never;
159
+ };
160
+ /**
161
+ * Download the Idea's slides (Leader, Member, Admin)
162
+ * @description The SLIDES_DOWNLOAD audit row is committed before the first byte is sent (PRD 6.11). The file is named by the current expected filename (PRD 6.6). Range is not supported; HEAD answers 405 (no audit row). The Accept header is not checked on this route. MinIO unavailable before the first byte answers 503 STORAGE_UNAVAILABLE; a storage failure after the headers are sent aborts the connection (the body is shorter than Content-Length). A transaction whose outcome is unknown answers 503 SERVICE_UNAVAILABLE and serves nothing.
163
+ */
164
+ get: operations["downloadSlides"];
165
+ put?: never;
166
+ post?: never;
167
+ delete?: never;
168
+ options?: never;
169
+ head?: never;
170
+ patch?: never;
171
+ trace?: never;
172
+ };
92
173
  }
93
174
  export type webhooks = Record<string, never>;
94
175
  export interface components {
@@ -96,6 +177,35 @@ export interface components {
96
177
  CsrfTokenResponse: {
97
178
  token: string;
98
179
  };
180
+ LoginRequest: {
181
+ /** @description Any string; a badly formatted ID is an ordinary login failure */
182
+ empNo: string;
183
+ /** Format: password */
184
+ password: string;
185
+ };
186
+ LoginResponse: {
187
+ empNo: string;
188
+ name: string;
189
+ /** @description null for Admin */
190
+ email: string | null;
191
+ /** @enum {string} */
192
+ role: "USER" | "ADMIN";
193
+ };
194
+ RegisterRequest: {
195
+ /** @description Exactly 6 ASCII digits */
196
+ empNo: string;
197
+ /** @description 1-50 code points after clean-up */
198
+ name: string;
199
+ /** @description Printable ASCII with exactly one @, at most 254 characters; stored lower-cased */
200
+ email: string;
201
+ /** Format: password */
202
+ password: string;
203
+ };
204
+ RegisterResponse: {
205
+ empNo: string;
206
+ name: string;
207
+ email: string;
208
+ };
99
209
  Problem: {
100
210
  /** @example about:blank */
101
211
  type: string;
@@ -112,6 +222,8 @@ export interface components {
112
222
  field: string;
113
223
  code: string;
114
224
  expected?: string;
225
+ /** @description PASSWORD_POLICY only, the violated rule id (PRD 6.4) */
226
+ rule?: string;
115
227
  };
116
228
  Deadline: {
117
229
  /** Format: date-time */
@@ -128,6 +240,23 @@ export interface components {
128
240
  empNo: string;
129
241
  name: string;
130
242
  };
243
+ CreateIdeaForm: {
244
+ /** @description COOL_IDEA or OUTSTANDING_IMPACT */
245
+ category: string;
246
+ /** @description AAID, BSID, ICSD, TSID, PLED or DEID */
247
+ org: string;
248
+ /** @description One of the five ESG directions (PRD 6.6) */
249
+ direction: string;
250
+ /** @description 1-75 code points after clean-up; no / \ : * ? " < > | */
251
+ name: string;
252
+ /** @description 1-300 code points after clean-up */
253
+ summary: string;
254
+ /**
255
+ * Format: binary
256
+ * @description One PDF or PPTX, at most 5 MiB, named {I|O}_{org}_{leaderName}_{leaderEmpNo}_{name}.{pdf|pptx}
257
+ */
258
+ slides: string;
259
+ };
131
260
  IdeaSummary: {
132
261
  /** Format: uuid */
133
262
  id: string;
@@ -235,6 +364,53 @@ export interface operations {
235
364
  500: components["responses"]["Problem"];
236
365
  };
237
366
  };
367
+ login: {
368
+ parameters: {
369
+ query?: never;
370
+ header?: {
371
+ /** @description Session-bound token from GET /auth/csrf. Mandatory on every write, but enforced by the CSRF middleware (403 CSRF_INVALID), so the generated wrapper must not reject its absence first (400). */
372
+ "X-CSRF-Token"?: components["parameters"]["CsrfToken"];
373
+ };
374
+ path?: never;
375
+ cookie?: never;
376
+ };
377
+ requestBody: {
378
+ content: {
379
+ "application/json": components["schemas"]["LoginRequest"];
380
+ };
381
+ };
382
+ responses: {
383
+ /** @description Logged in */
384
+ 200: {
385
+ headers: {
386
+ /** @description __Host-session=<opaque ID>; HttpOnly; Secure; SameSite=Lax; Path=/ */
387
+ "Set-Cookie"?: string;
388
+ [name: string]: unknown;
389
+ };
390
+ content: {
391
+ "application/json": components["schemas"]["LoginResponse"];
392
+ };
393
+ };
394
+ 400: components["responses"]["Problem"];
395
+ 401: components["responses"]["Problem"];
396
+ 403: components["responses"]["Problem"];
397
+ 413: components["responses"]["Problem"];
398
+ 415: components["responses"]["Problem"];
399
+ /** @description Throttled (LOGIN_THROTTLED) */
400
+ 429: {
401
+ headers: {
402
+ /** @description Seconds until a new attempt may succeed */
403
+ "Retry-After"?: number;
404
+ [name: string]: unknown;
405
+ };
406
+ content: {
407
+ "application/problem+json": components["schemas"]["Problem"];
408
+ };
409
+ };
410
+ 500: components["responses"]["Problem"];
411
+ 503: components["responses"]["Problem"];
412
+ };
413
+ };
238
414
  logout: {
239
415
  parameters: {
240
416
  query?: never;
@@ -258,6 +434,63 @@ export interface operations {
258
434
  500: components["responses"]["Problem"];
259
435
  };
260
436
  };
437
+ getMe: {
438
+ parameters: {
439
+ query?: never;
440
+ header?: never;
441
+ path?: never;
442
+ cookie?: never;
443
+ };
444
+ requestBody?: never;
445
+ responses: {
446
+ /** @description The authenticated caller */
447
+ 200: {
448
+ headers: {
449
+ [name: string]: unknown;
450
+ };
451
+ content: {
452
+ "application/json": components["schemas"]["LoginResponse"];
453
+ };
454
+ };
455
+ 401: components["responses"]["Problem"];
456
+ 500: components["responses"]["Problem"];
457
+ };
458
+ };
459
+ register: {
460
+ parameters: {
461
+ query?: never;
462
+ header?: {
463
+ /** @description Session-bound token from GET /auth/csrf. Mandatory on every write, but enforced by the CSRF middleware (403 CSRF_INVALID), so the generated wrapper must not reject its absence first (400). */
464
+ "X-CSRF-Token"?: components["parameters"]["CsrfToken"];
465
+ };
466
+ path?: never;
467
+ cookie?: never;
468
+ };
469
+ requestBody: {
470
+ content: {
471
+ "application/json": components["schemas"]["RegisterRequest"];
472
+ };
473
+ };
474
+ responses: {
475
+ /** @description Account created; log in separately */
476
+ 201: {
477
+ headers: {
478
+ [name: string]: unknown;
479
+ };
480
+ content: {
481
+ "application/json": components["schemas"]["RegisterResponse"];
482
+ };
483
+ };
484
+ 400: components["responses"]["Problem"];
485
+ 403: components["responses"]["Problem"];
486
+ 409: components["responses"]["Problem"];
487
+ 413: components["responses"]["Problem"];
488
+ 415: components["responses"]["Problem"];
489
+ 422: components["responses"]["Problem"];
490
+ 500: components["responses"]["Problem"];
491
+ 503: components["responses"]["Problem"];
492
+ };
493
+ };
261
494
  getDeadline: {
262
495
  parameters: {
263
496
  query?: never;
@@ -285,6 +518,21 @@ export interface operations {
285
518
  query?: {
286
519
  page?: number;
287
520
  size?: number;
521
+ /** @description Closed set; an invalid value is 422 INVALID_ENUM (never ignored). Validated in the handler. */
522
+ org?: string;
523
+ category?: string;
524
+ direction?: string;
525
+ leaderEmpNo?: string;
526
+ /** @description ISO 8601 instant with offset or Z; inclusive. Validated in the handler. */
527
+ createdFrom?: string;
528
+ /** @description ISO 8601 instant with offset or Z; exclusive. Validated in the handler. */
529
+ createdTo?: string;
530
+ /** @description Case-insensitive "name contains"; `%` and `_` are literal. */
531
+ q?: string;
532
+ /** @description createdAt (default) or name; anything else is 422 INVALID_ENUM. Validated in the handler. */
533
+ sort?: string;
534
+ /** @description asc or desc (default desc for createdAt, asc for name); anything else is 422 INVALID_ENUM. Validated in the handler. */
535
+ order?: string;
288
536
  };
289
537
  header?: never;
290
538
  path?: never;
@@ -306,6 +554,43 @@ export interface operations {
306
554
  500: components["responses"]["Problem"];
307
555
  };
308
556
  };
557
+ createIdea: {
558
+ parameters: {
559
+ query?: never;
560
+ header?: {
561
+ /** @description Session-bound token from GET /auth/csrf. Mandatory on every write, but enforced by the CSRF middleware (403 CSRF_INVALID), so the generated wrapper must not reject its absence first (400). */
562
+ "X-CSRF-Token"?: components["parameters"]["CsrfToken"];
563
+ };
564
+ path?: never;
565
+ cookie?: never;
566
+ };
567
+ requestBody: {
568
+ content: {
569
+ "multipart/form-data": components["schemas"]["CreateIdeaForm"];
570
+ };
571
+ };
572
+ responses: {
573
+ /** @description Created; Location is /api/v1/ideas/{id} */
574
+ 201: {
575
+ headers: {
576
+ Location?: string;
577
+ [name: string]: unknown;
578
+ };
579
+ content: {
580
+ "application/json": components["schemas"]["IdeaDetail"];
581
+ };
582
+ };
583
+ 400: components["responses"]["Problem"];
584
+ 401: components["responses"]["Problem"];
585
+ 403: components["responses"]["Problem"];
586
+ 409: components["responses"]["Problem"];
587
+ 413: components["responses"]["Problem"];
588
+ 415: components["responses"]["Problem"];
589
+ 422: components["responses"]["Problem"];
590
+ 500: components["responses"]["Problem"];
591
+ 503: components["responses"]["Problem"];
592
+ };
593
+ };
309
594
  getIdea: {
310
595
  parameters: {
311
596
  query?: never;
@@ -332,4 +617,37 @@ export interface operations {
332
617
  500: components["responses"]["Problem"];
333
618
  };
334
619
  };
620
+ downloadSlides: {
621
+ parameters: {
622
+ query?: never;
623
+ header?: never;
624
+ path: {
625
+ /** @description A malformed ID answers the uniform 404, not 400. */
626
+ id: components["parameters"]["IdeaId"];
627
+ };
628
+ cookie?: never;
629
+ };
630
+ requestBody?: never;
631
+ responses: {
632
+ /** @description The stored file */
633
+ 200: {
634
+ headers: {
635
+ /** @description attachment; filename="<ASCII fallback>"; filename*=UTF-8''<percent-encoded> (RFC 6266) */
636
+ "Content-Disposition"?: string;
637
+ "Content-Length"?: number;
638
+ "Cache-Control"?: "no-store";
639
+ "X-Content-Type-Options"?: "nosniff";
640
+ [name: string]: unknown;
641
+ };
642
+ content: {
643
+ "application/pdf": string;
644
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": string;
645
+ };
646
+ };
647
+ 401: components["responses"]["Problem"];
648
+ 404: components["responses"]["Problem"];
649
+ 500: components["responses"]["Problem"];
650
+ 503: components["responses"]["Problem"];
651
+ };
652
+ };
335
653
  }
@@ -56,14 +56,38 @@ export const en = {
56
56
  members: 'Members',
57
57
  leaderEmpNo: 'Leader employee ID',
58
58
  },
59
+ // Working drafts until the owner supplies official labels and Org full names (OI-17).
60
+ enums: {
61
+ category: { COOL_IDEA: 'Cool Idea Award', OUTSTANDING_IMPACT: 'Outstanding Impact Award' },
62
+ org: { AAID: 'AAID', BSID: 'BSID', ICSD: 'ICSD', TSID: 'TSID', PLED: 'PLED', DEID: 'DEID' },
63
+ direction: {
64
+ GREEN_MANUFACTURING: 'Green Manufacturing',
65
+ RESPONSIBLE_SUPPLY_CHAIN: 'Building a Responsible Supply Chain',
66
+ INCLUSIVE_WORKPLACE: 'Creating a Healthy and Inclusive Workplace',
67
+ TALENT_DEVELOPMENT: 'Talent Development',
68
+ CARING_FOR_DISADVANTAGED: 'Caring for the Disadvantaged',
69
+ },
70
+ },
71
+ passwordRules: {
72
+ title: 'Your password must:',
73
+ LENGTH: 'be 12–64 characters long',
74
+ CHARSET: 'use only printable ASCII characters (no accents, CJK, emoji or tabs)',
75
+ COMPLEXITY: 'contain at least 3 of: upper case, lower case, digit, symbol (space counts)',
76
+ COMMON_PASSWORD: 'not be one of the 10,000 most common passwords',
77
+ KEYBOARD_RUN: 'not contain 4 or more adjacent keyboard keys (qwer, asdf, 1234)',
78
+ SEQUENCE: 'not contain 4 or more letters or digits in sequence (abcd, 4321)',
79
+ REPEAT: 'not repeat a character 3 or more times in a row (aaa)',
80
+ CONTAINS_EMP_NO: 'not contain your employee ID',
81
+ status: { met: 'met', unmet: 'not met', idle: 'not checked yet', server: 'checked when you submit' },
82
+ },
59
83
  pages: {
60
- login: { title: 'Sign in to your account', description: 'Enter your employee ID and password.', noAccount: 'No account yet?', panelTitle: 'ESG Idea Contest', panelBody: 'Register, form a team and submit your Idea.', submitting: 'Signing in…', sessionEnded: 'Your session has ended. Please sign in again.' },
84
+ idle: { title: 'Are you still there?', body: 'You have been inactive for a while. For your security your session will end in about 2 minutes unless you continue.', continue: 'Continue' },
85
+ login: { title: 'Sign in to your account', description: 'Enter your employee ID and password.', noAccount: 'No account yet?', panelTitle: 'ESG Idea Contest', panelBody: 'Register, form a team and submit your Idea.', submitting: 'Signing in…', retryIn: 'Try again in {{seconds}} s', canRetry: 'You can sign in again.', sessionEnded: 'Your session has ended. Please sign in again.' },
61
86
  register: {
62
87
  title: 'Create an account',
63
88
  description: 'Enter your details below to register.',
64
89
  empNoHint: '6 digits.',
65
90
  emailHint: 'Letters, digits and symbols only, no spaces.',
66
- passwordHint: '12–64 printable ASCII characters, with at least 3 of: upper case, lower case, digit, symbol.',
67
91
  mismatch: 'The passwords do not match.',
68
92
  haveAccount: 'Already registered?',
69
93
  submit: 'Create account',
@@ -72,8 +96,26 @@ export const en = {
72
96
  success: 'Your account was created. Sign in to continue.',
73
97
  },
74
98
  ideaList: { search: 'Search', apply: 'Apply filters', create: 'Create an Idea', empty: 'No Ideas yet.' },
75
- ideaForm: { slidesHint: 'PDF or PPTX, up to 5 MiB.' },
76
- ideaCreate: { title: 'Create an Idea', submit: 'Submit Idea' },
99
+ ideaForm: {
100
+ slidesHint: 'PDF or PPTX, up to 5 MiB, named exactly as shown above.',
101
+ choose: 'Choose…',
102
+ leaderHint: 'You are the Leader of the Idea you create.',
103
+ nameHint: 'The characters / \\ : * ? " < > | are not allowed.',
104
+ count: '{{n}} / {{max}} characters',
105
+ expectedTitle: 'Expected file name',
106
+ expectedPending: 'Choose a category and an organization and enter the Idea name to see the expected file name.',
107
+ expectedHint: 'Name your slide file exactly like this before you choose it. The file is not renamed for you.',
108
+ copy: 'Copy the {{ext}} file name',
109
+ copied: 'File name copied.',
110
+ copyFailed: 'The file name could not be copied. Select it and copy it by hand.',
111
+ expectedIs: 'Expected: {{name}}',
112
+ },
113
+ ideaCreate: {
114
+ title: 'Create an Idea',
115
+ submit: 'Submit Idea',
116
+ submitting: 'Submitting…',
117
+ closedHint: 'Submissions are not open, so the Idea cannot be submitted now.',
118
+ },
77
119
  ideaEdit: { title: 'Edit Idea' },
78
120
  ideaDetail: {
79
121
  title: 'Idea',
@@ -53,14 +53,38 @@ export const zhTW: Dictionary = {
53
53
  members: '成員',
54
54
  leaderEmpNo: '組長員工編號',
55
55
  },
56
+ // 正式名稱由負責人提供前的暫定標籤(OI-17)。
57
+ enums: {
58
+ category: { COOL_IDEA: '酷點子獎', OUTSTANDING_IMPACT: '卓越影響獎' },
59
+ org: { AAID: 'AAID', BSID: 'BSID', ICSD: 'ICSD', TSID: 'TSID', PLED: 'PLED', DEID: 'DEID' },
60
+ direction: {
61
+ GREEN_MANUFACTURING: '綠色製造',
62
+ RESPONSIBLE_SUPPLY_CHAIN: '打造負責任供應鏈',
63
+ INCLUSIVE_WORKPLACE: '營造健康共融職場',
64
+ TALENT_DEVELOPMENT: '人才培育',
65
+ CARING_FOR_DISADVANTAGED: '關懷弱勢',
66
+ },
67
+ },
68
+ passwordRules: {
69
+ title: '密碼必須:',
70
+ LENGTH: '長度為 12–64 個字元',
71
+ CHARSET: '僅使用可列印的 ASCII 字元(不可含重音字母、中日韓文字、表情符號或 Tab)',
72
+ COMPLEXITY: '至少包含大寫、小寫、數字、符號其中 3 類(空白算符號)',
73
+ COMMON_PASSWORD: '不可是最常見的 10,000 個密碼之一',
74
+ KEYBOARD_RUN: '不可包含 4 個以上相鄰的鍵盤按鍵(qwer、asdf、1234)',
75
+ SEQUENCE: '不可包含 4 個以上連續的字母或數字(abcd、4321)',
76
+ REPEAT: '同一字元不可連續出現 3 次以上(aaa)',
77
+ CONTAINS_EMP_NO: '不可包含您的員工編號',
78
+ status: { met: '已符合', unmet: '未符合', idle: '尚未檢查', server: '送出時檢查' },
79
+ },
56
80
  pages: {
57
- login: { title: '登入您的帳號', description: '請輸入員工編號與密碼。', noAccount: '還沒有帳號?', panelTitle: 'ESG 創意競賽', panelBody: '註冊、組隊並提交您的創意。', submitting: '登入中…', sessionEnded: '您的登入已結束,請重新登入。' },
81
+ idle: { title: '您還在嗎?', body: '您已閒置一段時間。為了安全,若不繼續使用,您的登入將在約 2 分鐘後結束。', continue: '繼續' },
82
+ login: { title: '登入您的帳號', description: '請輸入員工編號與密碼。', noAccount: '還沒有帳號?', panelTitle: 'ESG 創意競賽', panelBody: '註冊、組隊並提交您的創意。', submitting: '登入中…', retryIn: '{{seconds}} 秒後可再試', canRetry: '您可以再次登入。', sessionEnded: '您的登入已結束,請重新登入。' },
58
83
  register: {
59
84
  title: '建立帳號',
60
85
  description: '請在下方輸入資料以完成註冊。',
61
86
  empNoHint: '6 位數字。',
62
87
  emailHint: '僅限英數字與符號,不可包含空白。',
63
- passwordHint: '12–64 個可列印 ASCII 字元,且至少包含大寫、小寫、數字、符號其中 3 類。',
64
88
  mismatch: '兩次輸入的密碼不一致。',
65
89
  haveAccount: '已經註冊?',
66
90
  submit: '建立帳號',
@@ -69,8 +93,26 @@ export const zhTW: Dictionary = {
69
93
  success: '帳號已建立,請登入。',
70
94
  },
71
95
  ideaList: { search: '搜尋', apply: '套用篩選', create: '建立提案', empty: '目前沒有提案。' },
72
- ideaForm: { slidesHint: 'PDF 或 PPTX,最大 5 MiB。' },
73
- ideaCreate: { title: '建立提案', submit: '送出提案' },
96
+ ideaForm: {
97
+ slidesHint: 'PDF 或 PPTX,最大 5 MiB,檔名須與上方完全相同。',
98
+ choose: '請選擇…',
99
+ leaderHint: '您將成為所建立提案的組長。',
100
+ nameHint: '不可包含 / \\ : * ? " < > | 等字元。',
101
+ count: '{{n}} / {{max}} 字',
102
+ expectedTitle: '應使用的檔名',
103
+ expectedPending: '選擇獎項類別與單位並輸入提案名稱後,即會顯示應使用的檔名。',
104
+ expectedHint: '請在選擇檔案前,將簡報檔案命名為下列名稱。系統不會自動為您改名。',
105
+ copy: '複製 {{ext}} 檔名',
106
+ copied: '已複製檔名。',
107
+ copyFailed: '無法複製檔名,請手動選取後複製。',
108
+ expectedIs: '應為:{{name}}',
109
+ },
110
+ ideaCreate: {
111
+ title: '建立提案',
112
+ submit: '送出提案',
113
+ submitting: '送出中…',
114
+ closedHint: '目前不在收件期間,暫時無法送出提案。',
115
+ },
74
116
  ideaEdit: { title: '編輯提案' },
75
117
  ideaDetail: {
76
118
  title: '提案',
@@ -0,0 +1,74 @@
1
+ // Slide rules of PRD §6.8 (Frontend.md §5.5). The back end implements the same
2
+ // rules (backend/internal/storage); both run testdata/expected-filename.json.
3
+ // The server is the final judge: this is a pre-check that saves a doomed upload.
4
+
5
+ export const CATEGORIES = ['COOL_IDEA', 'OUTSTANDING_IMPACT'] as const;
6
+ export type Category = (typeof CATEGORIES)[number];
7
+
8
+ /** 5 MiB, inclusive (PRD §6.8). */
9
+ export const MAX_SLIDES_BYTES = 5 * 1024 * 1024;
10
+ /** The longest expected filename in UTF-8 bytes (PRD §6.2), measured with `.pptx`. */
11
+ export const MAX_FILENAME_BYTES = 255;
12
+ const LONGEST_EXT = '.pptx';
13
+
14
+ export const SLIDE_EXTS = ['pdf', 'pptx'] as const;
15
+ export type SlideExt = (typeof SLIDE_EXTS)[number];
16
+
17
+ /**
18
+ * `{I|O}_{org}_{leaderName}_{leaderEmpNo}_{ideaName}` without extension, by plain
19
+ * concatenation, NFC-normalized. Inputs are the cleaned values.
20
+ */
21
+ export function expectedFilenameBase(category: Category, org: string, leaderName: string, leaderEmpNo: string, ideaName: string): string {
22
+ const prefix = category === 'COOL_IDEA' ? 'I' : 'O';
23
+ return `${prefix}_${org}_${leaderName}_${leaderEmpNo}_${ideaName}`.normalize('NFC');
24
+ }
25
+
26
+ export function utf8Bytes(s: string): number {
27
+ return new TextEncoder().encode(s).length;
28
+ }
29
+
30
+ /** True when base + `.pptx` exceeds 255 UTF-8 bytes → `FILENAME_TOO_LONG` (PRD §6.6). */
31
+ export function filenameTooLong(base: string): boolean {
32
+ return utf8Bytes(base + LONGEST_EXT) > MAX_FILENAME_BYTES;
33
+ }
34
+
35
+ export type SlidesProblem = '' | 'REQUIRED' | 'FILE_TOO_LARGE' | 'EMPTY_FILE' | 'UNSUPPORTED_FILE_TYPE' | 'FILENAME_MISMATCH';
36
+
37
+ /** The part after the last dot, lower-cased; '' when there is none. */
38
+ function extOf(name: string): string {
39
+ const dot = name.lastIndexOf('.');
40
+ return dot < 0 ? '' : name.slice(dot + 1).toLowerCase();
41
+ }
42
+
43
+ const PDF_MAGIC = [0x25, 0x50, 0x44, 0x46, 0x2d]; // %PDF-
44
+ const ZIP_MAGIC = [0x50, 0x4b, 0x03, 0x04]; // PK\3\4
45
+
46
+ const startsWith = (b: Uint8Array, magic: number[]) => magic.every((m, i) => b[i] === m);
47
+
48
+ async function head(file: Blob, n: number): Promise<Uint8Array> {
49
+ return new Uint8Array(await file.slice(0, n).arrayBuffer());
50
+ }
51
+
52
+ /**
53
+ * Pre-check in the server's order (PRD §6.8): count, size, not empty, magic bytes
54
+ * with an agreeing extension, filename. Stops at the first failing step.
55
+ * `expectedBase` is null while the text fields cannot produce one; the filename
56
+ * step is then skipped (the server does not validate the file when text is invalid).
57
+ *
58
+ * The client only checks the ZIP signature for a PPTX; the central directory
59
+ * check stays with the server.
60
+ */
61
+ export async function precheckSlides(files: readonly File[], expectedBase: string | null): Promise<SlidesProblem> {
62
+ if (files.length !== 1) return 'REQUIRED';
63
+ const file = files[0]!;
64
+ if (file.size > MAX_SLIDES_BYTES) return 'FILE_TOO_LARGE';
65
+ if (file.size === 0) return 'EMPTY_FILE';
66
+ const name = file.name.normalize('NFC');
67
+ const ext = extOf(name);
68
+ const bytes = await head(file, PDF_MAGIC.length);
69
+ const typeOk = (ext === 'pdf' && startsWith(bytes, PDF_MAGIC)) || (ext === 'pptx' && startsWith(bytes, ZIP_MAGIC));
70
+ if (!typeOk) return 'UNSUPPORTED_FILE_TYPE';
71
+ if (expectedBase === null) return '';
72
+ const base = name.slice(0, name.length - ext.length - 1);
73
+ return base === expectedBase.normalize('NFC') ? '' : 'FILENAME_MISMATCH';
74
+ }
@@ -0,0 +1,51 @@
1
+ import fixture from 'testdata/expected-filename.json';
2
+ import { expectedFilenameBase, filenameTooLong, MAX_SLIDES_BYTES, precheckSlides, utf8Bytes, type Category } from './index';
3
+
4
+ describe('expectedFilenameBase (testdata/expected-filename.json)', () => {
5
+ it.each(fixture.cases.map((c) => [c.id, c] as const))('%s', (_id, c) => {
6
+ const base = expectedFilenameBase(c.category as Category, c.org, c.leaderName, c.leaderEmpNo, c.ideaName);
7
+ expect(base).toBe(c.wantBase);
8
+ expect(utf8Bytes(base + '.pptx')).toBe(c.wantBytes);
9
+ expect(filenameTooLong(base)).toBe(c.wantTooLong);
10
+ });
11
+ });
12
+
13
+ const PDF = '%PDF-1.7\n';
14
+ const ZIP = 'PK\u0003\u0004rest';
15
+ const BASE = 'I_BSID_王小明_089290_成立供應商永續學院';
16
+
17
+ const file = (name: string, content: string | Uint8Array = PDF) => new File([content as BlobPart], name);
18
+
19
+ describe('precheckSlides (PRD §6.8 order)', () => {
20
+ it.each([
21
+ ['no file', [], 'REQUIRED'],
22
+ ['two files', [file(`${BASE}.pdf`), file(`${BASE}.pdf`)], 'REQUIRED'],
23
+ ['empty', [file(`${BASE}.pdf`, '')], 'EMPTY_FILE'],
24
+ ['PDF bytes, .pptx name', [file(`${BASE}.pptx`)], 'UNSUPPORTED_FILE_TYPE'],
25
+ ['ZIP bytes, .pdf name', [file(`${BASE}.pdf`, ZIP)], 'UNSUPPORTED_FILE_TYPE'],
26
+ ['.docx', [file(`${BASE}.docx`, ZIP)], 'UNSUPPORTED_FILE_TYPE'],
27
+ ['no extension', [file(BASE)], 'UNSUPPORTED_FILE_TYPE'],
28
+ ['wrong name', [file('slides.pdf')], 'FILENAME_MISMATCH'],
29
+ ['name with a path', [file(`dir/${BASE}.pdf`)], 'FILENAME_MISMATCH'],
30
+ ['PDF ok', [file(`${BASE}.pdf`)], ''],
31
+ ['PPTX ok, upper-case extension', [file(`${BASE}.PPTX`, ZIP)], ''],
32
+ ['NFD filename (macOS) matches', [file('I_ICSD_Zoe_000002_Café.pdf')], ''],
33
+ ] as const)('%s → %j', async (_n, files, want) => {
34
+ const base = files[0]?.name.startsWith('I_ICSD') ? 'I_ICSD_Zoe_000002_Café' : BASE;
35
+ expect(await precheckSlides(files, base)).toBe(want);
36
+ });
37
+
38
+ it('size is checked before emptiness and type; 5 MiB is allowed', async () => {
39
+ const big = file(`${BASE}.pdf`);
40
+ Object.defineProperty(big, 'size', { value: MAX_SLIDES_BYTES + 1 });
41
+ expect(await precheckSlides([big], BASE)).toBe('FILE_TOO_LARGE');
42
+ const exact = new File([PDF, new Uint8Array(MAX_SLIDES_BYTES - PDF.length)], `${BASE}.pdf`);
43
+ expect(exact.size).toBe(MAX_SLIDES_BYTES);
44
+ expect(await precheckSlides([exact], BASE)).toBe('');
45
+ });
46
+
47
+ it('skips the filename step while no expected name exists', async () => {
48
+ expect(await precheckSlides([file('anything.pdf')], null)).toBe('');
49
+ expect(await precheckSlides([file('anything.pptx')], null)).toBe('UNSUPPORTED_FILE_TYPE');
50
+ });
51
+ });