One endpoint does everything
Context
Every small change to draft creation turns into a day of work, because the whole thing
is one function nobody wants to touch. POST /api/drafts reads the request body, checks
the firm's monthly draft quota, writes to SQLite, calls the drafting model, saves the
generated sections, emails the attorney a link, and logs an analytics event, all inline
in the route. The only way to run any of it is over HTTP, so none of the rules have tests
of their own. Support has two open tickets on it: a firm on a 10 draft plan has 11 drafts
this month, and a typo in a request body comes back as a 500 with a Python error message
in the response. Product wants a new feature on the same endpoint this week.
The drafting model here is a simulated stand-in, not a network call, so the same inputs
always produce the same text.
Your task
- Before you change anything, write down two numbers: how many lines the
create_draft
handler is today, and how many of the rules in it you can test without going through
HTTP. Put both in NOTES.md at the end with the after numbers next to them.
- Split the endpoint into layers: the HTTP handler, input validation, a draft service
that holds the rules, a repository that owns the SQL, a notifier that owns the email,
and the model client.
create_app(options) keeps the same options and the happy path
keeps the same responses. See "What stays the same" below.
- Fix the problems you find along the way. Read the quota check, the order of the steps
in the handler, what happens on bad input, and how the email body is built. Say in
NOTES.md what you found and what you decided not to do.
- Add "Regenerate a section" end to end, backend and UI, to the contract below.
- Write a short
NOTES.md (5 to 10 lines): what you found, what you changed, the two
numbers from step 1 before and after, and anything you would do next with more time.
The regenerate feature
POST /api/drafts/<id>/regenerate with a JSON body {"section": "background"}, where
section is one of background, summary or claims.
- It regenerates that one section and leaves the other two alone. The new text has to
differ from what was there, so pass an
attempt number to model_client.draft_section.
- Success is 200 with the same draft JSON the create endpoint returns, plus a
regensRemaining field.
- Only drafts with status
ready can be regenerated. Anything else is
409 {"error": "draft_not_ready"}.
- An id that is not in the database is 404
{"error": "draft_not_found"}. An id that is
not a number is 400 {"error": "invalid_draft_id"}. A missing or unknown section is
400 {"error": "invalid_section"}.
- A draft gets at most 3 regenerations in any rolling 24 hours. The 4th is
429
{"error": "regen_limit_reached"}. Rolling means counted from now back 24 hours,
not per calendar day.
- Items from
GET /api/drafts also carry regensRemaining.
- In the UI,
renderDraftList puts a <button data-action="regen" data-draft-id="{id}">
on ready drafts only, with the count in the label, for example
Regenerate (2 left). At 0 the button is still there and is disabled.
- The click goes through
web/api.js as regenerateSection(draftId, section), which
posts to the endpoint above.
What stays the same
Our tests reach for these names, so keep them.
server.py with create_app(options). The option keys are db_path (a path to a
SQLite file), mailer, model, clock (a function of no arguments returning a
timezone aware datetime, called once per request), app_url and seed.
- The app object returned by
create_app keeps app.request(method, path, body, headers),
which returns a response with .status, .body and .json().
db.py, mailer.py with Mailer and FakeMailer, model_client.py,
web/render.js with renderDraftList(drafts), web/api.js and web/main.js.
- The
drafts table keeps its columns, including status with the values pending,
ready and failed. Add tables and columns as you need them.
POST /api/drafts keeps returning 201 with id, matterId, title, jurisdiction,
status, createdAt and sections. GET /api/drafts keeps returning
{"drafts": [...]}, newest first, with id, matterId, matterTitle, title,
jurisdiction, status and createdAt. Extra fields are fine.
- Errors are JSON with a short code, as in
{"error": "invalid_title"}.
If you pull the rules into a service, use this shape, because we test the rules with
fakes and no HTTP:
class DraftService:
def __init__(self, repo, notifier, model, clock): ...
def create_draft(self, user_id, matter_id, title, jurisdiction): ...
def regenerate_section(self, draft_id, section): ...
The repository methods the service may call, all times passed as ISO strings:
get_user(user_id) get_draft(draft_id)
get_matter(matter_id) get_sections(draft_id)
get_firm(firm_id) update_section(draft_id, section, body, updated_at)
save_sections(draft_id, sections, updated_at)
mark_ready(draft_id) / mark_failed(draft_id)
count_regens_since(draft_id, since) record_regen(draft_id, section, created_at)
record_event(name, user_id, payload, created_at)
list_drafts_for_user(user_id, since)
create_draft_if_under_quota(matter_id, user_id, title, jurisdiction, created_at, month_start)
create_draft_if_under_quota returns the new draft id, or None when the firm is
already at its monthly limit. The notifier gets send_draft_ready(user, draft, matter).
What's here
server.py HTTP layer, a small Flask-shaped router, and create_app(options)
db.py SQLite schema, connection helper, demo firm and matters
mailer.py Mailer (logs the message) and FakeMailer (keeps it in a list)
model_client.py DraftingModel, a deterministic stand-in for the drafting model
web/render.js pure functions that turn draft data into HTML strings
web/api.js every call to the backend
web/main.js the one file that touches the DOM
web/index.html the page
tests/test_drafts_api.py current backend behavior (all passing)
tests/render.test.js current render behavior (all passing)
Python 3.11 standard library only, Node 22.18 or newer for the frontend tests. No
installs, no network.
Running it
python3 -m unittest discover -s tests -v
node --test --no-warnings 'tests/*.test.js'
python3 server.py # then open http://localhost:8000
python3 server.py writes to drafts.db in the current directory. Delete that file to
start over.
Time
Aim for about 25-30 minutes. You don't need to finish everything; we care more about how
you approach it than about completeness.