From 529e79076378146402e0c2c72af838b69e47f71a Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Wed, 28 Oct 2026 15:56:38 -0500 Subject: [PATCH] fix(communities): adopt the applicant's own organization holding the community slug Resume dead-ended on "The community slug is already used by another organization" when an unbound organization owned by the applicant (or, for applications saved without an applicant user, any unbound organization) already held the community slug. The slug path now binds such an organization to the proposal instead of conflicting: it approves a pending org, stamps the community metadata plus a communityLinkedOrg marker, keeps existing owners, adds the reviewer as admin, provisions default teams, and never re-invites the applicant. Ownership is still enforced when the payload has an applicant user, and organizations bound to another proposal still conflict. releaseCommunityOrganization keeps marked organizations (keptReason "existing") so a later failed setup cannot delete a pre-existing organization. --- api/src/lib/community-organizations.ts | 145 +++++++++----- .../integration/community-proposals.test.ts | 189 ++++++++++++++++++ 2 files changed, 287 insertions(+), 47 deletions(-) diff --git a/api/src/lib/community-organizations.ts b/api/src/lib/community-organizations.ts index 38c7144a..3aef1659 100644 --- a/api/src/lib/community-organizations.ts +++ b/api/src/lib/community-organizations.ts @@ -34,6 +34,8 @@ async function fetchOrganization( } } +const SLUG_CONFLICT = "The community slug is already used by another organization"; + async function createCommunityOrganization( auth: AuthClient, input: { @@ -120,6 +122,71 @@ async function grantProposerOwnership( } } +type CommunityOrganization = NonNullable>>; + +async function bindCommunityOrganization( + auth: AuthClient, + organization: CommunityOrganization, + input: { + proposalId: string; + proposerUserId?: string; + applicantAccountId: string; + reviewerUserId: string; + }, + adopted = false, +) { + const proposerUserId = input.proposerUserId ?? null; + const metadata = organization.metadata ?? {}; + if (metadata.communityProposalId && metadata.communityProposalId !== input.proposalId) { + throw conflict( + adopted ? SLUG_CONFLICT : "This organization belongs to another community application", + ); + } + if (organization.requestedBy && proposerUserId && organization.requestedBy !== proposerUserId) { + const proposerMember = organization.members.find((member) => member.userId === proposerUserId); + if (proposerMember?.role !== "owner") { + throw conflict("The applicant does not own the existing organization"); + } + } + if (organization.status === "rejected") { + throw conflict(adopted ? SLUG_CONFLICT : "The existing organization request was rejected"); + } + if (organization.status === "pending") { + await auth.reviewOrganization({ organizationId: organization.id, decision: "approve" }); + } + const stamp = { + ...applicantMetadata({ ...input, proposerUserId }), + ...(adopted ? { communityLinkedOrg: "true" } : {}), + }; + if (Object.entries(stamp).some(([key, value]) => metadata[key] !== value)) { + await auth.updateOrganization({ + data: { metadata: { ...metadata, ...stamp } }, + organizationId: organization.id, + }); + } + if (proposerUserId) { + await grantProposerOwnership(auth, organization.id, proposerUserId); + } + if (input.reviewerUserId !== proposerUserId) { + const { members } = await auth.listMembers({ organizationId: organization.id }); + const reviewerMember = members.find((member) => member.userId === input.reviewerUserId); + if (!reviewerMember) { + await auth.addMember({ + userId: input.reviewerUserId, + role: "admin", + organizationId: organization.id, + }); + } else if (adopted && reviewerMember.role === "owner") { + await auth.updateMemberRole({ + memberId: reviewerMember.id, + role: "admin", + organizationId: organization.id, + }); + } + } + await provisionDefaultTeams(auth, organization.id); +} + export async function provisionCommunityOrganization( auth: AuthClient, input: { @@ -132,54 +199,13 @@ export async function provisionCommunityOrganization( legacyOrgId?: string; }, ): Promise<{ id: string; name: string; slug: string; applicantInvited: boolean }> { - if (input.legacyOrgId && input.proposerUserId) { - const proposerUserId = input.proposerUserId; + const proposerUserId = input.proposerUserId ?? null; + if (input.legacyOrgId && proposerUserId) { const organization = await fetchOrganization(auth, input.legacyOrgId); if (!organization) { throw conflict("The application's existing organization was removed"); } - const metadata = organization.metadata ?? {}; - if (metadata.communityProposalId && metadata.communityProposalId !== input.proposalId) { - throw conflict("This organization belongs to another community application"); - } - if (organization.requestedBy && organization.requestedBy !== proposerUserId) { - const proposerMember = organization.members.find( - (member) => member.userId === proposerUserId, - ); - if (proposerMember?.role !== "owner") { - throw conflict("The applicant does not own the existing organization"); - } - } - if (organization.status === "rejected") { - throw conflict("The existing organization request was rejected"); - } - if (organization.status === "pending") { - await auth.reviewOrganization({ - organizationId: organization.id, - decision: "approve", - }); - } - const applicant = applicantMetadata({ ...input, proposerUserId }); - if (Object.entries(applicant).some(([key, value]) => metadata[key] !== value)) { - await auth.updateOrganization({ - data: { metadata: { ...metadata, ...applicant } }, - organizationId: organization.id, - }); - } - await grantProposerOwnership(auth, organization.id, proposerUserId); - if (input.reviewerUserId !== proposerUserId) { - const reviewerMember = organization.members.find( - (member) => member.userId === input.reviewerUserId, - ); - if (!reviewerMember) { - await auth.addMember({ - userId: input.reviewerUserId, - role: "admin", - organizationId: organization.id, - }); - } - } - await provisionDefaultTeams(auth, organization.id); + await bindCommunityOrganization(auth, organization, input); return { id: organization.id, name: organization.name, @@ -191,9 +217,14 @@ export async function provisionCommunityOrganization( const slugStatus = await auth.checkSlug({ slug: input.slug }); const existing = slugStatus.status ? null : await fetchOrganization(auth, undefined, input.slug); if (existing && existing.metadata?.communityProposalId !== input.proposalId) { - throw conflict("The community slug is already used by another organization"); + await bindCommunityOrganization(auth, existing, input, true); + return { + id: existing.id, + name: existing.name, + slug: existing.slug, + applicantInvited: false, + }; } - const proposerUserId = input.proposerUserId ?? null; const organization = existing ?? (await createCommunityOrganization(auth, { @@ -206,6 +237,23 @@ export async function provisionCommunityOrganization( if (organization.status === "pending") { await auth.reviewOrganization({ organizationId: organization.id, decision: "approve" }); } + if (!proposerUserId && organization.metadata?.communityLinkedOrg === "true") { + const { members } = await auth.listMembers({ organizationId: organization.id }); + if (!members.some((member) => member.userId === input.reviewerUserId)) { + await auth.addMember({ + userId: input.reviewerUserId, + role: "admin", + organizationId: organization.id, + }); + } + await provisionDefaultTeams(auth, organization.id); + return { + id: organization.id, + name: organization.name, + slug: organization.slug, + applicantInvited: false, + }; + } const ownerUserId = proposerUserId ?? input.reviewerUserId; await grantProposerOwnership(auth, organization.id, ownerUserId); if (input.reviewerUserId !== ownerUserId) { @@ -240,11 +288,14 @@ export async function provisionCommunityOrganization( export async function releaseCommunityOrganization( auth: AuthClient, input: { organizationId: string; proposalId: string }, -): Promise<{ released: boolean; keptReason: "not_found" | "in_use" | null }> { +): Promise<{ released: boolean; keptReason: "not_found" | "in_use" | "existing" | null }> { const organization = await fetchOrganization(auth, input.organizationId); if (!organization || organization.metadata?.communityProposalId !== input.proposalId) { return { released: false, keptReason: "not_found" }; } + if (organization.metadata?.communityLinkedOrg === "true") { + return { released: false, keptReason: "existing" }; + } const provisioned = new Set( [ organization.requestedBy, diff --git a/api/tests/integration/community-proposals.test.ts b/api/tests/integration/community-proposals.test.ts index f1ee3a45..a216f25d 100644 --- a/api/tests/integration/community-proposals.test.ts +++ b/api/tests/integration/community-proposals.test.ts @@ -723,6 +723,195 @@ describe("approved community proposal provisioning", () => { }); expect(deleteOrganization).not.toHaveBeenCalled(); }); + + it("binds the applicant's own unbound organization at the community slug", async () => { + organizations.set("own-rosendale", { + id: "own-rosendale", + name: "Rosendale Collective", + slug: "rosendale", + status: "pending", + requestedBy: "user-applicant", + metadata: { seeded: true }, + members: [], + invitations: [], + teams: [], + }); + proposals.set("rosendale", savedProposal("prop-rosendale", "rosendale")); + const admin = await getPluginClient(authedContext("platform-reviewer", "admin")); + const input = { + proposalId: "prop-rosendale", + entityId: "rosendale", + hostname: "rosendale.citynode.app", + }; + const applied = await admin.applyCommunityProposal(input); + expect(applied.applicantInvited).toBe(false); + const adopted = organizations.get("own-rosendale")!; + expect(adopted.status).toBe("active"); + expect(adopted.metadata).toEqual({ + seeded: true, + communityProposalId: "prop-rosendale", + communityApplicantId: "user-applicant", + communityReviewerId: "platform-reviewer", + communityLinkedOrg: "true", + }); + expect( + Object.fromEntries(adopted.members.map((member) => [member.userId, member.role])), + ).toEqual({ "user-applicant": "owner", "platform-reviewer": "admin" }); + expect(adopted.invitations).toEqual([]); + expect(adopted.teams).toHaveLength(3); + const node = await admin.getNode({ nodeId: applied.nodeId }); + const tenant = (await admin.listTenants()).find((item) => item.id === node?.tenantId); + expect(tenant?.orgId).toBe("own-rosendale"); + + const resumed = await admin.applyCommunityProposal(input); + expect(resumed.nodeId).toBe(applied.nodeId); + expect(resumed.applicantInvited).toBe(false); + }); + + it("binds an unbound slug-matched organization when the application saved no applicant user", async () => { + organizations.set("own-oak-park", { + id: "own-oak-park", + name: "Oak Park Collective", + slug: "oak-park", + status: "active", + requestedBy: "user-organizer", + metadata: {}, + members: [ + { + id: "member-oak-owner", + userId: "user-organizer", + organizationId: "own-oak-park", + role: "owner", + }, + ], + invitations: [], + teams: [], + }); + proposals.set( + "oak-park", + savedProposal("prop-oak-park", "oak-park", { applicantUserId: null }), + ); + const admin = await getPluginClient(authedContext("platform-reviewer", "admin")); + const input = { + proposalId: "prop-oak-park", + entityId: "oak-park", + hostname: "oak-park.citynode.app", + }; + const applied = await admin.applyCommunityProposal(input); + expect(applied.applicantInvited).toBe(false); + const adopted = organizations.get("own-oak-park")!; + expect(adopted.status).toBe("active"); + expect(adopted.metadata).toEqual({ + communityProposalId: "prop-oak-park", + communityApplicantAccountId: applicant, + communityReviewerId: "platform-reviewer", + communityLinkedOrg: "true", + }); + expect( + Object.fromEntries(adopted.members.map((member) => [member.userId, member.role])), + ).toEqual({ "user-organizer": "owner", "platform-reviewer": "admin" }); + expect(adopted.invitations).toEqual([]); + expect(adopted.teams).toHaveLength(3); + const node = await admin.getNode({ nodeId: applied.nodeId }); + const tenant = (await admin.listTenants()).find((item) => item.id === node?.tenantId); + expect(tenant?.orgId).toBe("own-oak-park"); + + const resumed = await admin.applyCommunityProposal(input); + expect(resumed.nodeId).toBe(applied.nodeId); + + const failed: Record | undefined = proposals.get("oak-park"); + if (failed) failed.applyStatus = "failed"; + deleteOrganization.mockClear(); + expect( + await admin.releaseCommunityProposal({ + proposalId: "prop-oak-park", + entityId: "oak-park", + expectedUpdatedAt: savedAt, + }), + ).toEqual({ + community: { released: true, keptReason: null }, + organization: { released: false, keptReason: "existing" }, + }); + expect(deleteOrganization).not.toHaveBeenCalled(); + expect(organizations.has("own-oak-park")).toBe(true); + + const resumable: Record | undefined = proposals.get("oak-park"); + if (resumable) resumable.applyStatus = "applying"; + const reapplied = await admin.applyCommunityProposal(input); + expect(await admin.getNode({ nodeId: reapplied.nodeId })).toMatchObject({ slug: "oak-park" }); + }); + + it("still refuses a slug-matched organization held by another application or another owner", async () => { + organizations.set("taken-bound", { + id: "taken-bound", + name: "Bound Collective", + slug: "bound-slug", + status: "active", + requestedBy: "user-organizer", + metadata: { communityProposalId: "prop-other" }, + members: [ + { + id: "member-bound-owner", + userId: "user-organizer", + organizationId: "taken-bound", + role: "owner", + }, + ], + invitations: [], + teams: [], + }); + organizations.set("taken-owned", { + id: "taken-owned", + name: "Stranger Collective", + slug: "owned-slug", + status: "active", + requestedBy: "user-stranger", + metadata: {}, + members: [ + { + id: "member-stranger-owner", + userId: "user-stranger", + organizationId: "taken-owned", + role: "owner", + }, + ], + invitations: [], + teams: [], + }); + proposals.set( + "bound-slug", + savedProposal("prop-bound-slug", "bound-slug", { applicantUserId: null }), + ); + proposals.set("owned-slug", savedProposal("prop-owned-slug", "owned-slug")); + const admin = await getPluginClient(authedContext("platform-reviewer", "admin")); + await expect( + admin.applyCommunityProposal({ + proposalId: "prop-bound-slug", + entityId: "bound-slug", + hostname: "bound-slug.citynode.app", + }), + ).rejects.toMatchObject({ + code: "CONFLICT", + message: "The community slug is already used by another organization", + }); + await expect( + admin.applyCommunityProposal({ + proposalId: "prop-owned-slug", + entityId: "owned-slug", + hostname: "owned-slug.citynode.app", + }), + ).rejects.toMatchObject({ + code: "CONFLICT", + message: "The applicant does not own the existing organization", + }); + expect( + (await admin.listTenants()).some((item) => item.accountId === "bound-slug.sputnik-dao.near"), + ).toBe(false); + expect( + (await admin.listTenants()).some((item) => item.accountId === "owned-slug.sputnik-dao.near"), + ).toBe(false); + expect(organizations.get("taken-bound")?.metadata.communityProposalId).toBe("prop-other"); + }); it("makes the reviewer owner and invites the applicant when the application saved no applicant user", async () => { proposals.set( "brooklyn",