Every email went through one shared plain-text-only path. Redesigned
send_email/send_test_email around structured paragraphs + an optional
CTA button instead of one pre-formatted string, and render both a
proper styled HTML card (table-based, inline styles -- email clients
strip <style> blocks and don't support CSS variables) and a clean
plain-text fallback from the same input, sent as multipart/alternative.
The HTML is themed per recipient: an email to an existing user renders
in their own selected theme (dark/light/midnight/sunset, or their saved
custom palette), resolved server-side from User.theme/
active_custom_theme_id. Site invites have no account yet to read a
theme from, so they use the default DarkSingularity palette. All five
existing email triggers (site invite, room-added, password reset,
#66's DM notification, admin test email) updated to the new call shape.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
delete_room() only ever cleaned up Message and RoomMembership rows,
but none of the FK constraints referencing a room (or its messages)
are declared ON DELETE CASCADE at the DB level -- confirmed across
every migration that added one. Any room that ever had an image/file
attachment, an incoming webhook, an outgoing event subscription, or
was ever #referenced from a message in a *different* room (the one
that originally surfaced this as a message_room_references FK
violation in production) couldn't be deleted at all.
Now explicitly cleans up, in dependency order: message mentions,
reactions, and room-references (both the message-id and room-id
directions), the messages themselves, then room-scoped images/files
(including unlinking the actual stored files after a successful
commit, not just their DB rows) and incoming webhooks/event
subscriptions, before removing memberships and the room.
Added a test reproducing the full scenario -- attachments,
integrations, and a cross-room reference all on one room -- that
would have 500'd before this fix, plus a sanity check that deleting
the room doesn't touch the unrelated room that referenced it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
is_private was previously only settable at room creation. RoomUpdate now
accepts it, update_room() applies it, and PATCH /api/rooms/{id} allows a
site admin to make the change even for a room they haven't joined (in
addition to the existing room owner/admin gate) -- require_room_role
normally 403s a non-member before the role check ever runs, so this is a
deliberate bypass for site admins specifically.
Flipping the flag has no effect on existing members either direction
(confirmed is_private is only ever checked at self-serve join time) --
it purely controls Browse Rooms visibility and future self-joins.
Frontend: RoomInfoPanel's "Room settings" section is now visible to room
owner, room admin, or site admin (was owner-only), with a privacy toggle
reusing NewRoomModal's existing toggle-switch UI. "Delete room" stays
owner-only, now nested inside that wider section rather than gating the
whole thing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two independent gaps, both fixed since the report was ambiguous about
which "chats" meant:
- ChatPane.tsx concatenated history (REST-fetched) and live (WS-pushed)
without sorting, so anything that could desync receipt order from
send order -- a rejoin/resync racing a still-in-flight WS message,
which opening the same room on another device triggers directly via
a fresh socket connection -- could render messages out of
chronological order. Now sorted by created_at (stable sort, so
same-timestamp messages keep their relative order).
- list_member_rooms/list_open_rooms/list_recent_messages ordered by
created_at alone, with no secondary tiebreaker. Postgres doesn't
guarantee a stable order for tied rows across separate query
executions, so two rooms/messages sharing an identical timestamp
(a real possibility -- rapid sends, bulk-created rooms) could come
back in a different order on two separate fetches, i.e. two devices.
Added id as a secondary sort key everywhere this showed up.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Room info panel is now user-resizable (fixing a layout clip at narrow
widths), and every user-selection spot (room membership, admin ownership
transfer) uses a new searchable UserPicker instead of raw text input or
prompt(). Member rows fold role + actions into a single inline dropdown
instead of a row of buttons, so the member list stays usable as rooms grow.
Room invites (the accept/decline flow) are replaced by adding a user to a
room directly -- an admin/owner picks someone and they're a member
immediately, with a "you've been added" notification email instead of an
invite email. Drops the now-unused room_invites table.
Adds room_invites table + migration, owner/admin/member role enforcement
(require_room_role), and endpoints for private room creation, room
management (update/delete/leave/transfer-ownership/change-role/remove-member),
and the invite lifecycle (create/list/accept/decline/revoke). Registration
stays invite-only via the CLI from Phase 1 — this is a separate, room-level
invite system for adding existing users to private rooms.
Frontend is untouched: the UI redesign is happening separately, so this phase
is backend + tests only (35 passing). Verified no regressions in the Phase 1
open-room/WebSocket flow via manual smoke test.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Invite-only FastAPI + SQLAlchemy(async) + Postgres backend (session-cookie
auth via CLI-provisioned accounts, open-room CRUD, single-instance /ws/chat)
and a React + Vite PWA frontend (login, room list, chat view). Backend tests
pass against a local Postgres DB. See README.md and backend/README.md for
setup, and ARCHITECTURE.md for the full phased design.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>