Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 98 additions & 47 deletions api/src/lib/community-organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -120,6 +122,71 @@ async function grantProposerOwnership(
}
}

type CommunityOrganization = NonNullable<Awaited<ReturnType<typeof fetchOrganization>>>;

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: {
Expand All @@ -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,
Expand All @@ -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, {
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
189 changes: 189 additions & 0 deletions api/tests/integration/community-proposals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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<string, unknown> | 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",
Expand Down
Loading