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",