Skip to content
Open
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
9 changes: 9 additions & 0 deletions buzz/api/teams/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import frappe
from frappe.rate_limiter import rate_limit

from buzz.api.teams import invitations, services
from buzz.api.teams.schemas import InviteOutcome, TeamOption, TeamOverview
Expand Down Expand Up @@ -39,3 +40,11 @@ def remove_member(team: str, user: str) -> None:
@frappe.whitelist(methods=["POST"])
def invite_members(team: str, invites: list[dict]) -> list[InviteOutcome]:
return invitations.invite_members(team, invites)


# Rate limited by IP: the limiter buckets on `frappe.form_dict.cmd`, which only the
# /api/method path sets, so this endpoint is called from there rather than over v2.
@frappe.whitelist(methods=["POST"])
@rate_limit(limit=5, seconds=60 * 60)
def create_team(team_name: str, logo: str | None = None) -> TeamOption:
return services.create_team(team_name, logo)
15 changes: 14 additions & 1 deletion buzz/api/teams/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from frappe.query_builder import Case

from buzz.api.teams.exceptions import CannotManageMembers, NotATeamMember
from buzz.api.teams.schemas import TeamInvite, TeamMember, TeamOverview
from buzz.api.teams.schemas import TeamInvite, TeamMember, TeamOption, TeamOverview
from buzz.permissions import can_manage_members, team_role_of

TEAM_FIELDS = ("name", "team_name", "slug", "logo")
Expand Down Expand Up @@ -86,3 +86,16 @@ def remove_member(team: str, user: str) -> None:
# Event Manager holds no write permission on the membership doctype, so the guard above
# is the authorization — the same shape as the Desk add-members flow.
membership.save(ignore_permissions=True)


def create_team(team_name: str, logo: str | None = None) -> TeamOption:
"""Start a team with the session user as its Owner.

Inserted past permissions like the reads above: Buzz Team's create right belongs to
System Manager, while any signed-in user may start a team of their own. The controller
derives the slug and lays down the Owner membership and the team settings.
"""
team = frappe.get_doc({"doctype": "Buzz Team", "team_name": team_name, "logo": logo})
team.insert(ignore_permissions=True)

return TeamOption(name=team.name, team_name=team.team_name, logo=team.logo, team_role="Owner")
50 changes: 49 additions & 1 deletion buzz/api/teams/test_teams.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import frappe
from frappe.tests import IntegrationTestCase

from buzz.api.teams import get_my_teams, get_team_overview, remove_member
from buzz.api.teams import create_team, get_my_teams, get_team_overview, remove_member
from buzz.api.teams.exceptions import CannotManageMembers, NotATeamMember
from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user
from buzz.events.doctype.buzz_team_membership.buzz_team_membership import upsert_membership
Expand Down Expand Up @@ -212,3 +212,51 @@ def test_the_desk_role_survives_while_another_team_still_earns_it(self):

remove_member(second, member)
self.assertNotIn("Event Manager", frappe.get_roles(member))


class TestCreateTeam(IntegrationTestCase):
def setUp(self):
frappe.set_user("Administrator")
self.addCleanup(frappe.set_user, "Administrator")

def test_makes_the_creator_the_owner(self):
user = create_user("create-team-owner@example.com", "Founder")

frappe.set_user(user)
option = create_team("Create Team Owned")

self.assertEqual(option.team_name, "Create Team Owned")
self.assertEqual(option.team_role, "Owner")
self.assertTrue(
frappe.db.exists(
"Buzz Team Membership",
{"team": option.name, "user": user, "team_role": "Owner", "enabled": 1},
)
)
self.assertTrue(frappe.db.exists("Buzz Team Settings", {"team": option.name}))

def test_appears_in_the_creators_teams(self):
user = create_user("create-team-switcher@example.com", "Founder")

frappe.set_user(user)
option = create_team("Create Team Switcher")

self.assertIn(option.name, [team.name for team in get_my_teams()])

def test_a_repeated_name_gets_a_team_of_its_own(self):
user = create_user("create-team-twice@example.com", "Founder")

frappe.set_user(user)
first = create_team("Create Team Twice")
second = create_team("Create Team Twice")

self.assertNotEqual(first.name, second.name)
slugs = frappe.get_all("Buzz Team", filters={"name": ["in", [first.name, second.name]]}, pluck="slug")
self.assertEqual(len(set(slugs)), 2)

def test_refuses_a_nameless_team(self):
user = create_user("create-team-nameless@example.com", "Founder")

frappe.set_user(user)
with self.assertRaises(frappe.MandatoryError):
create_team("")
2 changes: 2 additions & 0 deletions dashboard/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ declare module 'vue' {
BuzzLogo: typeof import('./src/components/common/BuzzLogo.vue')['default']
CancellationRequestDialog: typeof import('./src/components/CancellationRequestDialog.vue')['default']
CancellationRequestNotice: typeof import('./src/components/CancellationRequestNotice.vue')['default']
CreateTeamDialog: typeof import('./src/components/dashboard/teams/CreateTeamDialog.vue')['default']
CustomFieldInput: typeof import('./src/components/CustomFieldInput.vue')['default']
CustomFieldsSection: typeof import('./src/components/CustomFieldsSection.vue')['default']
EventBanner: typeof import('./src/components/dashboard/events/EventBanner.vue')['default']
Expand All @@ -43,6 +44,7 @@ declare module 'vue' {
FormFieldSections: typeof import('./src/components/FormFieldSections.vue')['default']
GuestRegistrationDialog: typeof import('./src/components/dashboard/events/GuestRegistrationDialog.vue')['default']
LanguageSwitcher: typeof import('./src/components/LanguageSwitcher.vue')['default']
LoadingPanel: typeof import('./src/components/common/LoadingPanel.vue')['default']
LoginDialog: typeof import('./src/components/LoginDialog.vue')['default']
LoginRequired: typeof import('./src/components/LoginRequired.vue')['default']
Navbar: typeof import('./src/components/Navbar.vue')['default']
Expand Down
11 changes: 11 additions & 0 deletions dashboard/src/components/TeamSwitcher.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
<script setup lang="ts">
import CreateTeamDialog from "@/components/dashboard/teams/CreateTeamDialog.vue";
import { currentTeam, selectTeam, teams } from "@/data/teams";
import type { TeamOption } from "@/types";
import { Avatar, Badge, KeyboardShortcut, Popover } from "frappe-ui";
import { computed, ref } from "vue";

const query = ref("");
const showCreateDialog = ref(false);

const matchingTeams = computed(() =>
teams.value.filter((team) =>
Expand All @@ -16,6 +18,12 @@ function switchTeam(team: TeamOption, closePanel: () => void) {
selectTeam(team.name);
closePanel();
}

// The popover has to go first: it traps focus, which the dialog then cannot take.
function openCreateDialog(closePanel: () => void) {
closePanel();
showCreateDialog.value = true;
}
</script>

<template>
Expand Down Expand Up @@ -89,6 +97,7 @@ function switchTeam(team: TeamOption, closePanel: () => void) {

<button
class="flex w-full items-center gap-3 border-t px-3 py-2.5 text-left transition-colors duration-150 hover:bg-surface-gray-2 active:bg-surface-gray-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-outline-gray-3"
@click="openCreateDialog(close)"
>
<span class="lucide-plus size-4 shrink-0 text-ink-gray-6" />
<div class="min-w-0">
Expand All @@ -101,4 +110,6 @@ function switchTeam(team: TeamOption, closePanel: () => void) {
</div>
</template>
</Popover>

<CreateTeamDialog v-model="showCreateDialog" />
</template>
19 changes: 19 additions & 0 deletions dashboard/src/components/common/LoadingPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<script setup lang="ts">
import BuzzLogo from "@/components/common/BuzzLogo.vue";

withDefaults(defineProps<{ text?: string }>(), { text: "Loading" });
</script>

<template>
<div class="flex h-full min-h-64 flex-col items-center justify-center gap-4">
<div class="relative">
<!-- The copy behind carries the ping, so the logo itself stays readable. -->
<BuzzLogo
aria-hidden="true"
class="absolute inset-0 w-9 h-7 animate-ping text-ink-gray-3 motion-reduce:hidden"
/>
<BuzzLogo class="relative w-9 h-7 text-ink-gray-4" />
</div>
<p class="text-sm text-ink-gray-5">{{ text }}</p>
</div>
</template>
137 changes: 137 additions & 0 deletions dashboard/src/components/dashboard/teams/CreateTeamDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<script setup lang="ts">
import { createAndSelectTeam, createTeam } from "@/data/teams";
import type { FrappeError } from "@/types";
import { Avatar, Button, Dialog, ErrorMessage, FileUploader, toast } from "frappe-ui";
import { computed, ref, watch } from "vue";
import { useRouter } from "vue-router";

const IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp", "image/svg+xml"];

const router = useRouter();

const isOpen = defineModel<boolean>({ required: true });

const teamName = ref("");
const logo = ref("");
const showErrors = ref(false);

// createResource types its error as {}, so the message needs narrowing.
const errorMessage = computed(() => (createTeam.error as FrappeError | null)?.message);

watch(isOpen, (open) => {
if (!open) return;
teamName.value = "";
logo.value = "";
showErrors.value = false;
createTeam.error = null;
});

// The slug and the Owner membership come from the server, so a name is the whole form.
const invalid = computed(() => !teamName.value.trim());

async function submit() {
showErrors.value = true;
if (invalid.value) return;
Comment on lines +32 to +34

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Concurrent team submissions create duplicates

When a user submits the form again while creation is in progress, submit starts another request because it does not guard on createTeam.loading, causing multiple teams to be persisted for one intended creation.

Suggested change
async function submit() {
showErrors.value = true;
if (invalid.value) return;
async function submit() {
if (createTeam.loading) return;
showErrors.value = true;
if (invalid.value) return;
Prompt To Fix With AI
This is a comment left during a code review.
Path: dashboard/src/components/dashboard/teams/CreateTeamDialog.vue
Line: 32-34

Comment:
**Concurrent team submissions create duplicates**

When a user submits the form again while creation is in progress, `submit` starts another request because it does not guard on `createTeam.loading`, causing multiple teams to be persisted for one intended creation.

```suggestion
async function submit() {
	if (createTeam.loading) return;

	showErrors.value = true;
	if (invalid.value) return;
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex


const team = await createAndSelectTeam({
team_name: teamName.value.trim(),
logo: logo.value || null,
});
if (!team) return;

toast.success(`${team.team_name} created`);
isOpen.value = false;
// The new team is already the selected one, so the overview opens on it.
router.push({ name: "team-overview" });
}
</script>

<template>
<Dialog v-model="isOpen">
<template #body-title>
<div class="flex items-center gap-2">
<span class="lucide-users-round size-5 text-ink-gray-7" />
<h3 class="text-2xl font-semibold text-ink-gray-9">Create team</h3>
</div>
</template>

<template #body-content>
<form novalidate class="flex flex-col items-center gap-4" @submit.prevent="submit">
<FileUploader
:file-types="IMAGE_TYPES"
@success="(file: { file_url: string }) => (logo = file.file_url)"
>
<template #default="{ openFileSelector, uploading, error: uploadError }">
<div class="flex flex-col items-center gap-1">
<!-- The overlay is the control; the avatar under it is only the preview. -->
<button
type="button"
class="group relative rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-outline-gray-3"
:aria-label="logo ? 'Change logo' : 'Add logo'"
@click="openFileSelector"
>
<!-- Avatar sizes itself from the utility class rather than its size enum,
which tops out well below a preview worth looking at. -->
<Avatar
:image="logo || undefined"
shape="square"
class="size-24 rounded-xl"
/>
<!-- Empty, the icon is the whole placeholder; over an uploaded logo it
needs the backdrop to stay legible. -->
<span
class="absolute inset-0 grid place-items-center rounded-xl transition duration-150"
:class="[
logo
? 'bg-black/50 opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100'
: 'group-hover:bg-black/50',
uploading && 'opacity-100',
]"
>
<span
class="size-6"
:class="[
uploading
? 'lucide-loader-circle animate-spin'
: 'lucide-camera',
logo
? 'text-white'
: 'text-ink-gray-4 group-hover:text-white',
]"
/>
</span>
</button>
<ErrorMessage v-if="uploadError" :message="String(uploadError)" />
</div>
</template>
</FileUploader>

<!-- Unadorned on purpose: the name reads as the team's title, not as a form field. -->
<input
v-model="teamName"
aria-label="Team name"
placeholder="Team name"
autocomplete="off"
class="w-full bg-transparent text-center text-lg font-medium text-ink-gray-9 placeholder-ink-gray-4 focus:outline-none"
/>

<p v-if="showErrors && invalid" class="text-sm text-ink-red-4">
A team needs a name.
</p>
<ErrorMessage v-else :message="errorMessage" />

<!-- type=button: inside a form, a submit button would run `submit` twice —
once on click, once on the form's own submit — and the second insert
creates a second team. -->
<Button
type="button"
variant="solid"
label="Create"
class="w-full"
:loading="createTeam.loading"
@click="submit"
/>
</form>
</template>
</Dialog>
</template>
44 changes: 43 additions & 1 deletion dashboard/src/data/teams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,26 @@ const teamOverview = useCall<TeamOverview, { team: string }>({
* first fetch, so `loading` is true before the page paints.
*/
export function useTeamOverview() {
watch(currentTeam, (team) => team && teamOverview.reload(), { immediate: true })
// Watched by name rather than by the object: every refresh of the teams list builds
// fresh TeamOption objects, so watching `currentTeam` itself re-fired on the same team
// and the second request aborted the first one mid-flight.
watch(selectedTeamName, (team) => team && teamOverview.reload(), { immediate: true })
return teamOverview
}

/**
* The overview's error, minus the ones no one should see.
*
* A superseded request is aborted, and the rejection lands in `error` after the newer
* request has already succeeded — "signal is aborted without reason" over a page whose
* data is sitting right there.
*/
export const teamOverviewError = computed(() =>
teamOverview.error && teamOverview.error.name !== "AbortError"
? teamOverview.error.message
: ""
)

export const removeMember = createResource({
url: "buzz.api.teams.remove_member",
})
Expand All @@ -63,6 +79,32 @@ export const inviteMembers = createResource<InviteOutcome[]>({
url: "buzz.api.teams.invite_members",
})

// The endpoint is rate limited, and the limiter buckets on `cmd`, which only the
// /api/method path sets — hence createResource here rather than a v2 useCall.
export const createTeam = createResource<TeamOption>({
url: "buzz.api.teams.create_team",
})

/**
* Create a team and switch to it. Resolves once it is the selected team.
*
* The selection happens here rather than in an `onSuccess`, which createResource does
* not await: the caller would navigate first, the team page would load the old team,
* and the switch that followed would abort that request mid-flight — leaving "signal is
* aborted without reason" on a page whose data had already arrived.
*/
export async function createAndSelectTeam(params: {
team_name: string
logo: string | null
}): Promise<TeamOption | null> {
const team = (await createTeam.submit(params)) as TeamOption | null
if (createTeam.error || !team) return null

await teamsResource.reload()
selectTeam(team.name)
return team
}

export function selectTeam(name: string) {
selectedTeamName.value = name
localStorage.setItem(STORAGE_KEY, name)
Expand Down
Loading
Loading