Skip to main content

Post Collaborators — Technical Reference

← Back to Post Collaborators

Where this lives

Backend

The API described below is reachable — inviteCollaborator, bulkInviteCollaborators, acceptCollaboration, rejectCollaboration, removeCollaborator, postCollaborators, myPendingCollaborations, collaborationStats and isCollaborator are all live GraphQL fields.

Frontend

  • apps/frontend-nextjs/src/page-components/settings/CollaborationInvitesPage.tsx (mounted at /settings/collaborations) — lists the caller's pending invites (myPendingCollaborations), Accept/Decline buttons (acceptCollaboration/rejectCollaboration), and a 3-stat header (collaborationStats)
  • apps/frontend-nextjs/src/components/PostOptionsMenu.tsx has an owner-only "Manage collaborators" entry that opens CollaboratorsModal with isOwner set. Fixed this session: the modal used to no longer talk to the post-collaborator API at all — it queried post(id).taggedUsers (the separate tap-to-tag feature backed by PostTag/PostMention) and just listed those people, read-only. It's now rewired to the real postCollaborators query (management mode, triggered when a postId is passed without a users prop), showing each collaborator with a pending/accepted/declined status pill. When isOwner is set it also renders an invite form (username search reusing the tag-people searchUsers query, calling inviteCollaborator for a single pick or bulkInviteCollaborators when multiple are selected) and a "Remove" action per row (removeCollaborator) — both owner-gated, since only PostOptionsMenu's owner-only entry ever passes isOwner. PostCard.tsx's read-only "With @user and N more" line still passes users directly (not postId), so that plain display path is unaffected and still doesn't query postCollaborators.

Technical implementation checklist

  • inviteCollaborator mutation — implemented (post-collaborator.manager.js), wired to the schema (post-collaborator.resolver.js), and now called from CollaboratorsModal's invite form (owner-only). Fixed this session — previously reachable over GraphQL only, no frontend caller
  • acceptCollaboration / rejectCollaboration mutations — implemented, wired, and used by CollaborationInvitesPage.tsx
  • removeCollaborator mutation — implemented, wired, and now called from CollaboratorsModal's "Remove" row action (owner-only). Fixed this session — previously no frontend caller
  • bulkInviteCollaborators mutation — implemented, wired, and now called from CollaboratorsModal's invite form when more than one user is selected. Fixed this session — previously no frontend caller
  • postCollaborators / myPendingCollaborations queries — both implemented and wired; postCollaborators is now called by CollaboratorsModal in management mode (fixed this session — it previously queried post(id).taggedUsers instead), and myPendingCollaborations continues to be used by CollaborationInvitesPage.tsx
  • collaborationStats query — implemented, wired, and used by CollaborationInvitesPage.tsx (this is the caller's own stats across all of their collaborations, not scoped to one post)
  • isCollaborator query — implemented and wired; still no frontend caller
  • Collaborative post appears on all collaborators' profiles and feeds — post.manager.js's getUserPosts/getFeed pull in accepted-collaborator posts via getAcceptedPostIdsForUsers

How it works

The original post creator invites one or more collaborators via inviteCollaborator/bulkInviteCollaborators, from CollaboratorsModal's invite form (owner-only, opened via PostOptionsMenu's "Manage collaborators" entry). Each invited user can accept or reject the invitation from Settings → Collaboration invites. Once accepted, the post appears on the collaborator's own profile grid and in the feeds of their followers, just as if they had posted it themselves. Removing a collaborator (removeCollaborator, also owner-only from CollaboratorsModal) deletes the collaboration row outright rather than flipping it to a "removed" status. Fixed this session: inviting, bulk-inviting, removing, and browsing the roster now all have real frontend UI in CollaboratorsModal; previously only the accept/reject half of this flow (CollaborationInvitesPage.tsx) did, and the modal itself displayed unrelated tagged-user data instead of real collaboration records.

Data model

PostCollaborator (apps/backend/database/models/PostCollaborator.js, table post_collaborator):

FieldTypeDescription
idUUIDCollaborator record ID
postIdUUID (column post_id)The post being collaborated on
userIdUUID (column user_id)The invited/collaborating user's ID
invitedByUUID (column invited_by)User ID of the post owner who sent the invite
statusString(50)pending, accepted, or rejected (defaults to pending; there is no removed status — removal deletes the row)
createdAt / updatedAtDateTime (columns created_at/updated_at)Standard timestamps

There is no sharedToFeed, invitedAt, or respondedAt column — feed/profile visibility is derived at read time from status === 'accepted', and createdAt/updatedAt cover invite/response timing.

GraphQL API

Invite a collaborator

inviteCollaborator sends an invitation to a specific user. The caller must be the post's owner (enforced in the manager); inviting yourself, a nonexistent post, or a user who's already invited/collaborating all raise an error. The invite enters pending status and creates a collaboration_invite notification for the invitee.

mutation InviteCollaborator($postId: ID!, $userId: ID!) {
inviteCollaborator(postId: $postId, userId: $userId) {
id
status
createdAt
user { id username profilePicture }
}
}

Bulk invite

bulkInviteCollaborators calls inviteCollaborator once per userId; a failure for one user (already invited, self-invite, etc.) is collected in errors rather than aborting the whole batch, and success is false if any invite failed.

mutation BulkInviteCollaborators($postId: ID!, $userIds: [ID!]!) {
bulkInviteCollaborators(postId: $postId, userIds: $userIds) {
success
invited
failed
errors { userId error }
}
}

Respond to an invite

acceptCollaboration/rejectCollaboration let the invitee (only) respond to their own pending invite. Accepting sets status: accepted and notifies the inviter (collaboration_accepted); rejecting sets status: rejected and notifies the inviter (collaboration_rejected). Either call fails if the invite isn't pending or the caller isn't the invitee.

mutation AcceptCollaboration($collaborationId: ID!) {
acceptCollaboration(collaborationId: $collaborationId) { id status }
}

mutation RejectCollaboration($collaborationId: ID!) {
rejectCollaboration(collaborationId: $collaborationId) { id status }
}

Remove a collaborator

removeCollaborator deletes the collaboration row outright. Only the user who sent the invite (invitedBy, i.e. the post owner) can do this; it notifies the removed collaborator (collaboration_removed).

mutation RemoveCollaborator($collaborationId: ID!) {
removeCollaborator(collaborationId: $collaborationId)
}

Query collaborators / invites

postCollaborators returns a post's collaboration rows (any status by default, or filtered via status) — there's no ownership check in the resolver, so any authenticated caller can read it. myPendingCollaborations returns the caller's own pending invitations. isCollaborator reports whether the caller is an accepted collaborator on a post.

query PostCollaborators($postId: ID!) {
postCollaborators(postId: $postId, status: "accepted", limit: 20, offset: 0) {
id status createdAt
user { id username profilePicture }
}
}

query MyPendingCollaborations {
myPendingCollaborations {
id status createdAt
inviter { id username profilePicture }
post { id text }
}
}

query IsCollaborator($postId: ID!) {
isCollaborator(postId: $postId)
}

Collaboration stats

collaborationStats returns the caller's own collaboration counts across every post they've been invited to — not scoped to a single post.

query CollaborationStats {
collaborationStats {
totalCollaborations
accepted
pending
rejected
}
}

Feed behavior

When a user accepts a collaboration invite, post.manager.js's getUserPosts includes the post on their own profile grid, and getFeed includes it in the feeds of anyone who follows them — both derived per-request from postCollaboratorAccessService.getAcceptedPostIdsForUsers. Both lookups are best-effort: a failure there is logged and swallowed rather than breaking the profile/feed load.