Files
multica/server/cmd
苗大 ad6b42f093 fix(invitation): drop stale pending-invitation rows when an invite concludes elsewhere (MUL-7389)
* 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.
2026-09-16 15:50:26 +08:00
..