mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-11 10:56:45 +08:00
* fix(server): make invitation accept/decline idempotent once concluded Installed clients can hold a stale pending-invitation row for an invitation that was concluded from another surface (web invite page, another device). Both buttons then hit 400 "invitation is not pending", which clients swallow silently, so the row survives until restart. - AcceptInvitation: re-accepting an already-accepted invitation is idempotent while the membership from the first accept still exists — return the existing member (200). Only pgx.ErrNoRows means the membership is gone (leaving the workspace was explicit and a stale retry must not undo it); any other lookup failure surfaces as 500 instead of falling through to the 400 clients swallow, matching the JoinByShareLink pre-check. - DeclineInvitation: declining a concluded invitation is a no-op (204) — the invitation is over either way, and the client only needs its refetch to drop the row. This heals already-shipped clients with no client update. * fix(server): deliver invitation conclusions to the invitee directly The invitation:accepted / invitation:declined broadcasts only reach clients whose socket is bound to the target workspace room. An invitee usually isn't in that room yet — the client binds to the workspace it currently has open, and a user with no workspace open has no room at all — so their pending list never hears the conclusion and the stale row survives until restart (#8432). Keep the workspace broadcast (admin pending lists depend on it) and additionally SendToUser the acting invitee with excludeWorkspace so clients already in the room don't receive the event twice, mirroring the member:added pattern. The actor is the invitee on every producer path for both events; invitation:revoked keeps its invitee_user_id routing because its actor is the revoking admin. * fix(server): settle pending email invitation on share-link join A user invited by email who joins through the share link instead leaves that invitation row pending forever: it keeps its seat reservation, keeps showing in their pending list, and a later Accept on it dies on the CreateMember 409 — the second variant of #8432. Settle the matching pending invitation inside the join transaction. GetPendingInvitationByEmail plus the idx_invitation_unique_pending partial index guarantee at most one pending row per (workspace, email); AcceptInvitation re-checks status='pending' under the row lock, so a concurrent decline just leaves nothing to settle. - The share link's role wins: the user chose this entry point, so the member is created with link.Role and the invitation row is only settled, its own role left untouched. - The join's own confirmed capacity claim covers the new seat, so the settled invitation's reservation is released (enqueue + best-effort immediate release, outbox worker as fallback) instead of charging the workspace twice for one joining user. Unmanaged mode keeps no durable reservation and resolves the release as a no-op. - The settle publishes invitation:accepted with the same shape as the accept path, so workspace admins drop the row from their pending list instead of waiting on a refetch that never comes. * fix(invitation): refresh the pending-invitations list when an invite concludes elsewhere The sidebar's pending-invitations list never recovers on its own when the invitation is concluded from another surface or device: - Global QueryClient defaults are staleTime: Infinity with focus refetch off, and myInvitationListOptions overrides nothing, so the list only moves when something invalidates it. - The invitation:accepted / invitation:declined WS handlers invalidated the workspace-scoped admin lists but not the account-level pending list (invitation:revoked already did). - The sidebar's accept/decline mutations only handled onSuccess, so a "invitation is not pending" failure left the stale row on screen with no feedback. - use-realtime-sync: invalidate workspaceKeys.myInvitations() on invitation:accepted and invitation:declined, matching invitation:revoked — gated on the acting user, since the workspace broadcast reaches every online member and an ungated invalidation refetches the account-level list once per member on every accept/decline. The actor rides the frame envelope: ws-client hands it to handlers as the second argument (wire frame shape is {type, payload, actor_id, actor_type}), so the gate compares that argument, not a payload field. The workspace-scoped admin lists still refresh unconditionally off the broadcast. - app-sidebar: accept invalidates on onError and decline moves to onSettled, so any outcome refreshes the list. - use-realtime-sync-invitations: cover the actor case and the non-actor negative case at the handler layer, plus a wire-contract case per event that drives a real WSClient through a fake WebSocket with the exact frames listeners.go marshals, pinning frame → unpack → handler → gate end to end. The stale row now drops within a WS tick on updated clients; the server-side direct delivery covers clients that never update.