diff --git a/buzz/api/teams/__init__.py b/buzz/api/teams/__init__.py index 7bc2885c..7f31d627 100644 --- a/buzz/api/teams/__init__.py +++ b/buzz/api/teams/__init__.py @@ -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 @@ -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) diff --git a/buzz/api/teams/services.py b/buzz/api/teams/services.py index c30df12f..8518def8 100644 --- a/buzz/api/teams/services.py +++ b/buzz/api/teams/services.py @@ -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") @@ -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") diff --git a/buzz/api/teams/test_teams.py b/buzz/api/teams/test_teams.py index 72c75f8a..b619a90d 100644 --- a/buzz/api/teams/test_teams.py +++ b/buzz/api/teams/test_teams.py @@ -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 @@ -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("") diff --git a/dashboard/components.d.ts b/dashboard/components.d.ts index 913c2b24..3d146748 100644 --- a/dashboard/components.d.ts +++ b/dashboard/components.d.ts @@ -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'] @@ -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'] diff --git a/dashboard/src/components/TeamSwitcher.vue b/dashboard/src/components/TeamSwitcher.vue index 91c02b98..05cd752b 100644 --- a/dashboard/src/components/TeamSwitcher.vue +++ b/dashboard/src/components/TeamSwitcher.vue @@ -1,10 +1,12 @@ + + diff --git a/dashboard/src/components/common/LoadingPanel.vue b/dashboard/src/components/common/LoadingPanel.vue new file mode 100644 index 00000000..cc7e872f --- /dev/null +++ b/dashboard/src/components/common/LoadingPanel.vue @@ -0,0 +1,19 @@ + + + diff --git a/dashboard/src/components/dashboard/teams/CreateTeamDialog.vue b/dashboard/src/components/dashboard/teams/CreateTeamDialog.vue new file mode 100644 index 00000000..33b31a37 --- /dev/null +++ b/dashboard/src/components/dashboard/teams/CreateTeamDialog.vue @@ -0,0 +1,137 @@ + + + diff --git a/dashboard/src/data/teams.ts b/dashboard/src/data/teams.ts index 70560d3e..401cf6db 100644 --- a/dashboard/src/data/teams.ts +++ b/dashboard/src/data/teams.ts @@ -51,10 +51,26 @@ const teamOverview = useCall({ * 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", }) @@ -63,6 +79,32 @@ export const inviteMembers = createResource({ 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({ + 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 { + 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) diff --git a/dashboard/src/layouts/ManagerLayout.vue b/dashboard/src/layouts/ManagerLayout.vue index 5a8aab69..f972889f 100644 --- a/dashboard/src/layouts/ManagerLayout.vue +++ b/dashboard/src/layouts/ManagerLayout.vue @@ -1,4 +1,5 @@