@johnnyboy11234/esg-idea-contest 0.3.1 → 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 +36 -0
- package/backend/cmd/server/main.go +1 -0
- 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/password.go +8 -2
- package/backend/internal/account/sequence.go +54 -0
- package/backend/internal/account/sequence_test.go +38 -0
- package/backend/internal/api/api.gen.go +38 -0
- package/backend/internal/idea/create.go +5 -1
- package/backend/internal/idea/create_integration_test.go +33 -3
- 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 +3 -0
- package/backend/internal/idea/service.go +2 -1
- package/backend/internal/server/api.go +5 -0
- package/backend/internal/server/binding.go +4 -0
- package/backend/internal/server/server.go +20 -1
- package/backend/internal/server/slides_test.go +82 -0
- package/backend/internal/shared/web/transport.go +4 -3
- package/backend/test/arch/arch_test.go +5 -1
- package/backend/test/matrix/matrix_integration_test.go +6 -0
- 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/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/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/types.generated.ts +319 -1
- package/frontend/src/shared/i18n/locales/en.ts +45 -4
- package/frontend/src/shared/i18n/locales/zh-TW.ts +45 -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/password.json +164 -7
- package/testdata/permission-matrix.json +23 -0
- package/testdata/schema/permission-matrix.schema.json +1 -1
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { useRef, useState, type FormEvent, type ReactNode } from "react";
|
|
2
|
+
import { useTranslation } from "react-i18next";
|
|
3
|
+
import { Button } from "@/shared/ui/button";
|
|
4
|
+
import { CopyButton } from "@/shared/ui/copy-button";
|
|
5
|
+
import {
|
|
6
|
+
Field,
|
|
7
|
+
FieldDescription,
|
|
8
|
+
FieldError,
|
|
9
|
+
FieldGroup,
|
|
10
|
+
FieldLabel,
|
|
11
|
+
} from "@/shared/ui/field";
|
|
12
|
+
import { Input } from "@/shared/ui/input";
|
|
13
|
+
import { NativeSelect } from "@/shared/ui/native-select";
|
|
14
|
+
import { Textarea } from "@/shared/ui/textarea";
|
|
15
|
+
import { SLIDE_EXTS } from "@/shared/slides";
|
|
16
|
+
import { cleanName, cleanSummary, countCodePoints } from "@/shared/text";
|
|
17
|
+
import {
|
|
18
|
+
CATEGORIES,
|
|
19
|
+
createIdea,
|
|
20
|
+
DIRECTIONS,
|
|
21
|
+
expectedBaseFor,
|
|
22
|
+
IDEA_FIELDS,
|
|
23
|
+
mapServerError,
|
|
24
|
+
NAME_MAX,
|
|
25
|
+
ORGS,
|
|
26
|
+
precheck,
|
|
27
|
+
SUMMARY_MAX,
|
|
28
|
+
type FieldProblems,
|
|
29
|
+
type IdeaDetail,
|
|
30
|
+
type IdeaField,
|
|
31
|
+
type IdeaText,
|
|
32
|
+
type Leader,
|
|
33
|
+
} from "./create";
|
|
34
|
+
|
|
35
|
+
const EMPTY: IdeaText = { category: "", org: "", direction: "", name: "", summary: "" };
|
|
36
|
+
|
|
37
|
+
const OPTIONS = {
|
|
38
|
+
category: { list: CATEGORIES, key: "enums.category" },
|
|
39
|
+
org: { list: ORGS, key: "enums.org" },
|
|
40
|
+
direction: { list: DIRECTIONS, key: "enums.direction" },
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The create form (Frontend.md §5.5, §5.6, §8): code-point counters, the expected
|
|
45
|
+
* filename with copy buttons, a pre-check in the server's order, an error summary
|
|
46
|
+
* that takes focus, and server errors mapped to fields. The file is never renamed
|
|
47
|
+
* (OI-21): the user names it.
|
|
48
|
+
*/
|
|
49
|
+
export function CreateIdeaForm({
|
|
50
|
+
leader,
|
|
51
|
+
canSubmit,
|
|
52
|
+
onCreated,
|
|
53
|
+
onSubmissionRejected,
|
|
54
|
+
}: {
|
|
55
|
+
leader: Leader;
|
|
56
|
+
/** False outside the submission window; the server still decides (Frontend.md §6.3 rule 6). */
|
|
57
|
+
canSubmit: boolean;
|
|
58
|
+
onCreated: (idea: IdeaDetail) => void;
|
|
59
|
+
/** A 403 SUBMISSION_* answer: the caller refetches the deadline (Frontend.md §5.3). */
|
|
60
|
+
onSubmissionRejected?: () => void;
|
|
61
|
+
}) {
|
|
62
|
+
const { t, i18n } = useTranslation();
|
|
63
|
+
const [values, setValues] = useState<IdeaText>(EMPTY);
|
|
64
|
+
const [files, setFiles] = useState<File[]>([]);
|
|
65
|
+
const [problems, setProblems] = useState<FieldProblems>({});
|
|
66
|
+
const [serverExpected, setServerExpected] = useState<string | undefined>();
|
|
67
|
+
const [formError, setFormError] = useState<string | null>(null);
|
|
68
|
+
const [pending, setPending] = useState(false);
|
|
69
|
+
const [summaryKey, setSummaryKey] = useState(0);
|
|
70
|
+
const summary = useRef<HTMLDivElement>(null);
|
|
71
|
+
const form = useRef<HTMLFormElement>(null);
|
|
72
|
+
|
|
73
|
+
const expectedBase = expectedBaseFor(values, leader);
|
|
74
|
+
const fileExt = files[0]?.name.split(".").pop()?.toLowerCase();
|
|
75
|
+
|
|
76
|
+
const message = (f: IdeaField, code: string): string => {
|
|
77
|
+
const text = i18n.exists(`errors.${code}`) ? t(`errors.${code}`) : t("errors.VALIDATION_FAILED");
|
|
78
|
+
if (f !== "slides" || code !== "FILENAME_MISMATCH") return text;
|
|
79
|
+
const expected =
|
|
80
|
+
serverExpected ??
|
|
81
|
+
(expectedBase && `${expectedBase}.${fileExt === "pptx" ? "pptx" : "pdf"}`);
|
|
82
|
+
return expected ? `${text} ${t("pages.ideaForm.expectedIs", { name: expected })}` : text;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const fail = (p: FieldProblems, rest: string | null) => {
|
|
86
|
+
setProblems(p);
|
|
87
|
+
setFormError(rest);
|
|
88
|
+
setSummaryKey((k) => k + 1);
|
|
89
|
+
queueMicrotask(() => summary.current?.focus()); // WCAG 3.3.1
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const onSubmit = async (e: FormEvent) => {
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
if (pending || !canSubmit) return;
|
|
95
|
+
setServerExpected(undefined);
|
|
96
|
+
const { values: body, problems: local } = await precheck(values, files, leader);
|
|
97
|
+
if (Object.keys(local).length > 0) {
|
|
98
|
+
fail(local, null);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
setPending(true);
|
|
102
|
+
setProblems({});
|
|
103
|
+
setFormError(null);
|
|
104
|
+
try {
|
|
105
|
+
onCreated(await createIdea(body, files[0]!));
|
|
106
|
+
} catch (err) {
|
|
107
|
+
const { problems: p, rest, expected } = mapServerError(err);
|
|
108
|
+
setServerExpected(expected);
|
|
109
|
+
if (rest?.code === "SUBMISSION_CLOSED" || rest?.code === "SUBMISSION_NOT_OPEN") onSubmissionRejected?.();
|
|
110
|
+
fail(
|
|
111
|
+
p,
|
|
112
|
+
rest
|
|
113
|
+
? t(`errors.${rest.code}`) + (rest.requestId ? ` ${t("common.requestId", { id: rest.requestId })}` : "")
|
|
114
|
+
: null,
|
|
115
|
+
);
|
|
116
|
+
} finally {
|
|
117
|
+
setPending(false);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const focusField = (f: IdeaField) => (e: { preventDefault: () => void }) => {
|
|
122
|
+
e.preventDefault();
|
|
123
|
+
form.current?.querySelector<HTMLElement>(`#${f}`)?.focus();
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const set = (f: keyof IdeaText) => (e: { target: { value: string } }) =>
|
|
127
|
+
setValues((v) => ({ ...v, [f]: e.target.value }));
|
|
128
|
+
|
|
129
|
+
/** Common ARIA wiring of a control; `extra` ids (hint, counter) come first. */
|
|
130
|
+
const aria = (f: IdeaField, ...extra: string[]) => {
|
|
131
|
+
const problem = problems[f];
|
|
132
|
+
return {
|
|
133
|
+
id: f,
|
|
134
|
+
name: f,
|
|
135
|
+
required: true,
|
|
136
|
+
"aria-required": true as const,
|
|
137
|
+
"aria-invalid": problem ? true : undefined,
|
|
138
|
+
"aria-describedby": [...extra, problem && `${f}-error`].filter(Boolean).join(" ") || undefined,
|
|
139
|
+
};
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const wrap = (f: IdeaField, control: ReactNode, below?: ReactNode) => (
|
|
143
|
+
<Field key={f} data-invalid={problems[f] ? true : undefined}>
|
|
144
|
+
<FieldLabel htmlFor={f}>{t(`fields.${f === "name" ? "ideaName" : f}`)}</FieldLabel>
|
|
145
|
+
{control}
|
|
146
|
+
{below}
|
|
147
|
+
{problems[f] && (
|
|
148
|
+
<FieldError id={`${f}-error`} role={undefined}>
|
|
149
|
+
{message(f, problems[f])}
|
|
150
|
+
</FieldError>
|
|
151
|
+
)}
|
|
152
|
+
</Field>
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const counter = (f: "name" | "summary", n: number, max: number) => (
|
|
156
|
+
<FieldDescription id={`${f}-count`} className={n > max ? "text-destructive" : undefined}>
|
|
157
|
+
{t("pages.ideaForm.count", { n, max })}
|
|
158
|
+
</FieldDescription>
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const listed = IDEA_FIELDS.filter((f) => problems[f] !== undefined);
|
|
162
|
+
const hasSummary = listed.length > 0 || formError !== null;
|
|
163
|
+
const nameCount = countCodePoints(cleanName(values.name));
|
|
164
|
+
const summaryCount = countCodePoints(cleanSummary(values.summary));
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<form ref={form} onSubmit={(e) => void onSubmit(e)} noValidate>
|
|
168
|
+
<FieldGroup>
|
|
169
|
+
{hasSummary && (
|
|
170
|
+
<div
|
|
171
|
+
key={summaryKey}
|
|
172
|
+
ref={summary}
|
|
173
|
+
role="alert"
|
|
174
|
+
tabIndex={-1}
|
|
175
|
+
className="rounded-md border border-destructive p-3 text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-destructive/40"
|
|
176
|
+
>
|
|
177
|
+
<p className="font-medium text-destructive">{t("pages.register.summaryTitle")}</p>
|
|
178
|
+
{formError !== null && <p>{formError}</p>}
|
|
179
|
+
{listed.length > 0 && (
|
|
180
|
+
<ul className="mt-1 list-disc pl-5">
|
|
181
|
+
{listed.map((f) => (
|
|
182
|
+
<li key={f}>
|
|
183
|
+
<a href={`#${f}`} onClick={focusField(f)} className="underline underline-offset-4">
|
|
184
|
+
{t(`fields.${f === "name" ? "ideaName" : f}`)}: {message(f, problems[f]!)}
|
|
185
|
+
</a>
|
|
186
|
+
</li>
|
|
187
|
+
))}
|
|
188
|
+
</ul>
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
|
|
193
|
+
<div className="flex flex-col gap-1 text-sm">
|
|
194
|
+
<p className="font-medium">{t("fields.leader")}</p>
|
|
195
|
+
<p>
|
|
196
|
+
{leader.name} ({leader.empNo})
|
|
197
|
+
</p>
|
|
198
|
+
<p className="text-muted-foreground">{t("pages.ideaForm.leaderHint")}</p>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
{(["category", "org", "direction"] as const).map((f) =>
|
|
202
|
+
wrap(
|
|
203
|
+
f,
|
|
204
|
+
<NativeSelect {...aria(f)} value={values[f]} onChange={set(f)}>
|
|
205
|
+
<option value="">{t("pages.ideaForm.choose")}</option>
|
|
206
|
+
{OPTIONS[f].list.map((code) => (
|
|
207
|
+
<option key={code} value={code}>
|
|
208
|
+
{t(`${OPTIONS[f].key}.${code}`)}
|
|
209
|
+
</option>
|
|
210
|
+
))}
|
|
211
|
+
</NativeSelect>,
|
|
212
|
+
),
|
|
213
|
+
)}
|
|
214
|
+
|
|
215
|
+
{wrap(
|
|
216
|
+
"name",
|
|
217
|
+
<Input {...aria("name", "name-count", "name-hint")} autoComplete="off" value={values.name} onChange={set("name")} />,
|
|
218
|
+
<>
|
|
219
|
+
{counter("name", nameCount, NAME_MAX)}
|
|
220
|
+
<FieldDescription id="name-hint">{t("pages.ideaForm.nameHint")}</FieldDescription>
|
|
221
|
+
</>,
|
|
222
|
+
)}
|
|
223
|
+
|
|
224
|
+
{wrap(
|
|
225
|
+
"summary",
|
|
226
|
+
<Textarea {...aria("summary", "summary-count")} rows={6} value={values.summary} onChange={set("summary")} />,
|
|
227
|
+
counter("summary", summaryCount, SUMMARY_MAX),
|
|
228
|
+
)}
|
|
229
|
+
|
|
230
|
+
<section aria-labelledby="expected-title" className="rounded-md border bg-muted/40 p-3 text-sm">
|
|
231
|
+
<h2 id="expected-title" className="font-medium">
|
|
232
|
+
{t("pages.ideaForm.expectedTitle")}
|
|
233
|
+
</h2>
|
|
234
|
+
{expectedBase === null ? (
|
|
235
|
+
<p className="mt-1 text-muted-foreground">{t("pages.ideaForm.expectedPending")}</p>
|
|
236
|
+
) : (
|
|
237
|
+
<>
|
|
238
|
+
<p className="mt-1 text-muted-foreground">{t("pages.ideaForm.expectedHint")}</p>
|
|
239
|
+
<ul className="mt-2 flex flex-col gap-2">
|
|
240
|
+
{SLIDE_EXTS.map((ext) => {
|
|
241
|
+
const name = `${expectedBase}.${ext}`;
|
|
242
|
+
return (
|
|
243
|
+
<li key={ext} className="flex items-center gap-2">
|
|
244
|
+
<code className="min-w-0 flex-1 break-all rounded bg-background px-2 py-1">{name}</code>
|
|
245
|
+
<CopyButton
|
|
246
|
+
value={name}
|
|
247
|
+
label={t("pages.ideaForm.copy", { ext: ext.toUpperCase() })}
|
|
248
|
+
copiedText={t("pages.ideaForm.copied")}
|
|
249
|
+
failedText={t("pages.ideaForm.copyFailed")}
|
|
250
|
+
/>
|
|
251
|
+
</li>
|
|
252
|
+
);
|
|
253
|
+
})}
|
|
254
|
+
</ul>
|
|
255
|
+
</>
|
|
256
|
+
)}
|
|
257
|
+
</section>
|
|
258
|
+
|
|
259
|
+
{wrap(
|
|
260
|
+
"slides",
|
|
261
|
+
<Input
|
|
262
|
+
{...aria("slides", "slides-hint")}
|
|
263
|
+
type="file"
|
|
264
|
+
accept=".pdf,.pptx,application/pdf,application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
|
265
|
+
onChange={(e) => setFiles(Array.from(e.target.files ?? []))}
|
|
266
|
+
/>,
|
|
267
|
+
<FieldDescription id="slides-hint">{t("pages.ideaForm.slidesHint")}</FieldDescription>,
|
|
268
|
+
)}
|
|
269
|
+
|
|
270
|
+
<Field>
|
|
271
|
+
{!canSubmit && <FieldDescription>{t("pages.ideaCreate.closedHint")}</FieldDescription>}
|
|
272
|
+
<Button type="submit" disabled={pending || !canSubmit} aria-busy={pending || undefined}>
|
|
273
|
+
{pending ? t("pages.ideaCreate.submitting") : t("pages.ideaCreate.submit")}
|
|
274
|
+
</Button>
|
|
275
|
+
</Field>
|
|
276
|
+
</FieldGroup>
|
|
277
|
+
</form>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { ApiError, apiFetch, type components } from '@/shared/api';
|
|
2
|
+
import { CATEGORIES, expectedFilenameBase, filenameTooLong, precheckSlides, type Category } from '@/shared/slides';
|
|
3
|
+
import { checkName, checkSummary } from '@/shared/text';
|
|
4
|
+
|
|
5
|
+
export type IdeaDetail = components['schemas']['IdeaDetail'];
|
|
6
|
+
|
|
7
|
+
/** Form-field order; also the order of the error summary (error-codes.md §2). */
|
|
8
|
+
export const IDEA_FIELDS = ['category', 'org', 'direction', 'name', 'summary', 'slides'] as const;
|
|
9
|
+
export type IdeaField = (typeof IDEA_FIELDS)[number];
|
|
10
|
+
export type TextField = Exclude<IdeaField, 'slides'>;
|
|
11
|
+
|
|
12
|
+
export const ORGS = ['AAID', 'BSID', 'ICSD', 'TSID', 'PLED', 'DEID'] as const;
|
|
13
|
+
export const DIRECTIONS = [
|
|
14
|
+
'GREEN_MANUFACTURING',
|
|
15
|
+
'RESPONSIBLE_SUPPLY_CHAIN',
|
|
16
|
+
'INCLUSIVE_WORKPLACE',
|
|
17
|
+
'TALENT_DEVELOPMENT',
|
|
18
|
+
'CARING_FOR_DISADVANTAGED',
|
|
19
|
+
] as const;
|
|
20
|
+
export { CATEGORIES };
|
|
21
|
+
|
|
22
|
+
export const NAME_MAX = 75;
|
|
23
|
+
export const SUMMARY_MAX = 300;
|
|
24
|
+
|
|
25
|
+
export type IdeaText = Record<TextField, string>;
|
|
26
|
+
/** A field problem is an `errors.<code>` key. */
|
|
27
|
+
export type FieldProblems = Partial<Record<IdeaField, string>>;
|
|
28
|
+
|
|
29
|
+
export interface Leader {
|
|
30
|
+
name: string;
|
|
31
|
+
empNo: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const isCategory = (v: string): v is Category => (CATEGORIES as readonly string[]).includes(v);
|
|
35
|
+
const isOneOf = (list: readonly string[], v: string) => list.includes(v);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The expected filename base for the current input, or null while category, org or
|
|
39
|
+
* name cannot produce one. Built from the cleaned name, as the server does.
|
|
40
|
+
*/
|
|
41
|
+
export function expectedBaseFor(v: Pick<IdeaText, 'category' | 'org' | 'name'>, leader: Leader): string | null {
|
|
42
|
+
const name = checkName(v.name, NAME_MAX);
|
|
43
|
+
if (!isCategory(v.category) || !isOneOf(ORGS, v.org) || name.problem !== '') return null;
|
|
44
|
+
return expectedFilenameBase(v.category, v.org, leader.name, leader.empNo, name.cleaned);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Text checks in the server's order (PRD §6.2, §6.6), one problem per field;
|
|
49
|
+
* `FILENAME_TOO_LONG` is reported on `name` like the server does. Returns the
|
|
50
|
+
* cleaned values that are sent, and the expected base when the text is valid.
|
|
51
|
+
*/
|
|
52
|
+
export function precheckText(v: IdeaText, leader: Leader): { values: IdeaText; problems: FieldProblems; expectedBase: string | null } {
|
|
53
|
+
const problems: FieldProblems = {};
|
|
54
|
+
const pick = (f: 'category' | 'org' | 'direction', list: readonly string[]) => {
|
|
55
|
+
if (v[f] === '') problems[f] = 'REQUIRED';
|
|
56
|
+
else if (!isOneOf(list, v[f])) problems[f] = 'INVALID_ENUM';
|
|
57
|
+
};
|
|
58
|
+
pick('category', CATEGORIES);
|
|
59
|
+
pick('org', ORGS);
|
|
60
|
+
pick('direction', DIRECTIONS);
|
|
61
|
+
|
|
62
|
+
const name = checkName(v.name, NAME_MAX);
|
|
63
|
+
let expectedBase: string | null = null;
|
|
64
|
+
if (name.problem) problems.name = name.problem;
|
|
65
|
+
else {
|
|
66
|
+
expectedBase = expectedBaseFor(v, leader);
|
|
67
|
+
if (expectedBase !== null && filenameTooLong(expectedBase)) {
|
|
68
|
+
problems.name = 'FILENAME_TOO_LONG';
|
|
69
|
+
expectedBase = null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const summary = checkSummary(v.summary, SUMMARY_MAX);
|
|
74
|
+
if (summary.problem) problems.summary = summary.problem;
|
|
75
|
+
|
|
76
|
+
return { values: { ...v, name: name.cleaned, summary: summary.cleaned }, problems, expectedBase };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Whole-form pre-check. A missing file is always reported; the other file steps
|
|
81
|
+
* run only when the text is valid, because the server does not validate the file
|
|
82
|
+
* otherwise (PRD §6.8) and the filename depends on the text.
|
|
83
|
+
*/
|
|
84
|
+
export async function precheck(v: IdeaText, files: readonly File[], leader: Leader): Promise<{ values: IdeaText; problems: FieldProblems }> {
|
|
85
|
+
const { values, problems, expectedBase } = precheckText(v, leader);
|
|
86
|
+
const textOk = Object.keys(problems).length === 0;
|
|
87
|
+
const slides = files.length === 0 ? 'REQUIRED' : textOk ? await precheckSlides(files, expectedBase) : '';
|
|
88
|
+
if (slides) problems.slides = slides;
|
|
89
|
+
return { values, problems };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** POST /ideas: one multipart request with every field and the slides (PRD §6.6). */
|
|
93
|
+
export async function createIdea(values: IdeaText, file: File): Promise<IdeaDetail> {
|
|
94
|
+
const body = new FormData();
|
|
95
|
+
for (const f of ['category', 'org', 'direction', 'name', 'summary'] as const) body.append(f, values[f]);
|
|
96
|
+
body.append('slides', file, file.name);
|
|
97
|
+
const res = await apiFetch('/ideas', { method: 'POST', body });
|
|
98
|
+
return (await res.json()) as IdeaDetail;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** File errors that carry no `errors[]` entry; they belong to the slides field. */
|
|
102
|
+
const SLIDES_CODES = new Set(['FILE_TOO_LARGE', 'EMPTY_FILE', 'UNSUPPORTED_FILE_TYPE', 'FILENAME_MISMATCH']);
|
|
103
|
+
|
|
104
|
+
const isField = (f: string): f is IdeaField => (IDEA_FIELDS as readonly string[]).includes(f);
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Maps a failed create to field problems; `rest` has no field (403 state, 503, …)
|
|
108
|
+
* and is shown as a form-level message. `expected` is the server's expected
|
|
109
|
+
* filename from a `FILENAME_MISMATCH`.
|
|
110
|
+
*/
|
|
111
|
+
export function mapServerError(e: unknown): { problems: FieldProblems; rest: ApiError | null; expected?: string } {
|
|
112
|
+
if (!(e instanceof ApiError)) return { problems: {}, rest: new ApiError({ status: 0, code: 'UNKNOWN' }) };
|
|
113
|
+
if (e.code === 'IDEA_DUPLICATE') return { problems: { name: 'IDEA_DUPLICATE' }, rest: null };
|
|
114
|
+
const problems: FieldProblems = {};
|
|
115
|
+
let expected: string | undefined;
|
|
116
|
+
for (const fe of e.errors) {
|
|
117
|
+
if (!isField(fe.field) || problems[fe.field] !== undefined) continue;
|
|
118
|
+
problems[fe.field] = fe.code;
|
|
119
|
+
if (fe.field === 'slides' && typeof fe.expected === 'string') expected = fe.expected;
|
|
120
|
+
}
|
|
121
|
+
if (Object.keys(problems).length === 0 && SLIDES_CODES.has(e.code)) problems.slides = e.code;
|
|
122
|
+
return { problems, rest: Object.keys(problems).length > 0 ? null : e, expected };
|
|
123
|
+
}
|