Vectrum
Sign In
162 lines · 5.6 KB · TypeScript
"use server";

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { notifyInvitation } from "@/lib/emails";
import {
  answerInvitation,
  cancelInvitation,
  createOrg,
  deleteOrg,
  inviteMember,
  isOrgRole,
  OrgError,
  removeMember,
  removeOrgAvatar,
  ROLE_INFO,
  setMemberRole,
  setOrgAvatar,
  updateOrgProfile,
} from "@/lib/orgs";
import { ActionError, orgForAction, viewerOrThrow } from "./guard";
import type { FormState } from "./types";

async function attempt(fn: () => Promise<FormState | void>): Promise<FormState> {
  try {
    return (await fn()) ?? undefined;
  } catch (e) {
    if (e instanceof OrgError || e instanceof ActionError) return { error: e.message };
    throw e;
  }
}

function roleOf(formData: FormData) {
  const role = formData.get("role");
  if (!isOrgRole(role)) throw new ActionError("Choose a role.");
  return role;
}

// Memberships change what's in everyone's sidebar and which repositories they see.
const refresh = () => revalidatePath("/", "layout");

export async function createOrgAction(_prev: FormState, formData: FormData): Promise<FormState> {
  let path = "";
  const result = await attempt(async () => {
    const viewer = await viewerOrThrow();
    const org = await createOrg(viewer, {
      name: String(formData.get("name") ?? ""),
      displayName: String(formData.get("displayName") ?? ""),
      description: String(formData.get("description") ?? ""),
    });
    path = `/organizations/${org.username}/settings/members`;
  });
  if (result?.error) return result;
  refresh();
  redirect(path);
}

export async function updateOrgAction(name: string, _prev: FormState, formData: FormData) {
  return attempt(async () => {
    const { org } = await orgForAction(name, "owner");
    await updateOrgProfile(org.id, {
      displayName: String(formData.get("displayName") ?? ""),
      description: String(formData.get("description") ?? ""),
    });
    revalidatePath(`/${org.username}`);
    return { success: "Profile updated." };
  });
}

export async function updateOrgAvatarAction(name: string, _prev: FormState, formData: FormData) {
  return attempt(async () => {
    const { org } = await orgForAction(name, "owner");
    const file = formData.get("image");
    if (!(file instanceof File)) throw new OrgError("Choose an image to upload.");
    await setOrgAvatar(org.id, new Uint8Array(await file.arrayBuffer()));
    // The avatar shows up in sidebars and lists everywhere, beside the organization's repositories too.
    refresh();
    return { success: "Avatar updated." };
  });
}

export async function removeOrgAvatarAction(name: string) {
  const { org } = await orgForAction(name, "owner");
  await removeOrgAvatar(org.id);
  refresh();
}

export async function deleteOrgAction(name: string, _prev: FormState, formData: FormData) {
  const result = await attempt(async () => {
    const { org } = await orgForAction(name, "owner");
    if (formData.get("confirm") !== org.username) return { error: `Type ${org.username} to confirm.` };
    await deleteOrg(org);
  });
  if (result?.error) return result;
  refresh();
  redirect("/settings/organizations");
}

export async function inviteMemberAction(name: string, _prev: FormState, formData: FormData) {
  return attempt(async () => {
    const { org, viewer } = await orgForAction(name, "owner");
    const role = roleOf(formData);
    const user = await inviteMember(org, viewer, String(formData.get("username") ?? ""), role);
    notifyInvitation(user, org, viewer, role);
    revalidatePath(`/organizations/${org.username}/settings/members`);
    return { success: `Invited ${user.username} as ${ROLE_INFO[role].title.toLowerCase()}. They’ll find the invitation on their dashboard.` };
  });
}

export async function cancelInvitationAction(name: string, invitationId: string) {
  const { org } = await orgForAction(name, "owner");
  await cancelInvitation(org.id, String(invitationId));
  revalidatePath(`/organizations/${org.username}/settings/members`);
}

export async function setMemberRoleAction(name: string, userId: string, _prev: FormState, formData: FormData) {
  return attempt(async () => {
    const { org } = await orgForAction(name, "owner");
    const role = roleOf(formData);
    await setMemberRole(org.id, String(userId), role);
    refresh();
    return { success: `Role changed to ${ROLE_INFO[role].title.toLowerCase()}.` };
  });
}

export async function removeMemberAction(name: string, userId: string): Promise<FormState> {
  let left = false;
  const result = await attempt(async () => {
    const { org, viewer } = await orgForAction(name, "owner");
    await removeMember(org.id, String(userId));
    left = userId === viewer.id;
  });
  if (result?.error) return result;
  refresh();
  if (left) redirect("/settings/organizations");
  return { success: "Member removed." };
}

/** Any member can leave, unless they're its last owner. */
export async function leaveOrgAction(name: string): Promise<FormState> {
  const result = await attempt(async () => {
    const { org, viewer } = await orgForAction(name, "read");
    await removeMember(org.id, viewer.id);
  });
  if (result?.error) return result;
  refresh();
  return { success: `You left ${name}.` };
}

export async function answerInvitationAction(invitationId: string, accept: boolean): Promise<FormState> {
  accept = accept === true;
  let path = "";
  const result = await attempt(async () => {
    const viewer = await viewerOrThrow();
    const invitation = await answerInvitation(viewer.id, String(invitationId), accept);
    if (accept) path = `/${invitation.org.username}`;
  });
  if (result?.error) return result;
  refresh();
  if (path) redirect(path);
  return { success: "Invitation declined." };
}