295 Commits
Author SHA1 Message Date
Alexandre Possebom 1df9e81ec1 feat(calendar): publish a professional's agenda as a feed
Build and Push Docker Image / build (push) Successful in 4m33s
Continuous integration / Check (push) Successful in 1m40s
Continuous integration / Test Suite (push) Successful in 2m34s
Continuous integration / Rustfmt (push) Successful in 27s
Continuous integration / Clippy (push) Successful in 1m35s
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.
2026-07-30 09:21:10 -03:00
Alexandre Possebom 48d158c156 feat(public): let a company open the wizard by professional
Build and Push Docker Image / build (push) Successful in 5m20s
Continuous integration / Check (push) Successful in 1m43s
Continuous integration / Test Suite (push) Successful in 2m28s
Continuous integration / Rustfmt (push) Successful in 25s
Continuous integration / Clippy (push) Successful in 1m29s
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.
2026-07-30 07:37:19 -03:00
Alexandre Possebom 35866f6aaf feat(public): list which days have free slots
Build and Push Docker Image / build (push) Successful in 4m1s
Continuous integration / Check (push) Successful in 1m33s
Continuous integration / Test Suite (push) Successful in 2m24s
Continuous integration / Rustfmt (push) Successful in 23s
Continuous integration / Clippy (push) Successful in 1m34s
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.
2026-07-29 16:15:52 -03:00
Alexandre Possebom c96b6dba73 feat(pricing): public pages show resolved per-professional prices
Build and Push Docker Image / build (push) Successful in 4m2s
Continuous integration / Check (push) Successful in 1m33s
Continuous integration / Test Suite (push) Successful in 2m24s
Continuous integration / Rustfmt (push) Successful in 28s
Continuous integration / Clippy (push) Successful in 1m45s
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.
2026-07-29 15:54:44 -03:00
Alexandre Possebom ae67b101ae feat(pricing): panel writes and reads the per-professional price
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.
2026-07-29 15:50:13 -03:00
Alexandre Possebom c060f9816f feat(mail): emails show the frozen price, never the catalog
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.
2026-07-29 15:46:08 -03:00
Alexandre Possebom a05453dc79 feat(pricing): freeze the resolved price on the appointment
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.
2026-07-29 15:43:15 -03:00
Alexandre Possebom 1d89bd75cd feat(pricing): override and snapshot price columns
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.
2026-07-29 15:34:31 -03:00
Alexandre Possebom e28a795683 feat(service): lucide icon slug per service
Build and Push Docker Image / build (push) Successful in 6m4s
Continuous integration / Check (push) Successful in 1m31s
Continuous integration / Test Suite (push) Successful in 2m23s
Continuous integration / Rustfmt (push) Successful in 21s
Continuous integration / Clippy (push) Successful in 1m30s
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.
2026-07-29 14:40:35 -03:00
Alexandre Possebom 797d2f7410 feat(mail): client emails share an HTML card with logo, address and button
Build and Push Docker Image / build (push) Successful in 3m59s
Continuous integration / Check (push) Successful in 1m31s
Continuous integration / Test Suite (push) Successful in 2m20s
Continuous integration / Rustfmt (push) Successful in 31s
Continuous integration / Clippy (push) Successful in 1m37s
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.
2026-07-29 13:40:40 -03:00
Alexandre Possebom 16b4371fe5 feat(company): logo and address for the public page and confirmations
Build and Push Docker Image / build (push) Successful in 5m2s
Continuous integration / Check (push) Successful in 1m34s
Continuous integration / Test Suite (push) Successful in 2m21s
Continuous integration / Rustfmt (push) Successful in 26s
Continuous integration / Clippy (push) Successful in 1m28s
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.
2026-07-29 13:28:36 -03:00
Alexandre Possebom 1e6b993571 feat(clients): avatar via panel upload or WhatsApp profile picture
Build and Push Docker Image / build (push) Successful in 4m4s
Continuous integration / Check (push) Successful in 1m42s
Continuous integration / Test Suite (push) Successful in 2m27s
Continuous integration / Rustfmt (push) Successful in 24s
Continuous integration / Clippy (push) Successful in 1m40s
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.
2026-07-29 11:55:35 -03:00
Alexandre Possebom a6c8655727 feat(booking): email optional on the whatsapp channel
Build and Push Docker Image / build (push) Successful in 4m3s
Continuous integration / Check (push) Successful in 1m34s
Continuous integration / Test Suite (push) Successful in 2m22s
Continuous integration / Rustfmt (push) Successful in 23s
Continuous integration / Clippy (push) Successful in 1m34s
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.
2026-07-29 11:30:00 -03:00
Alexandre Possebom e91ed1f49d feat(mail): HTML confirm email with button
Build and Push Docker Image / build (push) Successful in 4m6s
Continuous integration / Check (push) Successful in 2m1s
Continuous integration / Test Suite (push) Successful in 3m20s
Continuous integration / Rustfmt (push) Successful in 31s
Continuous integration / Clippy (push) Successful in 1m37s
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.
2026-07-29 11:22:33 -03:00
Alexandre Possebom 93f881788f feat(booking): short access codes replace visible magic link JWTs (ADR 0005)
Build and Push Docker Image / build (push) Successful in 4m25s
Continuous integration / Check (push) Successful in 1m26s
Continuous integration / Test Suite (push) Successful in 2m19s
Continuous integration / Rustfmt (push) Successful in 25s
Continuous integration / Clippy (push) Successful in 1m30s
2026-07-29 11:08:47 -03:00
Alexandre Possebom e2e6b84282 docs(config): WHATSAPP_NUMBER is the dialable number, not the session JID
Build and Push Docker Image / build (push) Successful in 4m28s
Continuous integration / Check (push) Successful in 1m31s
Continuous integration / Test Suite (push) Successful in 2m19s
Continuous integration / Rustfmt (push) Successful in 25s
Continuous integration / Clippy (push) Successful in 1m43s
2026-07-29 10:40:04 -03:00
Alexandre Possebom 989e50c613 feat(booking): pending appointments confirm by channel ownership (ADR 0004)
Build and Push Docker Image / build (push) Successful in 4m44s
Continuous integration / Check (push) Successful in 1m31s
Continuous integration / Test Suite (push) Successful in 2m23s
Continuous integration / Rustfmt (push) Successful in 24s
Continuous integration / Clippy (push) Successful in 1m34s
2026-07-29 10:28:46 -03:00
Alexandre Possebom 9f49d8cd02 docs: refresh the test counts in CLAUDE.md
Build and Push Docker Image / build (push) Successful in 4m9s
Continuous integration / Check (push) Successful in 1m25s
Continuous integration / Test Suite (push) Successful in 2m14s
Continuous integration / Rustfmt (push) Successful in 26s
Continuous integration / Clippy (push) Successful in 1m30s
2026-07-29 09:00:26 -03:00
Alexandre Possebom 7c65105557 docs: close bugs 69, 70 and 94 in the ledger 2026-07-29 09:00:01 -03:00
Alexandre Possebom 0f47c521f9 fix(api): service detach, avatar lockdown, orphan cleanup (bugs 69, 70, 94)
- 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.
2026-07-29 08:59:26 -03:00
Alexandre Possebom eacafd16f6 docs: close bugs 81, 83 and 91 in the ledger 2026-07-29 08:50:34 -03:00
Alexandre Possebom 6dc68b25f8 fix(sql): batch lookups and precise locking, closing bugs 81, 83, 91
- 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.
2026-07-29 08:49:58 -03:00
Alexandre Possebom 06dab1badf docs: close bugs 68, 82 and 88 in the ledger 2026-07-29 08:43:35 -03:00
Alexandre Possebom cd1cabf5ed fix(availability): sanity caps and slot dedup, closing bugs 68, 82, 88
- 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.
2026-07-29 08:43:03 -03:00
Alexandre Possebom 4b299cbdb1 docs: close bugs 74, 87, 93 and 95 in the ledger 2026-07-29 08:37:47 -03:00
Alexandre Possebom 865e47dbaf fix(api): four data-quality gaps, closing bugs 74, 87, 93, 95
- 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.
2026-07-29 08:37:12 -03:00
Alexandre Possebom a37e3fd137 docs: close bug 85 in the ledger 2026-07-29 08:33:01 -03:00
Alexandre Possebom 6acf557ec8 fix(appointment): the end cannot move into the past, closing bug 85
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.
2026-07-29 08:32:45 -03:00
Alexandre Possebom 2d0acc4a08 docs: close bug 56 in the ledger 2026-07-29 08:30:33 -03:00
Alexandre Possebom 3facbfb119 fix(upload): honest statuses for caller errors, closing bug 56
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.
2026-07-29 08:30:14 -03:00
Alexandre Possebom 09ee431837 docs: close bug 86 in the ledger 2026-07-29 08:26:04 -03:00
Alexandre Possebom 3292187f2a fix(boot): create the upload dir, closing bug 86
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.
2026-07-29 08:25:43 -03:00
Alexandre Possebom d194c2c3c8 docs: close bugs 89 and 98 in the ledger 2026-07-29 08:20:23 -03:00
Alexandre Possebom f9acbff64d fix(appointment): reject deactivated or unlinked targets, closing bugs 89, 98
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.
2026-07-29 08:20:01 -03:00
Alexandre Possebom ef88a86d67 docs: close bug 90 in the ledger 2026-07-29 08:16:04 -03:00
Alexandre Possebom 6d6f41349f fix(auth): case-insensitive login email, closing bug 90
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.
2026-07-29 08:15:45 -03:00
Alexandre Possebom 238a028c42 docs: close bugs 72, 73, 80 and 96 in the ledger 2026-07-29 08:11:03 -03:00
Alexandre Possebom 7ca1fcd22c fix(api): honest statuses for common failures, closing bugs 72, 73, 80, 96
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.
2026-07-29 08:10:24 -03:00
Alexandre Possebom 1271249c74 docs: close bug 71 in the ledger 2026-07-29 08:07:00 -03:00
Alexandre Possebom f87955e385 fix(reminders): claim in bounded batches, closing bug 71
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.
2026-07-29 08:06:33 -03:00
Alexandre Possebom 8a32f0ebff docs: close bug 78 in the ledger 2026-07-29 08:00:55 -03:00
Alexandre Possebom 5029d5e2af fix(public): survive the client creation race, closing bug 78
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.
2026-07-29 08:00:36 -03:00
Alexandre Possebom ce3228441b docs: close bug 58 and open its sibling 98 in the ledger 2026-07-29 07:55:58 -03:00
Alexandre Possebom 430186fe05 fix(appointment): validate a reassigned service, closing bug 58
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.
2026-07-29 07:55:03 -03:00
Alexandre Possebom b37b5fdf49 docs: close bugs 61, 62, 64 and 67 in the ledger 2026-07-29 07:50:37 -03:00
Alexandre Possebom 043894114c fix(api): validate panel input against the schema, closing bugs 61, 62, 64, 67
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.
2026-07-29 07:49:59 -03:00
Alexandre Possebom 90cdf5eb66 docs: close bug 66 in the ledger 2026-07-29 07:40:12 -03:00
Alexandre Possebom aaba5757dd fix(auth): revoke tokens on password change, cap refresh chains, closing bug 66
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.
2026-07-29 07:39:54 -03:00
Alexandre Possebom 4ab0d72760 docs: close bugs 59, 60 and 79 in the ledger 2026-07-29 07:26:58 -03:00
Alexandre Possebom 94af968c32 fix(professional): empty PATCH strings mean keep, closing bugs 59, 60, 79
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.
2026-07-29 07:26:30 -03:00