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
- Pull the rules out of
create_draft so they can be tested without going
through HTTP. Use the DraftService shape under "What stays the same", since
our tests build it with fakes and a fixed clock. create_app(options) keeps
the same options and the happy path keeps the same responses.
- Fix the problems you find while you are in there. Read the quota check, the
order of the steps in the handler, what happens on bad input, and how the
email body is built.
- Add "Regenerate a section", backend and UI, to the contract under "The
regenerate feature". Then write a short
NOTES.md, three or four lines: what
you found, what you changed, and what you decided not to do.
This is the broadest problem in the set and finishing all three is not the
expectation. Say in NOTES.md how many of the handler's rules you can test
without HTTP once you are done. That number moving is the point of the exercise.
Start here
server.py create_app and the create_draft handler. Almost everything is in here.
db.py the SQL
mailer.py the email
model_client.py the model call
web/render.js renderDraftList, where the new control goes
web/api.js the browser's side of the API
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 30 minutes. You do not need to finish everything. We care more
about how you approach it than about how much you get through.