The Professional subscribes to their own agenda in Apple Calendar or
Google Calendar through a secret URL that serves iCalendar (RFC 5545).
No OAuth, no writing into their account: the feed is the complete state,
so cancelling makes the event disappear on the next fetch. See ADR 0006.
- `professionals.calendar_token`, nullable and UNIQUE, born on the first
request through the same idempotent COALESCE as the access code.
26 symbols of the existing unambiguous alphabet, ~129 bits: this URL
lives for years on someone else's server.
- `src/icalendar.rs` is pure, like `availability.rs`: folding at 75
octets without splitting a UTF-8 sequence, TEXT escaping, UTC
instants, stable UID so rescheduling moves the event. Do not copy the
frontend's `ics.ts`, which never folds.
- `DTSTAMP` is the appointment's `updated_at`, never `now()`: with the
request time the body would change on every fetch and the ETag would
never match, so the 304 would not exist. Apple fetches hourly per
subscriber.
- `confirmed`, `completed` and `no_show` only, from 90 days back into
the open future. The status list is a Rust constant handed to the
repository instead of a literal buried in SQL, so the ADR decision is
readable and testable.
- The feed answers the same 404 for an unknown token and for a
deactivated Professional, and the three panel routes go through
`require_manage_professional`.
The public route is `/calendar/{token}/feed.ics`, not `{token}.ics`:
matchit 0.8 does not support dynamic suffixes, and the other shape
panics the Router at boot instead of failing to compile.
24 unit tests and one against Postgres, which is where the status filter
and the window actually run. Also verified end to end against a real
server: 3 events out of 8, a client name with `;` and `,` escaped, no
line over 75 octets, 304 on `if-none-match`, rotation killing the old
URL, and the email landing in mailpit.
Two ROADMAP debt entries were stale and are corrected with the evidence:
clippy passes clean and the dbml is current.
Companies now carry a booking_flow ('service_first' by default,
'professional_first' as the alternative): it says which choice the public
wizard asks for first. Text plus CHECK, same reasoning as the appointment
status, so a third order is an edit to the CHECK instead of an ALTER TYPE.
Existing rows keep today's behaviour through the default.
The professional-first order needs two reads the public API did not have:
GET /{slug}/professionals the team, without price
GET /{slug}/professional/{id}/services what one person does, price closed
The team hides whoever is inactive or offers no active service, since that
card would lead to an empty list. Price stays out of the team payload on
purpose: it belongs to the service x professional pair, and the closed value
only exists on the screen after. The service list of a professional resolves
the override and drops price_varies, which has no meaning once the person is
known.
The SQL filter is covered against a real database in db_constraints, because
the controller tests mock the repositories and never run SQL.
The booking page draws a 14-day ribbon but only learns whether a day
has openings by clicking it, and asking day by day hits the public
rate limit.
GET /{slug}/professional/{id}/service/{id}/days/{from}?days=N answers
{"days":[{"date","has_slots"}]}, one item per day asked, in order.
Days beyond the company horizon or without working hours stay in the
list with has_slots false, so the page can draw them greyed out
instead of dropping them.
Working hours, time off and appointments are fetched once for the
whole range and the pure slot engine runs per day: 7 queries whatever
the range size, against 7 per day on the slots route. Reusing that
engine is what keeps a lit day from being refused by the day view.
GET /public/{slug}/services now returns, per service, the LOWEST price
resolved among active offering professionals (base price when nobody
offers) plus price_varies, so the frontend can render "a partir de".
The min/varies math lives in SQL so the list stays a single query.
GET /public/{slug}/service/{id}/professionals carries each professional
resolved price (override or base): the exact value appears when the
client picks who will serve them. GET /public/appointment/{token}
exposes the frozen snapshot. All additive for the frontend.
PUT /professional/{id}/services now takes two body shapes, resolved by
untagged serde: the new authoritative one, {"services": [{service_id,
price_cents?}]}, where a missing price clears the override; and the
legacy {"service_ids": [...]} the production frontend still sends, which
swaps the set while PRESERVING the override of the services that stay
(a client that does not know about prices must not be able to erase
them - done in a single DELETE...RETURNING + INSERT statement).
The same id with diverging prices is a contradiction and gets a 400;
an identical duplicated pair dedupes as before (bug 74). GET
/professional/{id}/services now carries override_price_cents next to
the base price, additive for the frontend.
Every client email (confirmation, confirm request, reminder, reschedule)
now prints the price from appointments.price_cents, the value agreed at
booking time, instead of reading services.price_cents at send time.
Editing the catalog or a professional override no longer rewrites what
old appointments display. A row without a snapshot (defensive) shows the
service name with no price. The reschedule notice receives the effective
price from the handler, since the new snapshot may have just been
re-resolved in the same PATCH.
Creating an appointment resolves the price of the (professional,
service) pair, override first and base as fallback, and stores it in
appointments.price_cents. The body never carries a price (serde(skip)):
the handler decides. Walk-ins without a service stay priceless.
On PATCH the snapshot is the price of the moment the pair was chosen:
rescheduling or changing status keeps it, swapping the professional or
the service re-resolves it, detaching the service clears it.
ServiceRepository::resolved_price does the COALESCE(override, base)
lookup. Mockall proves the negative paths: molds without the
expectation fail if a handler re-resolves when it should not.
A professional can now have his own price per service:
professional_services.price_cents, NULL = base price. Appointments
freeze the agreed price at creation in price_cents (NULL = walk-in
without service); existing rows are backfilled with the current base
price, which is the correct resolved price since no override existed
before this migration.
Schema only: the Rust side starts writing these columns in the next
commit, so .sqlx/ is intentionally untouched here.
New column services.icon (text NOT NULL DEFAULT ''), holding the
lucide.dev icon slug in kebab-case ("scissors", "sparkles"); empty
means no icon. The valid icon list lives in the frontend, which falls
back on unknown values, so the controller checks size only (max 40
chars).
Optional on create: NewService without "icon" stores ''. On PATCH,
"icon": "" clears the field (unlike name, where empty means keep).
Exposed in PublicService so GET /{slug}/services carries it.
All four client-facing emails (confirmed, confirm request, reminder,
rescheduled) now render the same inline-styled card: company logo on top
(name as fallback, which also covers remote-image blocking), proper
accents, an Onde line when the company has an address, and a
context-specific button. Professional notices stay plain text.
companies.logo (400px, POST /company/{id}/logo, shared avatar pipeline now
parameterized) and companies.address (free text up to 200 chars via PATCH,
empty clears). PublicCompany exposes both, and the address becomes the
"Onde:" line in the confirmation email and WhatsApp reply - the moment
the client needs to know where to go.
Clients gain an avatar in the professional's format: panel upload on
POST /client/{id}/avatar (same image pipeline, client- file prefix) and,
best-effort, the WhatsApp profile picture fetched on the first channel
confirmation - never overwriting an existing photo, silent when privacy
hides it or the channel is off.
On WhatsApp the sent message is the channel proof, so email only feeds
the reminder: accept bookings without it (client born with null email,
existing records untouched). Still required on the email channel, where
the address is the proof itself; format is validated whenever provided.
The confirm request now ships a multipart/alternative body: inline-styled
HTML card with a confirm button plus the existing plain text. User-supplied
fields are HTML-escaped; all other emails remain plain text.
- appointment PATCH (69): service_id used a plain Option with coalesce,
so null meant keep and there was no way to detach a service. It now
follows the standard merge pattern of the client e-mail (bug 53), via
a shared generic double_option: absent keeps, null detaches (the
appointment becomes a walk-in, skipping service validations), a value
swaps with the usual company/active/link checks. Verified end to end
with a real server: keep, detach and re-attach all observed in the
database.
- professional PATCH (70): the avatar column was writable by hand from
the body, letting any colleague point someone's avatar at an
arbitrary name (and the next upload deleted the file it named). The
field is now skip_deserializing; only the upload handler writes it.
- upload (94): when the UPDATE failed after the file was written, the
orphan stayed on disk until same-day overwrite; the error path now
removes it.
The 70 and 94 tests were verified against the reverted code (both fail
without their fixes); 69's SQL CASE is covered by the smoke.
- claim_due_reminders (81): FOR UPDATE SKIP LOCKED without OF also
locked the joined companies rows, so an UPDATE on a company could
make that tenant's rows be skipped by a concurrent sweep. Now locks
only the appointments rows. Never reproduced against a database, as
the ledger notes; the change is standard locking-clause semantics.
- public professionals listing (83): one get_professional_by_id per id
became a single get_by_ids (id = ANY). The company and active filters
stay in the handler on purpose, as a guard should the invariant of
professionals_offering ever drift.
- compute_slots (91): loaded the whole week and filtered the weekday in
Rust despite the (professional_id, weekday) index; the filter moved
into SQL via get_working_hours_for_weekday. The GET route keeps the
whole-week query it actually needs.
Existing fixtures now mock the new repository methods (watched failing
first), and both new queries were verified end to end with a real
server: the public listing returns the linked professional and
tomorrow's grid yields the expected 8 slots.
- company booking window (68): min_lead_minutes and max_horizon_days
only had sign CHECKs in the database; a horizon around 95 million
days overflowed now + horizon in available_slots and every slot
computation for that company panicked. Create and update now cap them
at one year of horizon and one week of lead.
- weekly grid (82): no ceiling on the number of windows before the
O(n^2) overlap loop, and everything was written; tens of thousands of
windows fit in the 2 MB body and burned CPU on the async worker. At
most 50 windows per week, checked before anything else.
- available_slots (88): overlapping windows (only reachable by editing
the table directly, the PUT refuses them) produced duplicate slots;
the sorted result is now deduped.
Covered by four tests watched failing first, including a valid 56-window
grid that passed every other check.
- set_professional_services (74): a repeated id in the list hit the
composite primary key and answered 500; the set is deduped first, a
duplicate is the same thing said twice.
- upload (93): only the first multipart field was read, so a form
sending any text field before the file answered 400 'Missing file
name'; text fields are now skipped until the file field, and a
multipart with no file at all answers 'a file field is required'.
- public booking (95): a lone '@' passed as e-mail and entered every
notification; the minimum is now a non-empty local part and a domain
with an inner dot. The client name, unbounded in the schema, is
capped at 100 chars.
- reminder sweep (87): a client without e-mail receives nothing, but
the counter still incremented and the 'lembretes enviados' log
overstated delivery; skipping now happens before the count, and the
row stays claimed like the walk-in case.
Covered by four new tests plus two cemented assertions flipped to the
honest behavior, all watched failing first.
Only start was compared against the clock, and only when present in the
body: a PATCH carrying just end could push the end of an ongoing
walk-in into the past (the old start is not in the body so its guard
never fires, and without a service there is no duration check to hold
the interval).
The end now gets the same guard as the start, and for the same reason
only when the field is in the body: completed and no_show are set after
the hour and must keep working.
Covered by a test that watched the past end reach the repository UPDATE
before the fix.
Every multipart failure was flattened into 500 by the From impl, and a
file that does not decode as an image also answered 'internal server
error': whoever sent a too-big photo or the wrong file had no way to
know the problem was on their side.
The From now leans on MultipartError::status(), which axum already
classifies: body over the limit is 413 (new PayloadTooLarge variant,
telling the caller exactly what to do), malformed multipart is 400, and
only the rest stays 500. A file that fails to decode answers 400 'the
file is not a valid image'.
The three upload tests that cemented the old behavior with a comment
now assert the honest statuses, and were watched failing first.
Nothing created upload_dir (default ./uploads), so on a fresh deploy
the first avatar upload answered 500 and ServeDir served nothing. The
boot now does create_dir_all right after reading the config, failing
loudly if it cannot: booting without it means booting with uploads
broken.
The handler deliberately still does not create directories (the
existing test cements that); verified end to end with a real server
booted on a nonexistent nested dir: it exists right after boot, the
first upload answers 200 and the avatar is served.
The panel could still route appointments to professionals who should
not receive them:
- create (89): company and permission of the target professional were
checked, but never target.active, so the panel booked into a
deactivated professional's calendar while the public route already
refuses. The same gap existed in update when the body reassigns the
professional; both now answer 400.
- update (98, the sibling of 58 through the other door): a PATCH
changing only professional_id never checked that the new professional
offers the appointment's current service. The service/professional
validation now covers the resulting pair: a service in the body is
validated for company, active and link (58), and a professional
change alone revalidates the link with the current service.
Covered by three tests that watched the invalid moves reach the
repository before the fix.
The unique index on professionals.email was case-sensitive and nothing
normalized the input: Alexandre@ could not log into the alexandre@
account, and creating a second account differing only in case worked.
Three layers, one identity: existing rows are lowercased and the unique
index becomes lower(email) (the migration fails loudly if case
duplicates already exist, they must be merged by hand); create and
update store the canonical form (trimmed, lowercased), with the
duplicate pre-check running on it; and the login lookup compares
lower() on both sides, so any casing signs in.
Covered by a db test (case-variant insert now violates the index) and
two handler tests, and verified end to end with a real server: login
with different casing answers 200, the stored e-mail is lowercase, and
the case-variant duplicate answers 400.
Four spots collapsed distinguishable failures into the wrong status:
- create_company (72): a taken slug fell into Err(_) => 500, discarding
the Conflict the classifier already produces; now 400 with the same
message as update_company.
- get_company_by_id (73): any failure answered 'company does not
exist', including the database being down; infra now answers 500 and
only NotFound is a 404.
- professional and client repositories (80): zero rows affected on
update/delete meant the row vanished between the check and the write,
but was reported as TechnicalError (500) while the handlers match
NotFound (404). Same contract appointment::update and service::update
already follow; the race itself is not reachable by the mock harness,
so this mapping rides on the handlers' existing NotFound tests.
- update_professional (96): moving a professional to a nonexistent
company hits the FK (StillReferenced) and answered 500; now the same
400 the create path already gives.
Covered by three tests that watched the wrong statuses first.
The claiming UPDATE had no LIMIT: one sweep marked the WHOLE due
backlog as sent before any e-mail went out, so a restart or SIGTERM in
the middle of the serial send loop lost every remaining reminder
forever (release_reminder only covers per-row preparation failures, not
a crash).
claim_due_reminders now takes at most REMINDER_CLAIM_BATCH (50) rows
per call, which caps what a crash between claiming and sending can
lose, and sweep drains batch by batch: a full batch claims again, a
short one means the queue is empty, so the common single-batch sweep
costs no extra round trip.
Covered by a test where a full batch forces a second claim, and by the
existing tests cementing that a short batch does not.
Two simultaneous public bookings of the same new phone raced on the
(phone, company_id) unique index and the loser answered 500 in the
middle of a perfectly good booking: find_or_create_client flattened the
Conflict into InternalServerError, and any lookup failure (including
infra) also fell into the creation branch.
Only NotFound opens the creation branch now; an infra error on the
lookup answers 500 without masking itself as a creation attempt. On
Conflict the winner just created our client, so the handler refetches
by phone and carries on with that id.
Covered by two tests: one that loses the race and still books, and one
that watched the infra error turn into a creation before the fix.
A PATCH carrying only service_id pinned a service from another company
(or an inactive one, or one the professional does not offer) to the
appointment: the ownership check lives in compute_slots, which only
runs when start is in the body, and the only brake left was the service
duration having to match the appointment's. That corrupted reporting
references and jammed the appointment, since a later PATCH with start
would die on compute_slots' 404.
The update handler now validates a service_id present in the body with
the same criteria and answers as compute_slots: wrong company or
inactive is 404 (not revealing the id exists elsewhere), no link with
the professional is 400.
Covered by three tests that watched the invalid reassignment reach the
repository UPDATE before the fix.
The public booking route validates and normalizes since bug 16; the
panel routes did not, so ordinary input blew up varchar columns with a
500 or silently wrote junk:
- client create/update (61): phone is now normalized to digits (shared
normalize_phone, moved to utils) and checked against varchar(11);
email checked against varchar(70). A formatted phone also used to
make the client unfindable by the public route's normalized lookup.
- professional create and update (62): same phone rule against
varchar(11); a formatted phone answered 'internal server error'.
- company update (64): empty name no longer reaches the coalesce (empty
means keep, the panel-wide PATCH convention); a taken slug answers
400 instead of 500, and an unknown id answers 404 (the repository now
reports zero rows as NotFound, same contract as delete).
- service create/update (67): name checked against varchar(80) in
chars, not bytes (accents are common in service names); empty name on
update means keep instead of silently blanking the public page.
Existing tests that cemented invalid phones in their payloads were
updated to valid ones. Covered by 12 new tests that watched the old
behavior first.
A leaked refresh token was worth 5 days and renewed itself forever if
used often enough, and changing the password cut nothing: the only
revocation was deactivating the account.
Two stateless cuts, no session table:
- professionals.password_changed_at (new column, bumped by the UPDATE
whenever a new password is written): the refresh handler and the
Claims extractor refuse any token whose issued_at predates it, so
changing the password kills every outstanding access and refresh
token. The extractor reads it from the same RETURNING that already
answers active/role/company, costing no extra round trip.
- login_at claim: minted at login, carried forward verbatim by every
refresh, and refused once the chain is older than 30 days. Renewing
no longer renews the cap, so a leaked refresh dies at most 30 days
after the original login even if the password never changes.
The migration's DEFAULT now() and the new required claim invalidate
every token issued before this deploy: everyone logs in once, the same
precedent as the purpose claim (bug 4).
Covered by four tests that watched the old behavior first, and
verified end to end with a real server: old pair answers 401 after a
password change, fresh login works, and the column bump is visible in
the database.
The handler's guards already ignored empty strings during validation
(the role filter, the email and password is_empty checks), but the value
still reached the UPDATE and coalesce wrote it: an empty password
bricked the account while answering ok (login dies at
PasswordHash::new("")), an empty email erased the login email, and an
empty role died at the database CHECK as a 500.
UpdateProfessional::treat_empty_as_absent now normalizes every text
field (name, email, phone, role, password) to None at the top of the
handler, in one place. Avatar stays out: there '' is a legitimate value
meaning no photo (bug 65). The now-redundant guards were simplified to
lean on the invariant that Some is never empty.
Covered by four tests (one per bug plus name/phone) that watched the
mock receive Some("") before the fix.