100 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
Alexandre Possebom a8607e8c86 docs: close bug 65 in the ledger 2026-07-29 07:20:59 -03:00
Alexandre Possebom 8de22c7f22 fix(professional): empty avatar default, closing bug 65
Every new professional was born pointing at a Vite SOURCE path
(/src/assets/images/avatars/avatar-1.png) that no route serves, so the
public booking page showed a broken image until a photo was uploaded.
The path lived in three places: the INSERT coalesce, the column DEFAULT
from the 2023 migration, and the rows created since then (seed
included).

The INSERT now stores whatever the model carries (serde defaults it to
empty, meaning no photo; real photos point at /api/v1/avatars, bug 17),
and a migration swaps the column DEFAULT and backfills the stuck rows.
database.dbml updated in the same change.

Covered by two db_constraints tests that reproduced both halves, and
verified end to end with a real server: POST /professional without
avatar stores '' and the seeded row is backfilled.
2026-07-29 07:20:38 -03:00
Alexandre Possebom 92e7ab4c1e docs: close bug 63 in the ledger 2026-07-29 07:15:01 -03:00
Alexandre Possebom 50aff87d35 fix(upload): cap decoded image dimensions, closing bug 63
The 2 MB body limit does not bound what the image DECLARES: a tiny PNG
header can promise dimensions that decode to hundreds of MB while still
passing the image crate's default limits (512 MiB allocation cap, no
width/height cap), so concurrent uploads could OOM the whole process.

Decoding now goes through ImageReader with explicit Limits capping both
sides at 4096, which for PNG is enforced at decoder construction: the
refusal happens while reading the header, before any allocation. A
limits violation answers 400 with a clear message; every other decode
error keeps today's behavior (bug 56, still open).

Covered by a unit test posting a ~60 byte PNG header declaring
10000x10000, which reproduced the old behavior before the fix.
2026-07-29 07:14:41 -03:00
Alexandre Possebom 3c874e591b docs: close bug 57 in the ledger 2026-07-29 07:04:30 -03:00
Alexandre Possebom f70919f228 fix(availability): checked time math, closing bug 57
A parseable extreme date (+262142-12-31, NaiveDate::MAX) reaching the
public get_slots route panicked in two places: the DST probe loop in
to_utc (probe += 1min walking past NaiveDateTime::MAX) and the slot
scan step in available_slots (start + duration crossing the end of
representable time). Both additions are now checked: the probe returns
None and the scan stops, so the window is skipped instead of killing
the connection.

Covered by two unit tests that reproduced both panics before the fix.
2026-07-29 07:03:08 -03:00
Alexandre Possebom 56f9aca30d docs: log the second bug-hunt pass in the ledger
Second adversarial review pass (hunter/skeptic/referee) on 2026-07-29,
with the two panics reproduced against the repo's pinned chrono.

- 40 confirmed, opened as 57-96 (11 medium, 29 low)
- 1 discarded as 97 (get_timestamp_from_now: the unwrap and u8 are not
  defects; the real bug there was already closed as 22)
- no criticals survived; scoreboard updated to 41 open

Referenced by file and function per the ledger convention, with ties to
existing entries noted (58->bug 2, 65->bug 17, 69->bug 53, 92->bug 19).
2026-07-29 06:44:24 -03:00
Alexandre Possebom c87b68ea1f docs: close bugs 18, 30, 31 and 55 and open 56 in the ledger
Build and Push Docker Image / build (push) Successful in 4m16s
Continuous integration / Check (push) Successful in 1m32s
Continuous integration / Test Suite (push) Successful in 2m18s
Continuous integration / Rustfmt (push) Successful in 30s
Continuous integration / Clippy (push) Successful in 1m35s
2026-07-28 23:07:32 -03:00
Alexandre Possebom b95e674d48 fix(api): rewrite the avatar upload in memory, closing bugs 18, 30, 31 and 55
Build and Push Docker Image / build (push) Successful in 4m26s
Continuous integration / Check (push) Successful in 1m31s
Continuous integration / Test Suite (push) Successful in 2m11s
Continuous integration / Rustfmt (push) Successful in 24s
Continuous integration / Clippy (push) Successful in 1m34s
2026-07-28 23:06:02 -03:00
Alexandre Possebom 2041185ed6 chore: move the merged upload tests to the sibling file layout
Build and Push Docker Image / build (push) Successful in 4m23s
Continuous integration / Check (push) Successful in 1m31s
Continuous integration / Test Suite (push) Successful in 2m56s
Continuous integration / Rustfmt (push) Successful in 1m6s
Continuous integration / Clippy (push) Successful in 2m22s
2026-07-28 22:55:01 -03:00
Alexandre Possebom fa87a1779a Merge branch 'worktree-upload-tests': unit tests for the avatar upload 2026-07-28 22:32:29 -03:00
Alexandre Possebom 754c13fcd9 chore: move the giant inline test blocks to sibling files
Build and Push Docker Image / build (push) Successful in 4m39s
Continuous integration / Check (push) Successful in 1m38s
Continuous integration / Test Suite (push) Successful in 2m19s
Continuous integration / Rustfmt (push) Failing after 22s
Continuous integration / Clippy (push) Successful in 1m35s
2026-07-28 22:32:06 -03:00
Alexandre Possebom df24ae5e17 test(api): cover the avatar upload handler, from authorization to disk
controllers/upload.rs nao tinha teste nenhum: 0% de linha, e quatro bugs abertos morando la (18, 30, 31 e 55). Agora sao 15 testes ativos, do 403 do colega ao PNG que sobra no disco, e 85% de linha no arquivo.

Os nok provam a ausencia de escrita pelo mock sem expectativa de update_professional. O caminho feliz confere as tres promessas de uma vez: o que a resposta diz, o UpdateProfessional por igualdade exata com o Default, e o arquivo de 200x150 sendo o unico do diretorio com o temporario ja apagado.

Tres testes ficam #[ignore] apontando para os bugs 18, 30 e 55: eles viram verdes junto com a correcao.

Um achado novo: corpo acima do limite responde 500, nao 413, porque o From<MultipartError> mapeia todo erro de multipart para InternalServerError. O teste crava o 500 de hoje com o comentario dizendo que 413 seria o certo; corrigir mexe em todo handler multipart.

Nada fora de #[cfg(test)] foi tocado. Detalhes, linhas descobertas e justificativas em .claude/upload-coverage-goal.md.
2026-07-28 22:32:01 -03:00
Alexandre Possebom 90fb826266 perf(api): pass the company into compute_slots instead of refetching it
Build and Push Docker Image / build (push) Successful in 4m18s
Continuous integration / Check (push) Successful in 1m34s
Continuous integration / Test Suite (push) Successful in 2m14s
Continuous integration / Rustfmt (push) Successful in 25s
Continuous integration / Clippy (push) Successful in 1m36s
2026-07-28 22:27:58 -03:00
Alexandre Possebom 06e377daf0 refactor(api): make the appointment status an enum like Role
Build and Push Docker Image / build (push) Successful in 4m19s
Continuous integration / Check (push) Successful in 1m25s
Continuous integration / Test Suite (push) Successful in 2m13s
Continuous integration / Rustfmt (push) Successful in 25s
Continuous integration / Clippy (push) Successful in 1m35s
2026-07-28 22:20:45 -03:00
Alexandre Possebom 941f01d03c refactor(api): finish the DataAccessError migration in the three anyhow repositories
Build and Push Docker Image / build (push) Successful in 4m40s
Continuous integration / Check (push) Successful in 1m28s
Continuous integration / Test Suite (push) Successful in 2m13s
Continuous integration / Rustfmt (push) Successful in 25s
Continuous integration / Clippy (push) Successful in 1m31s
2026-07-28 22:15:06 -03:00
Alexandre Possebom dc44c706cf docs: add reusable coverage goal prompt
Ready-to-paste /goal for raising unit coverage: fixed commands, five
targets in value order, tests-only guardrails, and a stop condition the
goal evaluator can check from the transcript.
2026-07-28 22:14:13 -03:00
Alexandre Possebom 60ea9b33e3 fix(api): stop reporting infrastructure failures as 404
Build and Push Docker Image / build (push) Successful in 4m11s
Continuous integration / Check (push) Successful in 2m47s
Continuous integration / Test Suite (push) Successful in 2m15s
Continuous integration / Rustfmt (push) Successful in 25s
Continuous integration / Clippy (push) Successful in 1m30s
2026-07-28 22:06:04 -03:00
Alexandre Possebom 98f4d49ad4 docs: close bug 17 and update CLAUDE.md to match reality 2026-07-28 21:53:09 -03:00
Alexandre Possebom f7d857a213 test: cover the public booking flow end to end and the service permissions
Build and Push Docker Image / build (push) Successful in 7m23s
Continuous integration / Check (push) Successful in 3m40s
Continuous integration / Test Suite (push) Successful in 4m1s
Continuous integration / Rustfmt (push) Successful in 30s
Continuous integration / Clippy (push) Successful in 1m36s
2026-07-28 21:39:11 -03:00
Alexandre Possebom 412108c12e fix(build): commit Cargo.lock for the ServeDir dependency
Build and Push Docker Image / build (push) Successful in 6m2s
Continuous integration / Check (push) Successful in 2m9s
Continuous integration / Test Suite (push) Successful in 3m17s
Continuous integration / Rustfmt (push) Successful in 41s
Continuous integration / Clippy (push) Successful in 1m53s
2026-07-28 21:03:44 -03:00
Alexandre Possebom e58628bb7e feat(api): serve avatars and store a real avatar URL
Build and Push Docker Image / build (push) Failing after 1m5s
Continuous integration / Check (push) Successful in 1m31s
Continuous integration / Test Suite (push) Successful in 2m14s
Continuous integration / Rustfmt (push) Successful in 24s
Continuous integration / Clippy (push) Successful in 1m34s
The upload endpoint resized and saved the image but recorded
/src/assets/images/avatars/..., a path of the retired frontend that
resolves nowhere. Serve the upload dir at /api/v1/avatars via ServeDir
(works in dev through the vite proxy and in production untouched by
nginx) and record that URL on the professional.
2026-07-28 20:17:17 -03:00
Alexandre Possebom ff66e3e194 docs: close bug 20 in the ledger
Build and Push Docker Image / build (push) Successful in 4m9s
Continuous integration / Check (push) Successful in 1m27s
Continuous integration / Test Suite (push) Successful in 2m8s
Continuous integration / Rustfmt (push) Successful in 26s
Continuous integration / Clippy (push) Successful in 1m33s
2026-07-28 20:10:04 -03:00
Alexandre Possebom 0096866f0f fix(api)!: replace the seeded password with ADMIN_PASSWORD on boot
Every install was born with the same super-admin and a password hash
versioned in migration 001, a publicly known credential. On boot, any
professional still carrying that exact hash gets it replaced with the
hash of ADMIN_PASSWORD; with the public hash active and no
ADMIN_PASSWORD set the process refuses to start, same philosophy as
JWT_SECRET. After the first boot the env var is inert: the panel owns
the password.
2026-07-28 20:09:29 -03:00
Alexandre Possebom c9b2ab11f7 docs: close bug 40 in the ledger 2026-07-28 20:02:05 -03:00
Alexandre Possebom c8197eeea6 fix(api): serve /metrics only to local addresses
Metrics expose per-route volume and latency on the same listener that
faces the internet. The handler now resolves the client IP (first
X-Forwarded-For entry behind the proxy, peer otherwise, same premise
as the rate limit) and answers 404 outside loopback and private
ranges.
2026-07-28 20:01:40 -03:00
Alexandre Possebom a786c49572 feat: dockerize and auto-deploy on push
Build and Push Docker Image / build (push) Successful in 4m33s
Continuous integration / Check (push) Successful in 1m33s
Continuous integration / Test Suite (push) Successful in 2m24s
Continuous integration / Rustfmt (push) Successful in 27s
Continuous integration / Clippy (push) Successful in 1m30s
Multi-stage image built offline with the sqlx cache (migrations are
already embedded in the binary) on a slim runtime, and a Gitea
Actions workflow that pushes to the registry and triggers the shared
vemmarcar deploy webhook, mirroring the up-track pipeline.
2026-07-28 19:40:12 -03:00
Alexandre Possebom 7df13e4104 fix(api): key rate limits by the forwarded client IP
Behind traefik every request reaches the app with the proxy peer IP,
so both governors (public and auth) throttled globally. The
SmartIpKeyExtractor reads X-Forwarded-For/X-Real-IP and falls back to
the peer address, keeping dev behavior unchanged; traefik discards
forwarded headers from untrusted sources by default, so clients
cannot pick their own key.
2026-07-28 19:40:10 -03:00
Alexandre Possebom 0aa439f06c ci: reach the postgres service over the job network, not the host
Publishing 5432 on the runner host collided with the postgres already
living there, killing every Test Suite run at container start. The job
runs in a container on the same docker network as the service, so it
reaches it by the service hostname with no port published.
2026-07-28 19:36:21 -03:00
Alexandre Possebom 05c7b25433 docs: close bug 53 in the ledger 2026-07-28 19:33:17 -03:00
Alexandre Possebom ae95c2c455 fix(api)!: let a null email clear the client field (merge patch)
An explicit null in the client PATCH now clears the email (RFC 7386):
absent keeps, null clears, a value replaces. With a plain Option the
two first cases were indistinguishable and a wrongly saved email could
never be removed.
2026-07-28 19:33:00 -03:00
Alexandre Possebom 4c52220e89 docs: close bugs 42 and 44 in the ledger 2026-07-28 19:26:11 -03:00
Alexandre Possebom 958208c458 fix(api): reschedule notice, emails in background
Rescheduling now signs a fresh magic link (the old one's expiry froze
at the old end, so moving an appointment later voided the client's
link) and emails the client the new time (bug 42).

All notification emails now go through a spawned task instead of being
awaited inside the request, so a slow relay no longer delays the public
booking response; failures land in the log (bug 44).
2026-07-28 19:25:48 -03:00
Alexandre Possebom 601ed20a26 docs: run the dev mailpit on apple container
Full path on purpose: non-interactive shells lack /usr/local/bin in
PATH, and the service must be up via container system start.
2026-07-28 19:25:00 -03:00
Alexandre Possebom 72fb2ff205 docs: close the migration trio in the ledger 2026-07-28 19:15:53 -03:00
Alexandre Possebom d04b422de5 fix(db): make the slug backfill survive any name
Symbol-only names produced an empty slug that passed NOT NULL and broke
the format check later; names normalizing to the same slug broke the
unique index; long names overflowed varchar(60). Backfill now truncates
at 52, falls back to empresa-<id> and dedupes with the id suffix.

Editing an applied migration changes its checksum: already-migrated
databases must update _sqlx_migrations for version 20260728120200 (or
be recreated) before the next boot.
2026-07-28 19:15:34 -03:00
Alexandre Possebom fce8f7042a fix(db): enforce start < end on appointments 2026-07-28 19:15:28 -03:00
Alexandre Possebom 0df7a50bcf fix(db): add the missing appointments company FK 2026-07-28 19:15:25 -03:00
Alexandre Possebom 2c882203c0 docs: close bug 12 in the ledger 2026-07-28 19:05:02 -03:00
Alexandre Possebom 8c42252616 fix(api): refuse an appointment end that ignores the service duration 2026-07-28 19:04:38 -03:00
Alexandre Possebom fd1cef4ef9 docs: record the second bug queue in the ledger
Continuous integration / Check (push) Successful in 1m32s
Continuous integration / Test Suite (push) Failing after 1s
Continuous integration / Rustfmt (push) Successful in 27s
Continuous integration / Clippy (push) Successful in 1m30s
2026-07-28 18:55:58 -03:00
Alexandre Possebom 91d0b91c85 fix(api): validate the grid when rescheduling an appointment with a service 2026-07-28 18:55:12 -03:00
Alexandre Possebom d840208600 fix(api): keep the valid part of a window that touches the DST gap 2026-07-28 18:51:41 -03:00
Alexandre Possebom a5e69f043a fix(api): release a claimed reminder when preparing it fails 2026-07-28 18:50:02 -03:00
Alexandre Possebom 45a9c34cd1 docs: rewrite request.http and database.dbml against reality 2026-07-28 18:47:31 -03:00
Alexandre Possebom 3188de76c5 fix(dev): make clear_db.sh actual bash and drop the inline password 2026-07-28 18:45:42 -03:00
Alexandre Possebom a7284e3af0 docs: add the bug ledger and refresh guardrails
Continuous integration / Check (push) Successful in 1m32s
Continuous integration / Test Suite (push) Failing after 1s
Continuous integration / Rustfmt (push) Successful in 28s
Continuous integration / Clippy (push) Successful in 1m29s
BUGS.md records the 55 findings from the adversarial review with
severity, status and the commit that closed each one (34 closed,
19 open, none critical).

CLAUDE.md links it, updates the test counts, documents the
mock-without-expectation pattern, and rewrites the stale traps:
fmt/clippy are clean now, error causes are logged via
DataAccessError::from_sqlx, and conflict checks go through
get_busy_between instead of loading the whole history.
2026-07-28 16:44:38 -03:00
Alexandre Possebom 096e47fc1d fix(api): parse SMTP_URL with percent-encoding, bare user and IPv6 hosts 2026-07-28 16:34:40 -03:00
Alexandre Possebom aabba2c767 fix(api): initialize tracing before the pool and migrations 2026-07-28 16:33:05 -03:00
Alexandre Possebom 970d809c49 fix(api): drop the CORS layer that wrapped an empty router 2026-07-28 16:29:28 -03:00