diff --git a/src/app/manage/[slug]/(dashboard)/(forms)/MemberSettings.tsx b/src/app/manage/[slug]/(dashboard)/(forms)/MemberSettings.tsx new file mode 100644 index 000000000..6cd1bfce8 --- /dev/null +++ b/src/app/manage/[slug]/(dashboard)/(forms)/MemberSettings.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { + Button, + FormControl, + InputLabel, + MenuItem, + Select, +} from '@mui/material'; +import { useMutation } from '@tanstack/react-query'; +import { useState } from 'react'; +import Panel from '@src/components/common/Panel'; +import { setSnackbar, SnackbarPresets } from '@src/components/global/Snackbar'; +import type { SelectClub } from '@src/server/db/models'; +import { useTRPC } from '@src/trpc/react'; + +type MemberSettingsProps = { + club: SelectClub; +}; + +const policyLabels: Record = { + open: 'Anyone can join', + request: 'Request to join', + closed: 'No new members', +}; + +export default function MemberSettings({ club }: MemberSettingsProps) { + const api = useTRPC(); + const [policy, setPolicy] = useState(club.membershipPolicy); + + const updatePolicy = useMutation( + api.club.edit.updateMembershipPolicy.mutationOptions({ + onSuccess: () => { + setSnackbar({ + message: 'Membership policy updated!', + type: 'success', + autoHideDuration: true, + fitContent: true, + closeOn: ['timeout', 'escapeKeyDown', 'dismiss'], + }); + }, + onError: (error) => { + setSnackbar( + SnackbarPresets.errorCustomMessage( + 'Failed to update membership policy', + error.message, + ), + ); + }, + }), + ); + + const hasChanges = policy !== club.membershipPolicy; + + return ( + +
+ + + Membership Policy + + + +
+ + +
+
+
+ ); +} diff --git a/src/app/manage/[slug]/(dashboard)/ClubManageForm.tsx b/src/app/manage/[slug]/(dashboard)/ClubManageForm.tsx index b8b65c539..87ce4391c 100644 --- a/src/app/manage/[slug]/(dashboard)/ClubManageForm.tsx +++ b/src/app/manage/[slug]/(dashboard)/ClubManageForm.tsx @@ -7,6 +7,7 @@ import Collaborators from './(forms)/Collaborators'; import Contacts from './(forms)/Contacts'; import DeleteClub from './(forms)/DeleteClub'; import Details from './(forms)/Details'; +import MemberSettings from './(forms)/MemberSettings'; import MembershipForms from './(forms)/MembershipForms'; import Officers from './(forms)/Officers'; import Slug from './(forms)/Slug'; @@ -57,6 +58,7 @@ const ClubManageForm = async ({ club={club} listedMembershipForms={listedMembershipForms} /> + }) => { startIcon={} size="large" > - Followers + Members & Followers
diff --git a/src/components/club/JoinButton.tsx b/src/components/club/JoinButton.tsx index 76d6aaf6e..17f571b63 100644 --- a/src/components/club/JoinButton.tsx +++ b/src/components/club/JoinButton.tsx @@ -1,13 +1,24 @@ 'use client'; import AddIcon from '@mui/icons-material/Add'; +import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; import CheckIcon from '@mui/icons-material/Check'; import TuneIcon from '@mui/icons-material/Tune'; -import { Button, Skeleton, Tooltip } from '@mui/material'; +import { + Button, + ButtonGroup, + ClickAwayListener, + Grow, + MenuItem, + MenuList, + Paper, + Popper, + Skeleton, +} from '@mui/material'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { useRef } from 'react'; +import React, { useRef, useState } from 'react'; import { useRegisterModal } from '@src/components/global/RegisterModalProvider'; import { setSnackbar, SnackbarPresets } from '@src/components/global/Snackbar'; import { useTRPC } from '@src/trpc/react'; @@ -26,88 +37,200 @@ const JoinButton = ({ isHeader, clubId, clubSlug }: JoinButtonProps) => { const { data: memberState, isPending } = useQuery( api.club.memberState.queryOptions({ id: clubId }), ); + const { data: membershipPolicy } = useQuery( + api.club.membershipPolicy.queryOptions({ id: clubId }), + ); - const joinLeave = useMutation( - api.club.joinLeave.mutationOptions({ - onMutate: async ({ clubId }) => { - const queryKey = [ - ['club', 'memberState'], - { input: { id: clubId }, type: 'query' }, - ]; - - // Cancel outgoing refetches - await queryClient.cancelQueries({ - queryKey, - }); + const memberStateQueryKey = [ + ['club', 'memberState'], + { input: { id: clubId }, type: 'query' }, + ]; - // Remember previous value - const previousState = - queryClient.getQueryData(queryKey); + const invalidateMemberState = () => { + queryClient.invalidateQueries({ queryKey: memberStateQueryKey }); + }; - // Optimistically update the cache - queryClient.setQueryData(queryKey, (old: typeof memberState) => { - if (!old) return old; + const follow = useMutation( + api.club.follow.mutationOptions({ + onSuccess: () => { + invalidateMemberState(); + setSnackbar({ + message: 'Followed club!', + type: 'success', + autoHideDuration: true, + fitContent: true, + closeOn: ['timeout', 'escapeKeyDown', 'dismiss'], + }); + }, + onError: (error) => { + setSnackbar( + SnackbarPresets.errorCustomMessage( + 'An error occurred', + error.message, + ), + ); + }, + }), + ); - return { - ...old, - memberType: old.memberType ? null : 'Member', - joinedAt: old.memberType ? null : new Date(), - }; + const unfollow = useMutation( + api.club.unfollow.mutationOptions({ + onSuccess: () => { + invalidateMemberState(); + setSnackbar({ + message: 'Unfollowed club', + type: 'info', + autoHideDuration: true, + fitContent: true, + closeOn: ['timeout', 'escapeKeyDown', 'dismiss'], }); + }, + onError: (error) => { + setSnackbar( + SnackbarPresets.errorCustomMessage( + 'An error occurred', + error.message, + ), + ); + }, + }), + ); - // Return context for rollback - return { previousState, queryKey }; + const join = useMutation( + api.club.join.mutationOptions({ + onSuccess: () => { + invalidateMemberState(); + setSnackbar({ + message: 'Joined club!', + type: 'success', + autoHideDuration: true, + fitContent: true, + closeOn: ['timeout', 'escapeKeyDown', 'dismiss'], + }); }, - onSuccess: (context) => { - const joined = context?.memberType === undefined; + onError: (error) => { + setSnackbar( + SnackbarPresets.errorCustomMessage( + 'An error occurred', + error.message, + ), + ); + }, + }), + ); + const leave = useMutation( + api.club.leave.mutationOptions({ + onSuccess: () => { + invalidateMemberState(); setSnackbar({ - message: joined ? 'Followed club!' : 'Left club!', - type: joined ? 'success' : 'info', + message: 'Left club', + type: 'info', autoHideDuration: true, fitContent: true, closeOn: ['timeout', 'escapeKeyDown', 'dismiss'], }); }, - onError: (error, _vars, context) => { + onError: (error) => { setSnackbar( SnackbarPresets.errorCustomMessage( 'An error occurred', error.message, ), ); - if (context?.previousState) { - queryClient.setQueryData(context.queryKey, context.previousState); - } }, - onSettled: (_data, _error, { clubId }) => { - queryClient.invalidateQueries({ - queryKey: [ - ['club', 'memberState'], - { input: { id: clubId }, type: 'query' }, - ], + }), + ); + + const requestJoin = useMutation( + api.club.requestJoin.mutationOptions({ + onSuccess: () => { + invalidateMemberState(); + setSnackbar({ + message: 'Request sent!', + type: 'success', + autoHideDuration: true, + fitContent: true, + closeOn: ['timeout', 'escapeKeyDown', 'dismiss'], }); }, + onError: (error) => { + setSnackbar( + SnackbarPresets.errorCustomMessage( + 'An error occurred', + error.message, + ), + ); + }, }), ); - const router = useRouter(); + const cancelRequest = useMutation( + api.club.cancelRequest.mutationOptions({ + onSuccess: () => { + invalidateMemberState(); + setSnackbar({ + message: 'Request removed', + type: 'info', + autoHideDuration: true, + fitContent: true, + closeOn: ['timeout', 'escapeKeyDown', 'dismiss'], + }); + }, + onError: (error) => { + setSnackbar( + SnackbarPresets.errorCustomMessage( + 'An error occurred', + error.message, + ), + ); + }, + }), + ); + const router = useRouter(); const useAuthPage = useRef(false); - const { setShowRegisterModal } = useRegisterModal(() => { useAuthPage.current = true; }); + const [menuOpen, setMenuOpen] = useState(false); + const [anchorEl, setAnchorEl] = useState(null); + const memberType = memberState?.memberType ?? null; + const policy = membershipPolicy ?? 'open'; + const anyPending = + follow.isPending || + unfollow.isPending || + join.isPending || + leave.isPending || + requestJoin.isPending || + cancelRequest.isPending; + + const requireAuth = (callback: () => void) => { + if (!session) { + if (useAuthPage.current) { + router.push( + `/auth?callbackUrl=${encodeURIComponent(window.location.href)}`, + ); + } else { + setShowRegisterModal(true); + } + return; + } + callback(); + }; + + const size = isHeader ? 'large' : 'small'; + // Officers/Presidents see the Manage button if (memberType === 'Officer' || memberType === 'President') { return (
- } - > + // Not connected: show Follow button + if (!memberType) { + return ( + ); + } + + // Member: show Leave button + if (memberType === 'Member') { + return ( + + ); + } - if (isPending || joinLeave.isPending) return; - - if (!session) { - // This will use auth page when this JoinButton and a RegisterModal are not wrapped in a ``. - if (useAuthPage.current) { - router.push( - `/auth?callbackUrl=${encodeURIComponent(window.location.href)}`, - ); - } else { - setShowRegisterModal(true); - } - return; - } - - void joinLeave.mutate({ clubId }); + // Requested: show Remove Request button + if (memberType === 'Requested') { + return ( + - + ); + } + + // Follower: show split button with Join/Request + Unfollow dropdown + const joinLabel = + policy === 'open' + ? 'Join' + : policy === 'request' + ? 'Request to Join' + : null; + + const handleJoinAction = () => { + if (anyPending) return; + if (policy === 'open') { + join.mutate({ clubId }); + } else if (policy === 'request') { + requestJoin.mutate({ clubId }); + } + }; + + return ( + <> + + {joinLabel ? ( + + ) : ( + + )} + + + + {({ TransitionProps, placement }) => ( + + + setMenuOpen(false)}> + + { + e.preventDefault(); + e.stopPropagation(); + setMenuOpen(false); + if (anyPending) return; + unfollow.mutate({ clubId }); + }} + > + Unfollow + + + + + + )} + + ); }; diff --git a/src/components/manage/MemberList/CustomRenderCell.tsx b/src/components/manage/MemberList/CustomRenderCell.tsx index 134758e0c..299ca16e4 100644 --- a/src/components/manage/MemberList/CustomRenderCell.tsx +++ b/src/components/manage/MemberList/CustomRenderCell.tsx @@ -1,3 +1,5 @@ +import CheckIcon from '@mui/icons-material/Check'; +import CloseIcon from '@mui/icons-material/Close'; import DeleteIcon from '@mui/icons-material/Delete'; import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined'; import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined'; @@ -29,6 +31,17 @@ export function SmallTextCell(params: GridRenderCellParams) { export function JoinedAtCell(params: GridRenderCellParams) { const { expandTimestamps } = useContext(MemberListContext); + if (!params.row.joinedAt) { + return ( + + — + + ); + } + const localeDateString = params.row.joinedAt.toLocaleString('en-us', { month: 'short', day: 'numeric', @@ -125,7 +138,9 @@ export function ContactEmailCell(params: GridRenderCellParams) { const RoleToMemberType: Record = { Admin: 'President', Collaborator: 'Officer', - Follower: 'Member', + Member: 'Member', + Follower: 'Follower', + Requested: 'Requested', }; export function MemberTypeCell(params: GridRenderCellParams) { @@ -138,8 +153,12 @@ export function MemberTypeCell(params: GridRenderCellParams) { export function ActionsCell( props: GridRenderCellParams, ) { - const { memberListDeletionState, memberListAbilities, removeMembers } = - useContext(MemberListContext); + const { + memberListDeletionState, + memberListAbilities, + removeMembers, + onUpdateMemberStatus, + } = useContext(MemberListContext); const deleting = Array.isArray(removeMembers?.variables?.ids) ? removeMembers?.variables?.ids.includes(props.row.userId) @@ -147,9 +166,32 @@ export function ActionsCell( const session = authClient.useSession(); const self = props.row.userId === session.data?.user.id; + const isRequested = props.row.memberType === 'Requested'; return ( -
+
+ {isRequested && onUpdateMemberStatus && ( + <> + + onUpdateMemberStatus(props.row.userId, 'Member')} + > + + + + + onUpdateMemberStatus(props.row.userId, 'Follower')} + > + + + + + )} {memberListAbilities.removeUsers && ( (null); const exportMenuOpen = Boolean(exportMenuAnchorEl); + const [showOnlyMembers, setShowOnlyMembers] = useState(false); + + const handleToggleShowOnlyMembers = () => { + const newValue = !showOnlyMembers; + setShowOnlyMembers(newValue); + if (newValue) { + apiRef.current.setFilterModel({ + items: [ + { + field: 'memberType', + operator: 'isAnyOf', + value: ['Member', 'Admin', 'Collaborator', 'Requested'], + }, + ], + }); + } else { + apiRef.current.setFilterModel({ items: [] }); + } + }; + return ( - {'Club Followers for ' + club.name} + {'Members & Followers for ' + club.name} - Club Followers + Members & Followers )}
+ + {memberListAbilities.refresh && ( { z.infer >(api.club.edit.removeMembers.mutationOptions({})); + const updateMemberStatus = useMutation( + api.club.updateMemberStatus.mutationOptions({}), + ); + // For refresh button const getMembers = useQuery( api.club.getMembers.queryOptions({ id: club.id }, { enabled: false }), @@ -218,15 +222,26 @@ const MemberList = ({ members, club }: MemberListProps) => { }); }, [getMembers]); + const handleUpdateMemberStatus = useCallback( + (userId: string, newStatus: 'Follower' | 'Member') => { + updateMemberStatus.mutate( + { clubId: club.id, userId, newStatus }, + { onSuccess: () => void refreshList() }, + ); + }, + [updateMemberStatus, club.id, refreshList], + ); + /* * Abilities */ const self = rows.find((row) => row.userId === session.data?.user.id); - const isAdmin = - rows.find((row) => row.userId === session.data?.user.id)?.memberType === - 'President'; + const selfMemberType = self?.memberType; + const isAdmin = selfMemberType === 'President'; + const isOfficerOrAdmin = + selfMemberType === 'Officer' || selfMemberType === 'President'; const memberListAbilities: MemberListAbilities = useMemo(() => { return { @@ -237,8 +252,10 @@ const MemberList = ({ members, club }: MemberListProps) => { }; }, [isAdmin]); - // Shows action column only if user is an admin - const actionedColumns = isAdmin ? [...columns, actionColumn] : columns; + // Shows action column if user is an officer or admin (for approve/deny and removal) + const actionedColumns = isOfficerOrAdmin + ? [...columns, actionColumn] + : columns; /* * Context @@ -256,6 +273,8 @@ const MemberList = ({ members, club }: MemberListProps) => { refreshList, rowSelectionModel, selfRowId: self?.id, + clubId: club.id, + onUpdateMemberStatus: handleUpdateMemberStatus, }), [ memberListDeletionState, @@ -267,6 +286,8 @@ const MemberList = ({ members, club }: MemberListProps) => { refreshList, rowSelectionModel, self?.id, + club.id, + handleUpdateMemberStatus, ], ); diff --git a/src/components/manage/MemberList/MemberListContext.tsx b/src/components/manage/MemberList/MemberListContext.tsx index a7245c37e..3041927b3 100644 --- a/src/components/manage/MemberList/MemberListContext.tsx +++ b/src/components/manage/MemberList/MemberListContext.tsx @@ -33,6 +33,11 @@ export interface MemberListContextType { refreshList: () => void; rowSelectionModel: GridRowSelectionModel; selfRowId: GridRowId | undefined; + clubId?: string; + onUpdateMemberStatus?: ( + userId: string, + newStatus: 'Follower' | 'Member', + ) => void; } export const MemberListContext = createContext({ @@ -53,4 +58,6 @@ export const MemberListContext = createContext({ ids: new Set(), }, selfRowId: undefined, + clubId: undefined, + onUpdateMemberStatus: undefined, }); diff --git a/src/components/manage/MemberList/utils.tsx b/src/components/manage/MemberList/utils.tsx index 16885fc7d..bd8b57b33 100644 --- a/src/components/manage/MemberList/utils.tsx +++ b/src/components/manage/MemberList/utils.tsx @@ -26,7 +26,7 @@ import { export const defaultUserSort = ( a: SelectUserMetadataToClubsWithUserMetadataWithUser, b: SelectUserMetadataToClubsWithUserMetadataWithUser, -) => b.joinedAt.getTime() - a.joinedAt.getTime(); +) => (b.joinedAt?.getTime() ?? 0) - (a.joinedAt?.getTime() ?? 0); /** * Wrapper function for {@linkcode formatListString()} that takes in a list of users and returns the users' first names formatted as a list. @@ -236,6 +236,10 @@ export const columns: GridColDef , resizable: false, }; diff --git a/src/components/manage/MemberRoleChip.tsx b/src/components/manage/MemberRoleChip.tsx index da725ae65..e6d418184 100644 --- a/src/components/manage/MemberRoleChip.tsx +++ b/src/components/manage/MemberRoleChip.tsx @@ -1,5 +1,7 @@ import GavelIcon from '@mui/icons-material/Gavel'; +import GroupIcon from '@mui/icons-material/Group'; import HandymanIcon from '@mui/icons-material/Handyman'; +import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty'; import PersonIcon from '@mui/icons-material/Person'; import { SvgIconOwnProps } from '@mui/material'; import Chip, { ChipProps } from '@mui/material/Chip'; @@ -18,11 +20,21 @@ export type ChipStyles = Record< >; export const chipStyles: ChipStyles = { - Member: { + Follower: { label: 'Follower', colorClass: undefined, icon: , }, + Member: { + label: 'Member', + colorClass: 'bg-emerald-200 dark:bg-emerald-600/30', + icon: , + }, + Requested: { + label: 'Requested', + colorClass: 'bg-amber-200 dark:bg-amber-600/30', + icon: , + }, Officer: { label: 'Collaborator', colorClass: 'bg-royal/30 dark:bg-cornflower-300/30', diff --git a/src/components/settings/forms/JoinedClubs.tsx b/src/components/settings/forms/JoinedClubs.tsx index d5f5d68ef..f80dbdf2c 100644 --- a/src/components/settings/forms/JoinedClubs.tsx +++ b/src/components/settings/forms/JoinedClubs.tsx @@ -57,11 +57,10 @@ export default function JoinedClubs({ joinedClubs }: ClubsProps) { title={`Unfollow ${leaveClub?.club.name}?`} contentText={ <> - You followed this club{' '} - {leaveClub && - formatDistanceStrict(leaveClub?.joinedAt, new Date(), { - addSuffix: true, - })} + You followed this club + {leaveClub?.joinedAt + ? ` ${formatDistanceStrict(leaveClub.joinedAt, new Date(), { addSuffix: true })}` + : ''} .
This action cannot be undone. @@ -131,12 +130,12 @@ function ClubListItem({ joinedClub, onLeave }: ClubListItemProps) {
{club.name} - {joinedClub && ( + {joinedClub?.joinedAt && ( - {`Following since ${joinedClub?.joinedAt.toLocaleString( + {`Member since ${joinedClub.joinedAt.toLocaleString( 'en-us', { month: 'short', diff --git a/src/server/api/routers/admin.ts b/src/server/api/routers/admin.ts index 94fd25673..2fb8e0c3a 100644 --- a/src/server/api/routers/admin.ts +++ b/src/server/api/routers/admin.ts @@ -66,7 +66,7 @@ export const adminRouter = createTRPCRouter({ input.deleted.map((officer) => ({ userId: officer, clubId: input.clubId, - memberType: 'Member' as const, + memberType: 'Follower' as const, })), ) .onConflictDoUpdate({ @@ -111,7 +111,11 @@ export const adminRouter = createTRPCRouter({ .onConflictDoUpdate({ target: [userMetadataToClubs.userId, userMetadataToClubs.clubId], set: { memberType: 'Officer' as const }, - where: eq(userMetadataToClubs.memberType, 'Member'), + where: inArray(userMetadataToClubs.memberType, [ + 'Member', + 'Follower', + 'Requested', + ]), }); } diff --git a/src/server/api/routers/club.ts b/src/server/api/routers/club.ts index 1b495a467..f6b98bb81 100644 --- a/src/server/api/routers/club.ts +++ b/src/server/api/routers/club.ts @@ -189,6 +189,7 @@ export const clubRouter = createTRPCRouter({ where: and( eq(userMetadataToClubs.userId, ctx.session.user.id), inArray(userMetadataToClubs.memberType, [ + 'Follower', 'Member', 'Officer', 'President', @@ -204,6 +205,7 @@ export const clubRouter = createTRPCRouter({ where: and( eq(userMetadataToClubs.userId, ctx.session.user.id), inArray(userMetadataToClubs.memberType, [ + 'Follower', 'Member', 'Officer', 'President', @@ -245,11 +247,6 @@ export const clubRouter = createTRPCRouter({ where: and( eq(userMetadataToClubs.clubId, input.id), eq(userMetadataToClubs.userId, ctx.session.user.id), - inArray(userMetadataToClubs.memberType, [ - 'Member', - 'Officer', - 'President', - ]), ), }) )?.memberType ?? null @@ -264,11 +261,6 @@ export const clubRouter = createTRPCRouter({ where: and( eq(userMetadataToClubs.clubId, input.id), eq(userMetadataToClubs.userId, ctx.session.user.id), - inArray(userMetadataToClubs.memberType, [ - 'Member', - 'Officer', - 'President', - ]), ), }); return { @@ -276,6 +268,15 @@ export const clubRouter = createTRPCRouter({ joinedAt: result?.joinedAt ?? null, }; }), + membershipPolicy: publicProcedure + .input(byIdSchema) + .query(async ({ input, ctx }) => { + const clubData = await ctx.db.query.club.findFirst({ + where: eq(club.id, input.id), + columns: { membershipPolicy: true }, + }); + return clubData?.membershipPolicy ?? 'open'; + }), joinLeave: protectedProcedure .input(joinLeaveSchema) .mutation(async ({ ctx, input }) => { @@ -288,7 +289,11 @@ export const clubRouter = createTRPCRouter({ eq(userMetadataToClubs.clubId, clubId), ), }); - if (dataExists && dataExists.memberType == 'Member') { + if ( + dataExists && + (dataExists.memberType === 'Member' || + dataExists.memberType === 'Follower') + ) { await ctx.db .delete(userMetadataToClubs) .where( @@ -297,13 +302,187 @@ export const clubRouter = createTRPCRouter({ eq(userMetadataToClubs.clubId, clubId), ), ); - } else { - await ctx.db - .insert(userMetadataToClubs) - .values({ userId: joinUserId, clubId, joinedAt: new Date() }); + } else if (!dataExists) { + await ctx.db.insert(userMetadataToClubs).values({ + userId: joinUserId, + clubId, + memberType: 'Follower', + }); } return dataExists; }), + follow: protectedProcedure + .input(joinLeaveSchema) + .mutation(async ({ ctx, input }) => { + const userId = ctx.session.user.id; + const existing = await ctx.db.query.userMetadataToClubs.findFirst({ + where: and( + eq(userMetadataToClubs.userId, userId), + eq(userMetadataToClubs.clubId, input.clubId), + ), + }); + if (existing) { + throw new TRPCError({ + code: 'CONFLICT', + message: 'Already connected to this club', + }); + } + await ctx.db.insert(userMetadataToClubs).values({ + userId, + clubId: input.clubId, + memberType: 'Follower', + }); + return { memberType: 'Follower' as const }; + }), + unfollow: protectedProcedure + .input(joinLeaveSchema) + .mutation(async ({ ctx, input }) => { + const userId = ctx.session.user.id; + const existing = await ctx.db.query.userMetadataToClubs.findFirst({ + where: and( + eq(userMetadataToClubs.userId, userId), + eq(userMetadataToClubs.clubId, input.clubId), + ), + }); + if ( + !existing || + existing.memberType === 'Officer' || + existing.memberType === 'President' + ) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Cannot unfollow as an officer or admin', + }); + } + await ctx.db + .delete(userMetadataToClubs) + .where( + and( + eq(userMetadataToClubs.userId, userId), + eq(userMetadataToClubs.clubId, input.clubId), + ), + ); + return { memberType: null }; + }), + join: protectedProcedure + .input(joinLeaveSchema) + .mutation(async ({ ctx, input }) => { + const userId = ctx.session.user.id; + const clubData = await ctx.db.query.club.findFirst({ + where: eq(club.id, input.clubId), + columns: { membershipPolicy: true }, + }); + if (!clubData || clubData.membershipPolicy !== 'open') { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'This club does not allow direct joining', + }); + } + await ctx.db + .update(userMetadataToClubs) + .set({ memberType: 'Member', joinedAt: new Date() }) + .where( + and( + eq(userMetadataToClubs.userId, userId), + eq(userMetadataToClubs.clubId, input.clubId), + eq(userMetadataToClubs.memberType, 'Follower'), + ), + ); + return { memberType: 'Member' as const }; + }), + leave: protectedProcedure + .input(joinLeaveSchema) + .mutation(async ({ ctx, input }) => { + const userId = ctx.session.user.id; + await ctx.db + .update(userMetadataToClubs) + .set({ memberType: 'Follower' }) + .where( + and( + eq(userMetadataToClubs.userId, userId), + eq(userMetadataToClubs.clubId, input.clubId), + eq(userMetadataToClubs.memberType, 'Member'), + ), + ); + return { memberType: 'Follower' as const }; + }), + requestJoin: protectedProcedure + .input(joinLeaveSchema) + .mutation(async ({ ctx, input }) => { + const userId = ctx.session.user.id; + const clubData = await ctx.db.query.club.findFirst({ + where: eq(club.id, input.clubId), + columns: { membershipPolicy: true }, + }); + if (!clubData || clubData.membershipPolicy !== 'request') { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'This club does not accept join requests', + }); + } + await ctx.db + .update(userMetadataToClubs) + .set({ memberType: 'Requested' }) + .where( + and( + eq(userMetadataToClubs.userId, userId), + eq(userMetadataToClubs.clubId, input.clubId), + eq(userMetadataToClubs.memberType, 'Follower'), + ), + ); + return { memberType: 'Requested' as const }; + }), + cancelRequest: protectedProcedure + .input(joinLeaveSchema) + .mutation(async ({ ctx, input }) => { + const userId = ctx.session.user.id; + await ctx.db + .update(userMetadataToClubs) + .set({ memberType: 'Follower' }) + .where( + and( + eq(userMetadataToClubs.userId, userId), + eq(userMetadataToClubs.clubId, input.clubId), + eq(userMetadataToClubs.memberType, 'Requested'), + ), + ); + return { memberType: 'Follower' as const }; + }), + updateMemberStatus: protectedProcedure + .input( + z.object({ + clubId: z.string(), + userId: z.string(), + newStatus: z.enum(['Follower', 'Member']), + }), + ) + .mutation(async ({ ctx, input }) => { + const isOfficer = await ctx.db.query.userMetadataToClubs.findFirst({ + where: and( + eq(userMetadataToClubs.clubId, input.clubId), + eq(userMetadataToClubs.userId, ctx.session.user.id), + inArray(userMetadataToClubs.memberType, ['Officer', 'President']), + ), + }); + if (!isOfficer) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + await ctx.db + .update(userMetadataToClubs) + .set({ memberType: input.newStatus }) + .where( + and( + eq(userMetadataToClubs.userId, input.userId), + eq(userMetadataToClubs.clubId, input.clubId), + inArray(userMetadataToClubs.memberType, [ + 'Follower', + 'Member', + 'Requested', + ]), + ), + ); + return { success: true }; + }), create: protectedProcedure .input(createClubSchema) .mutation(async ({ input, ctx }) => { diff --git a/src/server/api/routers/clubEdit.ts b/src/server/api/routers/clubEdit.ts index 789bb9a1e..7236b2ecd 100644 --- a/src/server/api/routers/clubEdit.ts +++ b/src/server/api/routers/clubEdit.ts @@ -21,7 +21,7 @@ async function isUserOfficer(userId: string, clubId: string) { ), }); if (!officer || !officer.memberType) return false; - return officer.memberType !== 'Member'; + return officer.memberType === 'Officer' || officer.memberType === 'President'; } async function isUserPresident(userId: string, clubId: string) { @@ -269,7 +269,7 @@ export const clubEditRouter = createTRPCRouter({ input.deleted.map((officer) => ({ userId: officer, clubId: input.clubId, - memberType: 'Member' as const, + memberType: 'Follower' as const, })), ) .onConflictDoUpdate({ @@ -314,7 +314,10 @@ export const clubEditRouter = createTRPCRouter({ .onConflictDoUpdate({ target: [userMetadataToClubs.userId, userMetadataToClubs.clubId], set: { memberType: 'Officer' as const }, - where: eq(userMetadataToClubs.memberType, 'Member'), + where: inArray(userMetadataToClubs.memberType, [ + 'Member', + 'Follower', + ]), }); } @@ -590,6 +593,24 @@ export const clubEditRouter = createTRPCRouter({ }) .where(eq(club.id, input.id)); }), + updateMembershipPolicy: protectedProcedure + .input( + z.object({ + clubId: z.string(), + policy: z.enum(['open', 'request', 'closed']), + }), + ) + .mutation(async ({ ctx, input }) => { + const isOfficer = await isUserOfficer(ctx.session.user.id, input.clubId); + if (!isOfficer) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + await ctx.db + .update(club) + .set({ membershipPolicy: input.policy }) + .where(eq(club.id, input.clubId)); + return { success: true }; + }), removeMembers: protectedProcedure .input(removeMembersSchema) .mutation(async ({ input, ctx }) => { diff --git a/src/server/api/routers/userMetadata.ts b/src/server/api/routers/userMetadata.ts index d6c951187..73f238dcb 100644 --- a/src/server/api/routers/userMetadata.ts +++ b/src/server/api/routers/userMetadata.ts @@ -166,6 +166,7 @@ export const userMetadataRouter = createTRPCRouter({ and( eq(userMetadataToClubs.userId, ctx.session.user.id), inArray(userMetadataToClubs.memberType, [ + 'Follower', 'Member', 'Officer', 'President', @@ -203,6 +204,7 @@ export const userMetadataRouter = createTRPCRouter({ and( eq(userMetadataToClubs.userId, ctx.session.user.id), inArray(userMetadataToClubs.memberType, [ + 'Follower', 'Member', 'Officer', 'President', diff --git a/src/server/db/migrations/20260328000000_club-membership-policy.sql b/src/server/db/migrations/20260328000000_club-membership-policy.sql new file mode 100644 index 000000000..821b54ec4 --- /dev/null +++ b/src/server/db/migrations/20260328000000_club-membership-policy.sql @@ -0,0 +1,12 @@ +-- Add new values to the member_type enum +ALTER TYPE "member_type" ADD VALUE IF NOT EXISTS 'Follower'; +ALTER TYPE "member_type" ADD VALUE IF NOT EXISTS 'Requested'; + +-- Create the membership_policy enum +CREATE TYPE "membership_policy" AS ENUM ('open', 'request', 'closed'); + +-- Add membership_policy column to club table +ALTER TABLE "club" ADD COLUMN "membership_policy" "membership_policy" DEFAULT 'open' NOT NULL; + +-- Convert all existing 'Member' rows to 'Follower' +UPDATE "user_metadata_to_clubs" SET "member_type" = 'Follower' WHERE "member_type" = 'Member'; diff --git a/src/server/db/migrations/20260406000000_nullable-joined-at.sql b/src/server/db/migrations/20260406000000_nullable-joined-at.sql new file mode 100644 index 000000000..59adfdd55 --- /dev/null +++ b/src/server/db/migrations/20260406000000_nullable-joined-at.sql @@ -0,0 +1,6 @@ +-- Make joined_at nullable: it now represents when a user became a Member, not when they started following +ALTER TABLE "user_metadata_to_clubs" ALTER COLUMN "joined_at" DROP NOT NULL; +ALTER TABLE "user_metadata_to_clubs" ALTER COLUMN "joined_at" DROP DEFAULT; + +-- Clear joined_at for existing Followers and Requested since the timestamp was follow time, not join time +UPDATE "user_metadata_to_clubs" SET "joined_at" = NULL WHERE "member_type" IN ('Follower', 'Requested'); diff --git a/src/server/db/schema/club.ts b/src/server/db/schema/club.ts index 2ca553ea6..122a166ee 100644 --- a/src/server/db/schema/club.ts +++ b/src/server/db/schema/club.ts @@ -24,6 +24,12 @@ export const approvedEnum = pgEnum('approved_enum', [ 'deleted', ]); +export const membershipPolicyEnum = pgEnum('membership_policy', [ + 'open', + 'request', + 'closed', +]); + export const clubSizeEnum = pgEnum('club_size', [ '1-10', '10-50', @@ -64,6 +70,9 @@ export const club = pgTable( () => user.id, { onDelete: 'set null' }, ), + membershipPolicy: membershipPolicyEnum('membership_policy') + .notNull() + .default('open'), }, (t) => [ index('club_search_idx') diff --git a/src/server/db/schema/users.ts b/src/server/db/schema/users.ts index 000e939ea..4ddb0ca64 100644 --- a/src/server/db/schema/users.ts +++ b/src/server/db/schema/users.ts @@ -26,6 +26,8 @@ export const clubRoleEnum = pgEnum('member_type', [ 'President', 'Officer', 'Member', + 'Follower', + 'Requested', ]); export const userMetadata = pgTable('user_metadata', { @@ -51,9 +53,9 @@ export const userMetadataToClubs = pgTable( .notNull() .references(() => club.id, { onDelete: 'cascade' }), memberType: clubRoleEnum('member_type') - .$default(() => 'Member') + .$default(() => 'Follower') .notNull(), - joinedAt: timestamp('joined_at').defaultNow().notNull(), + joinedAt: timestamp('joined_at'), }, (t) => [primaryKey({ columns: [t.userId, t.clubId] })], );