From 77550a8a7617856ce95d2f57e7541a4e9eecb286 Mon Sep 17 00:00:00 2001 From: bryan Date: Sun, 21 Jun 2026 22:06:55 +0100 Subject: [PATCH] Add admin matchmaker console --- app/[locale]/admin/actions.ts | 224 +++++++++++++++++++++++ app/[locale]/admin/page.tsx | 331 +++++++++++++++++++++++++++++++++- lib/admin/matchmaker.ts | 156 ++++++++++++++++ 3 files changed, 708 insertions(+), 3 deletions(-) create mode 100644 lib/admin/matchmaker.ts diff --git a/app/[locale]/admin/actions.ts b/app/[locale]/admin/actions.ts index a5c69eb..bf7891a 100644 --- a/app/[locale]/admin/actions.ts +++ b/app/[locale]/admin/actions.ts @@ -8,7 +8,10 @@ import { canAccessAdminConsole } from '@/lib/admin/access'; import { requireUser } from '@/lib/auth'; import { getAppPolicySettingsForAdmin } from '@/lib/policy/app-policy'; import { DEFAULT_APP_POLICY_SETTINGS } from '@/lib/policy/defaults'; +import { APP_EVENTS } from '@/lib/logging/events'; +import { logAppEvent } from '@/lib/logging/logger'; import { createSupabaseAdminClient } from '@/lib/supabase/admin'; +import { generateInviteCode } from '@/lib/utils'; function parseInteger( formData: FormData, @@ -258,3 +261,224 @@ export async function updateAdminPolicySettingsAction(formData: FormData) { revalidatePath(`/${locale}/billing`); redirect(`/${locale}/admin?saved=1`); } + +function parseGroupDifficulty(value: FormDataEntryValue | null) { + return value === 'low' || value === 'high' ? value : 'medium'; +} + +function parseUserIds(formData: FormData) { + return [ + ...new Set( + formData + .getAll('memberUserIds') + .map((value) => String(value).trim()) + .filter(Boolean), + ), + ]; +} + +async function createUniqueInviteCode( + admin: ReturnType, +) { + for (let attempt = 0; attempt < 8; attempt += 1) { + const candidate = generateInviteCode(); + const { data: existing } = await admin + .schema('public') + .from('groups') + .select('id') + .eq('invite_code', candidate) + .maybeSingle(); + + if (!existing) { + return candidate; + } + } + + throw new Error('Unable to generate a unique invite code'); +} + +export async function composeAdminMatchmakerGroupAction(formData: FormData) { + const locale = (formData.get('locale') === 'fr' ? 'fr' : 'en') as AppLocale; + const user = await requireUser(locale); + + if (!canAccessAdminConsole(user.email)) { + throw new Error('Not authorized'); + } + + const admin = createSupabaseAdminClient(); + const groupId = String(formData.get('groupId') ?? '').trim(); + const groupName = String(formData.get('groupName') ?? '').trim(); + const difficultyLevel = parseGroupDifficulty(formData.get('difficultyLevel')); + const leaderUserId = String(formData.get('leaderUserId') ?? '').trim(); + const selectedUserIds = parseUserIds(formData); + const memberUserIds = [ + ...new Set([leaderUserId, ...selectedUserIds].filter(Boolean)), + ]; + const requestedMaxMembers = Number(formData.get('maxMembers')); + const maxMembers = Math.min( + Math.max( + Number.isFinite(requestedMaxMembers) ? Math.round(requestedMaxMembers) : 5, + memberUserIds.length, + 1, + ), + 6, + ); + + if (!groupName || !leaderUserId || memberUserIds.length === 0) { + redirect(`/${locale}/admin?matchmaker=missing#matchmaker`); + } + + if (memberUserIds.length > 6) { + redirect(`/${locale}/admin?matchmaker=too-many#matchmaker`); + } + + const { data: existingUsers, error: usersError } = await admin + .schema('public') + .from('users') + .select('id') + .in('id', memberUserIds); + + if (usersError) { + throw new Error(usersError.message); + } + + if ((existingUsers ?? []).length !== memberUserIds.length) { + redirect(`/${locale}/admin?matchmaker=invalid-user#matchmaker`); + } + + let resolvedGroupId = groupId; + let action: 'created' | 'updated' = 'updated'; + + if (!resolvedGroupId) { + const inviteCode = await createUniqueInviteCode(admin); + const { data: group, error: groupError } = await admin + .schema('public') + .from('groups') + .insert({ + name: groupName, + invite_code: inviteCode, + created_by: leaderUserId, + difficulty_level: difficultyLevel, + max_members: maxMembers, + }) + .select('id') + .single(); + + if (groupError || !group?.id) { + throw new Error(groupError?.message ?? 'Failed to create group'); + } + + resolvedGroupId = group.id; + action = 'created'; + } else { + const { data: activeSession, error: sessionError } = await admin + .schema('public') + .from('sessions') + .select('id') + .eq('group_id', resolvedGroupId) + .eq('status', 'active') + .limit(1) + .maybeSingle(); + + if (sessionError) { + throw new Error(sessionError.message); + } + + if (activeSession) { + redirect(`/${locale}/admin?matchmaker=active-session#matchmaker`); + } + + const { error: groupError } = await admin + .schema('public') + .from('groups') + .update({ + name: groupName, + created_by: leaderUserId, + difficulty_level: difficultyLevel, + max_members: maxMembers, + }) + .eq('id', resolvedGroupId); + + if (groupError) { + throw new Error(groupError.message); + } + } + + const { data: currentMembers, error: membersReadError } = await admin + .schema('public') + .from('group_members') + .select('user_id') + .eq('group_id', resolvedGroupId); + + if (membersReadError) { + throw new Error(membersReadError.message); + } + + const nextMemberSet = new Set(memberUserIds); + const usersToRemove = (currentMembers ?? []) + .map((member) => member.user_id) + .filter((userId) => !nextMemberSet.has(userId)); + + if (usersToRemove.length > 0) { + const { error: deleteError } = await admin + .schema('public') + .from('group_members') + .delete() + .eq('group_id', resolvedGroupId) + .in('user_id', usersToRemove); + + if (deleteError) { + throw new Error(deleteError.message); + } + } + + const { error: upsertError } = await admin + .schema('public') + .from('group_members') + .upsert( + memberUserIds.map((memberUserId) => ({ + group_id: resolvedGroupId, + user_id: memberUserId, + is_founder: memberUserId === leaderUserId, + })), + { onConflict: 'group_id,user_id' }, + ); + + if (upsertError) { + throw new Error(upsertError.message); + } + + await logAppEvent({ + eventName: 'admin_matchmaker_group_composed', + locale, + userId: user.id, + groupId: resolvedGroupId, + metadata: { + action, + member_count: memberUserIds.length, + max_members: maxMembers, + difficulty_level: difficultyLevel, + leader_user_id: leaderUserId, + }, + useAdmin: true, + }); + + await logAppEvent({ + eventName: + action === 'created' ? APP_EVENTS.groupCreated : APP_EVENTS.groupMemberAdded, + locale, + userId: user.id, + groupId: resolvedGroupId, + metadata: { + source: 'admin_matchmaker', + member_count: memberUserIds.length, + leader_user_id: leaderUserId, + }, + useAdmin: true, + }); + + revalidatePath(`/${locale}/admin`); + revalidatePath(`/${locale}/dashboard`); + revalidatePath(`/${locale}/groups`); + redirect(`/${locale}/admin?matchmaker=1&matchGroup=${resolvedGroupId}#matchmaker`); +} diff --git a/app/[locale]/admin/page.tsx b/app/[locale]/admin/page.tsx index e0bae87..5ed8adc 100644 --- a/app/[locale]/admin/page.tsx +++ b/app/[locale]/admin/page.tsx @@ -7,14 +7,23 @@ import { ArrowLeft } from 'lucide-react'; import { Link } from '@/i18n/navigation'; import type { AppLocale } from '@/i18n/routing'; import { canAccessAdminConsole } from '@/lib/admin/access'; +import { getAdminMatchmakerData } from '@/lib/admin/matchmaker'; import { requireUser } from '@/lib/auth'; import { getAppPolicySettingsForAdmin } from '@/lib/policy/app-policy'; -import { updateAdminPolicySettingsAction } from './actions'; +import { + composeAdminMatchmakerGroupAction, + updateAdminPolicySettingsAction, +} from './actions'; type AdminPolicyPageProps = { params: { locale: string }; - searchParams: { saved?: string }; + searchParams: { + saved?: string; + matchmaker?: string; + matchGroup?: string; + userSearch?: string; + }; }; export const dynamic = 'force-dynamic'; @@ -34,8 +43,28 @@ export default async function AdminPolicyPage({ notFound(); } - const policy = await getAppPolicySettingsForAdmin(); + const [policy, matchmakerData] = await Promise.all([ + getAppPolicySettingsForAdmin(), + getAdminMatchmakerData(searchParams.userSearch, searchParams.matchGroup), + ]); const copy = getCopy(locale); + const userById = new Map( + matchmakerData.users.map((matchmakerUser) => [ + matchmakerUser.id, + matchmakerUser, + ]), + ); + const selectedGroup = + matchmakerData.groups.find( + (group) => group.id === searchParams.matchGroup, + ) ?? null; + const selectedGroupMemberIds = new Set( + selectedGroup?.members.map((member) => member.userId) ?? [], + ); + const selectedLeaderId = + selectedGroup?.members.find((member) => member.isFounder)?.userId ?? + selectedGroup?.createdBy ?? + ''; return (
@@ -67,6 +96,193 @@ export default async function AdminPolicyPage({ ) : null} + {searchParams.matchmaker ? ( +
+ {getMatchmakerFeedback(copy, searchParams.matchmaker)} +
+ ) : null} + +
+
+
+
+

+ {copy.matchmakerEyebrow} +

+

+ {copy.matchmakerTitle} +

+

+ {copy.matchmakerDescription} +

+
+ {selectedGroup ? ( + + {copy.newGroup} + + ) : null} +
+ +
+ + + +
+ + + +
+ +
+
+

+ {copy.members} +

+

+ {copy.membersHint} +

+
+
+ {matchmakerData.users.length} {copy.usersShown} +
+
+ +
+ {matchmakerData.users.map((matchmakerUser) => { + const isSelected = selectedGroupMemberIds.has(matchmakerUser.id); + const isLeader = selectedLeaderId === matchmakerUser.id; + + return ( + + ); + })} +
+ + +
+
+ + +
+
@@ -345,6 +561,57 @@ function TextField({ ); } +function UserAvatar({ + user, +}: { + user: { displayName: string | null; avatarUrl: string | null; email: string }; +}) { + const label = user.displayName ?? user.email; + const initials = label + .split(/\s|@/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join(''); + + if (user.avatarUrl) { + return ( + // eslint-disable-next-line @next/next/no-img-element + + ); + } + + return ( + + {initials || 'U'} + + ); +} + +function getMatchmakerFeedback( + copy: ReturnType, + value: string, +) { + switch (value) { + case '1': + return copy.matchmakerSaved; + case 'missing': + return copy.matchmakerMissing; + case 'too-many': + return copy.matchmakerTooMany; + case 'invalid-user': + return copy.matchmakerInvalidUser; + case 'active-session': + return copy.matchmakerActiveSession; + default: + return copy.matchmakerSaved; + } +} + function getCopy(locale: AppLocale) { if (locale === 'fr') { return { @@ -380,6 +647,35 @@ function getCopy(locale: AppLocale) { maxTimer: 'Timer max', completionMin: 'Complétion min', completionMax: 'Complétion max', + matchmakerEyebrow: 'Match-maker', + matchmakerTitle: 'Composition des groupes', + matchmakerDescription: + 'Crée ou ajuste les groupes depuis la console administrateur. Choisis les membres, puis désigne le leader du groupe.', + newGroup: 'Nouveau groupe', + groupName: 'Nom du groupe', + capacity: 'Capacité', + difficulty: 'Niveau', + difficultyLow: 'Faible', + difficultyMedium: 'Moyen', + difficultyHigh: 'Élevé', + members: 'Membres', + membersHint: + 'Le leader est automatiquement inclus dans le groupe. Maximum 6 membres.', + usersShown: 'utilisateurs affichés', + leader: 'Leader', + unnamedUser: 'Utilisateur sans nom', + createGroup: 'Créer le groupe', + updateGroup: 'Mettre à jour le groupe', + searchUsers: 'Rechercher', + searchPlaceholder: 'Nom ou e-mail', + currentGroups: 'Groupes actuels', + notSet: 'non défini', + matchmakerSaved: 'Composition du groupe enregistrée.', + matchmakerMissing: 'Ajoute un nom, au moins un membre et un leader.', + matchmakerTooMany: 'Un groupe ne peut pas dépasser 6 membres.', + matchmakerInvalidUser: 'Un utilisateur sélectionné est introuvable.', + matchmakerActiveSession: + 'Impossible de modifier ce groupe pendant une session active.', }; } @@ -416,5 +712,34 @@ function getCopy(locale: AppLocale) { maxTimer: 'Max timer', completionMin: 'Completion min', completionMax: 'Completion max', + matchmakerEyebrow: 'Match-maker', + matchmakerTitle: 'Group composition', + matchmakerDescription: + 'Create or adjust groups from the admin console. Choose members, then assign the group leader.', + newGroup: 'New group', + groupName: 'Group name', + capacity: 'Capacity', + difficulty: 'Level', + difficultyLow: 'Low', + difficultyMedium: 'Medium', + difficultyHigh: 'High', + members: 'Members', + membersHint: + 'The leader is automatically included in the group. Maximum 6 members.', + usersShown: 'users shown', + leader: 'Leader', + unnamedUser: 'Unnamed user', + createGroup: 'Create group', + updateGroup: 'Update group', + searchUsers: 'Search', + searchPlaceholder: 'Name or email', + currentGroups: 'Current groups', + notSet: 'not set', + matchmakerSaved: 'Group composition saved.', + matchmakerMissing: 'Add a name, at least one member, and a leader.', + matchmakerTooMany: 'A group cannot exceed 6 members.', + matchmakerInvalidUser: 'One selected user could not be found.', + matchmakerActiveSession: + 'This group cannot be changed during an active session.', }; } diff --git a/lib/admin/matchmaker.ts b/lib/admin/matchmaker.ts new file mode 100644 index 0000000..26d7747 --- /dev/null +++ b/lib/admin/matchmaker.ts @@ -0,0 +1,156 @@ +import { createSupabaseAdminClient } from '@/lib/supabase/admin'; + +export type AdminMatchmakerUser = { + id: string; + email: string; + displayName: string | null; + avatarUrl: string | null; + questionsAnswered: number; + createdAt: string; +}; + +export type AdminMatchmakerGroup = { + id: string; + name: string; + maxMembers: number; + difficultyLevel: 'low' | 'medium' | 'high'; + createdAt: string; + createdBy: string | null; + inviteCode: string; + members: Array<{ + userId: string; + isFounder: boolean; + joinedAt: string; + }>; +}; + +export type AdminMatchmakerData = { + users: AdminMatchmakerUser[]; + groups: AdminMatchmakerGroup[]; +}; + +function normalizeSearch(value: string | undefined) { + return value?.trim().toLowerCase() ?? ''; +} + +function matchesUserSearch(user: AdminMatchmakerUser, search: string) { + if (!search) { + return true; + } + + return ( + user.email.toLowerCase().includes(search) || + (user.displayName ?? '').toLowerCase().includes(search) + ); +} + +export async function getAdminMatchmakerData( + search?: string, + selectedGroupId?: string, +): Promise { + const admin = createSupabaseAdminClient(); + const normalizedSearch = normalizeSearch(search); + + const [usersResult, groupsResult, membersResult] = await Promise.all([ + admin + .schema('public') + .from('users') + .select('id, email, display_name, avatar_url, questions_answered, created_at') + .order('created_at', { ascending: false }) + .limit(500), + admin + .schema('public') + .from('groups') + .select('id, name, max_members, difficulty_level, created_at, created_by, invite_code') + .order('created_at', { ascending: false }) + .limit(200), + admin + .schema('public') + .from('group_members') + .select('group_id, user_id, is_founder, joined_at'), + ]); + + if (usersResult.error) { + throw new Error(usersResult.error.message); + } + if (groupsResult.error) { + throw new Error(groupsResult.error.message); + } + if (membersResult.error) { + throw new Error(membersResult.error.message); + } + + const selectedGroupUserIds = [ + ...new Set( + (membersResult.data ?? []) + .filter((membership) => membership.group_id === selectedGroupId) + .map((membership) => membership.user_id), + ), + ]; + const loadedUserIds = new Set((usersResult.data ?? []).map((user) => user.id)); + const missingSelectedUserIds = selectedGroupUserIds.filter( + (userId) => !loadedUserIds.has(userId), + ); + + const selectedUsersResult = + missingSelectedUserIds.length > 0 + ? await admin + .schema('public') + .from('users') + .select( + 'id, email, display_name, avatar_url, questions_answered, created_at', + ) + .in('id', missingSelectedUserIds) + : { data: [], error: null }; + + if (selectedUsersResult.error) { + throw new Error(selectedUsersResult.error.message); + } + + const users = [ + ...(usersResult.data ?? []), + ...(selectedUsersResult.data ?? []), + ].map((user) => ({ + id: user.id, + email: user.email, + displayName: user.display_name, + avatarUrl: user.avatar_url, + questionsAnswered: user.questions_answered ?? 0, + createdAt: user.created_at, + })); + + const membershipsByGroup = new Map< + string, + AdminMatchmakerGroup['members'] + >(); + + for (const membership of membersResult.data ?? []) { + const current = membershipsByGroup.get(membership.group_id) ?? []; + current.push({ + userId: membership.user_id, + isFounder: Boolean(membership.is_founder), + joinedAt: membership.joined_at, + }); + membershipsByGroup.set(membership.group_id, current); + } + + const groups = (groupsResult.data ?? []).map((group) => ({ + id: group.id, + name: group.name, + maxMembers: group.max_members, + difficultyLevel: group.difficulty_level ?? 'medium', + createdAt: group.created_at, + createdBy: group.created_by, + inviteCode: group.invite_code, + members: membershipsByGroup.get(group.id) ?? [], + })); + + return { + users: users.filter( + (user) => + selectedGroupUserIds.includes(user.id) || + matchesUserSearch(user, normalizedSearch), + ), + groups, + }; +}