Files
redefined-designs/frontend/src/intake/Submit.tsx
T
bermudalamb bcecda9122
Linting / lint (pull_request) Successful in 2m27s
SonarQube Analysis / sonarqube (pull_request) Successful in 22m27s
fix(intake): stop a throttled sender being told their link is dead (#222)
Adding e2e specs for the submission page found a defect in the page they were written for, which is what they were for.

One limiter counted page loads and submissions against the same twenty-per-quarter-hour allowance, so a sender working through a box of stock ran out after ten items — the exact person the feature exists for, and the exact case the limiter's own comment said must not be refused. The comment said refusing them costs a consignment while the number quietly did it.

Worse, the page could not tell a 429 from a 404. `fetchIntakeLink` treated any non-OK response as "no link", so a throttled sender was told "This link is not active" and sent to ask for a replacement — which could not have helped, because the problem was their address and a minute of patience. Two conditions needing opposite reactions were sharing a message.

Now two limiters, because the two requests cost different things. Reading a link hits one indexed row and writes nothing, so that allowance is generous at 120: someone re-reading the form or losing their signal should never be told to wait. Submitting writes up to six files, so that is the one worth bounding, at 30 — more than anyone photographing items can manage and far less than a script would want.

The page gains a third state. Unknown, revoked and used-up still collapse into one "not active" card, because whether a link exists is not something a stranger needs to learn. Throttled is deliberately kept apart from them, since "wait a moment" and "go and ask for another link" are opposite instructions.

Measured rather than assumed, on a freshly started process both times: before, 25 page loads produced 14 rejections; after, 40 produce none. The first attempt at that measurement was wrong and worth recording — the restart had failed with EADDRINUSE, so it read 30 of 30 against the old process's already-exhausted store.

The two specs now pass in a full parallel run alongside everything else. They are scoped the way #241 asks: unique run ids, assertions naming only this run's rows, nothing asserted about the table as a whole.

Backend: 284 integration, 309 unit. Frontend: build clean, lint unchanged at 2 pre-existing warnings.

Ref #222, #241
2026-08-31 15:42:26 -05:00

184 lines
6.1 KiB
TypeScript

import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import Typography from 'antd/es/typography';
import Card from 'antd/es/card';
import Upload from 'antd/es/upload';
import Button from 'antd/es/button';
import Input from 'antd/es/input';
import Alert from 'antd/es/alert';
import Spin from 'antd/es/spin';
import Space from 'antd/es/space';
import { UploadOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface';
import { fetchIntakeLink, submitItem } from './intakeApi';
import type { LinkState } from './intakeApi';
const { Title, Paragraph } = Typography;
const { TextArea } = Input;
/**
* Where someone with no account sends in photos of one item (#222).
*
* The three types the server will accept, and the same per-request cap. Listed
* here so the file picker offers exactly what will be taken and the count is
* bounded before anything is uploaded — but the server checks both again,
* because everything on this page is under the sender's control.
*/
const ACCEPT = 'image/jpeg,image/png,image/webp';
const MAX_IMAGES = 6;
export default function Submit() {
const { token = '' } = useParams();
const [state, setState] = useState<LinkState>({ kind: 'unusable' });
const [checking, setChecking] = useState(true);
const [files, setFiles] = useState<UploadFile[]>([]);
const [note, setNote] = useState('');
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void fetchIntakeLink(token).then((result) => {
// The token can change if the URL does, and a late response from the
// previous one would otherwise overwrite the current answer.
if (cancelled) return;
setState(result);
setChecking(false);
});
return () => {
cancelled = true;
};
}, [token]);
async function send() {
setSending(true);
setError(null);
const result = await submitItem(
token,
// originFileObj is what antd hands back for a file it did not upload
// itself; beforeUpload returning false is what keeps them here. flatMap
// rather than map-then-filter because a type predicate cannot narrow to
// File here — antd's RcFile extends it, so the predicate would widen.
files.flatMap((f) => (f.originFileObj ? [f.originFileObj] : [])),
note
);
setSending(false);
if (result.ok) {
setSent(true);
return;
}
setError(result.error);
}
if (checking) {
return (
<div style={{ maxWidth: 640, margin: '48px auto', padding: '0 16px', textAlign: 'center' }}>
<Spin />
</div>
);
}
// Kept apart from the state below on purpose. The two need opposite
// reactions — wait a moment, versus go and ask for a different link — so
// telling a throttled sender their link was dead would send them to fetch a
// replacement that could not have helped.
if (state.kind === 'throttled') {
return (
<div style={{ maxWidth: 640, margin: '48px auto', padding: '0 16px' }}>
<Card>
<Title level={3}>Too many requests just now</Title>
<Paragraph>
Your link is fine this page has just been asked for too many times from your
connection. Wait a minute and reload.
</Paragraph>
</Card>
</div>
);
}
// One state for unknown, revoked and used-up alike, matching the server's
// single 404. Saying which it was would tell a stranger whether a link they
// guessed at exists.
if (state.kind === 'unusable') {
return (
<div style={{ maxWidth: 640, margin: '48px auto', padding: '0 16px' }}>
<Card>
<Title level={3}>This link is not active</Title>
<Paragraph>
It may have been turned off, or already used as many times as it was meant for. Ask
whoever sent it to you for a new one.
</Paragraph>
</Card>
</div>
);
}
if (sent) {
return (
<div style={{ maxWidth: 640, margin: '48px auto', padding: '0 16px' }}>
<Card>
<Title level={3}>Thank you it arrived</Title>
<Paragraph>
Somebody will look at your photos and write it up. Nothing is listed for sale until they
have.
</Paragraph>
<Button
onClick={() => {
setFiles([]);
setNote('');
setSent(false);
}}
>
Send another item
</Button>
</Card>
</div>
);
}
return (
<div style={{ maxWidth: 640, margin: '48px auto', padding: '0 16px' }}>
<Card>
<Title level={3}>Send in an item</Title>
<Paragraph>
Photos of one item, and anything you know about it. Send each item separately.
</Paragraph>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Upload
accept={ACCEPT}
multiple
listType="picture"
maxCount={MAX_IMAGES}
fileList={files}
// Returning false stops antd uploading each file the moment it is
// picked; they are sent together by `send` instead, which is what
// makes this one request the server can accept or refuse as a unit.
beforeUpload={() => false}
onChange={({ fileList }) => setFiles(fileList)}
>
<Button icon={<UploadOutlined />}>Choose photos</Button>
</Upload>
<TextArea
rows={4}
value={note}
onChange={(e) => setNote(e.target.value)}
aria-label="Anything you know about this item"
placeholder="What is it, what is it made of, how big, what condition, where did it come from? Anything you know helps — a photo cannot show any of it."
/>
{error && <Alert type="error" message={error} showIcon />}
<Button type="primary" onClick={send} loading={sending} disabled={files.length === 0}>
Send
</Button>
</Space>
</Card>
</div>
);
}