feat(groups): S2 frontend - request join, invite, roles, my groups, MSW handlers

This commit is contained in:
senke 2026-02-21 05:51:29 +01:00
parent 7ca8d14283
commit d2a55b405e
12 changed files with 489 additions and 53 deletions

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { Button } from '../../ui/button';
import { SearchInput } from '../../ui/input';
import { Plus, Compass, Users, Loader2 } from 'lucide-react';
@ -23,14 +23,13 @@ export const GroupsView: React.FC<GroupsViewProps> = ({ onOpenGroup }) => {
const [groups, setGroups] = useState<SocialGroup[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadGroups();
}, []);
const loadGroups = async () => {
const loadGroups = useCallback(async () => {
try {
setLoading(true);
const res = await groupService.list();
const res =
activeTab === 'my_groups'
? await groupService.listMine()
: await groupService.list();
setGroups(res.groups);
} catch (e) {
logger.error('Failed to load groups', {
@ -41,7 +40,11 @@ export const GroupsView: React.FC<GroupsViewProps> = ({ onOpenGroup }) => {
} finally {
setLoading(false);
}
};
}, [activeTab]);
useEffect(() => {
loadGroups();
}, [loadGroups]);
const handleCreate = async (data: any) => {
try {
@ -83,12 +86,9 @@ export const GroupsView: React.FC<GroupsViewProps> = ({ onOpenGroup }) => {
}
};
const filteredGroups = groups.filter((g) => {
const matchesSearch = g.name.toLowerCase().includes(search.toLowerCase());
if (activeTab === 'my_groups')
return matchesSearch && g.userRole !== 'none';
return matchesSearch && g.userRole === 'none'; // Simple 'discover' logic
});
const filteredGroups = groups.filter((g) =>
g.name.toLowerCase().includes(search.toLowerCase()),
);
return (
<div className="space-y-6 animate-fadeIn pb-20">

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import { Loader2 } from 'lucide-react';
import { FeedView } from '../../FeedView';
import { useGroupDetailView } from './useGroupDetailView';
@ -6,12 +6,15 @@ import { GroupDetailViewHeader } from './GroupDetailViewHeader';
import { GroupDetailViewMembers } from './GroupDetailViewMembers';
import { GroupDetailViewEvents } from './GroupDetailViewEvents';
import { GroupDetailViewSidebar } from './GroupDetailViewSidebar';
import { InviteMemberModal } from './InviteMemberModal';
import { JoinRequestsSection } from './JoinRequestsSection';
import type { GroupDetailViewProps } from './types';
export const GroupDetailView: React.FC<GroupDetailViewProps> = ({
groupId,
onBack,
}) => {
const [showInviteModal, setShowInviteModal] = useState(false);
const {
group,
loading,
@ -19,8 +22,14 @@ export const GroupDetailView: React.FC<GroupDetailViewProps> = ({
setActiveTab,
handleJoin,
handleLeave,
handleApproveRequest,
handleRejectRequest,
handleInvite,
joinRequests,
} = useGroupDetailView(groupId);
const handleInviteClose = () => setShowInviteModal(false);
if (loading) {
return (
<div className="flex justify-center py-24 min-h-layout-page-sm">
@ -46,16 +55,27 @@ export const GroupDetailView: React.FC<GroupDetailViewProps> = ({
onTabChange={setActiveTab}
onJoin={handleJoin}
onLeave={handleLeave}
onInvite={() => setShowInviteModal(true)}
/>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
<div className="lg:col-span-8">
{activeTab === 'feed' && <FeedView />}
{activeTab === 'members' && (
<GroupDetailViewMembers
membersList={group.membersList}
totalMembers={group.members}
/>
<>
{(group.userRole === 'admin' || group.userRole === 'mod') &&
joinRequests.length > 0 && (
<JoinRequestsSection
requests={joinRequests}
onApprove={handleApproveRequest}
onReject={handleRejectRequest}
/>
)}
<GroupDetailViewMembers
membersList={group.membersList}
totalMembers={group.members}
/>
</>
)}
{activeTab === 'events' && (
<GroupDetailViewEvents events={group.events} />
@ -63,6 +83,13 @@ export const GroupDetailView: React.FC<GroupDetailViewProps> = ({
</div>
<GroupDetailViewSidebar membersList={group.membersList} />
</div>
{showInviteModal && handleInvite && (
<InviteMemberModal
onClose={handleInviteClose}
onInvite={handleInvite}
/>
)}
</div>
);
};

View file

@ -1,5 +1,5 @@
import { Button } from '@/components/ui/button';
import { ArrowLeft, Lock, Globe, Plus, LogOut, Settings } from 'lucide-react';
import { ArrowLeft, Lock, Globe, Plus, LogOut, Settings, UserPlus } from 'lucide-react';
import type { ExtendedGroup, GroupDetailTab } from './types';
interface GroupDetailViewHeaderProps {
@ -9,6 +9,7 @@ interface GroupDetailViewHeaderProps {
onTabChange: (tab: GroupDetailTab) => void;
onJoin: () => void;
onLeave: () => void;
onInvite?: () => void;
}
const TABS: GroupDetailTab[] = ['feed', 'members', 'events'];
@ -20,6 +21,7 @@ export function GroupDetailViewHeader({
onTabChange,
onJoin,
onLeave,
onInvite,
}: GroupDetailViewHeaderProps) {
return (
<div className="relative rounded-2xl overflow-hidden bg-muted border border-border">
@ -39,6 +41,16 @@ export function GroupDetailViewHeader({
</Button>
</div>
<div className="absolute bottom-4 right-4 flex gap-2">
{(group.userRole === 'admin' || group.userRole === 'mod') && onInvite && (
<Button
variant="secondary"
size="sm"
className="bg-black/50 backdrop-blur border-none hover:bg-black/70"
onClick={onInvite}
>
<UserPlus className="w-4 h-4 mr-2" /> Invite
</Button>
)}
{group.userRole === 'admin' && (
<Button
variant="secondary"
@ -57,9 +69,14 @@ export function GroupDetailViewHeader({
>
<LogOut className="w-4 h-4 mr-2" /> Leave
</Button>
) : group.hasPendingRequest ? (
<Button variant="secondary" size="sm" disabled>
Request pending
</Button>
) : (
<Button variant="primary" size="sm" onClick={onJoin}>
<Plus className="w-4 h-4 mr-2" /> Join Group
<Plus className="w-4 h-4 mr-2" />
{group.isPrivate ? 'Request to join' : 'Join Group'}
</Button>
)}
</div>

View file

@ -0,0 +1,58 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { SearchInput } from '@/components/ui/input';
import { X } from 'lucide-react';
interface InviteMemberModalProps {
onClose: () => void;
onInvite: (emailOrUserId: string) => Promise<void>;
}
export function InviteMemberModal({ onClose, onInvite }: InviteMemberModalProps) {
const [value, setValue] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = value.trim();
if (!trimmed) return;
setLoading(true);
try {
await onInvite(trimmed);
onClose();
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-card border border-border rounded-xl p-6 w-full max-w-md shadow-xl">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold">Invite member</h3>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="w-4 h-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground mb-4">
Enter email or user ID to invite someone to this group.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<SearchInput
placeholder="Email or user ID"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<div className="flex gap-2 justify-end">
<Button type="button" variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={loading || !value.trim()}>
{loading ? 'Sending...' : 'Send invitation'}
</Button>
</div>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,51 @@
import { Button } from '@/components/ui/button';
import type { JoinRequest } from './useGroupDetailView';
interface JoinRequestsSectionProps {
requests: JoinRequest[];
onApprove: (requestId: string) => void;
onReject: (requestId: string) => void;
}
export function JoinRequestsSection({
requests,
onApprove,
onReject,
}: JoinRequestsSectionProps) {
return (
<div className="mb-6 p-4 bg-card rounded-xl border border-border">
<h3 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-4">
Pending join requests
</h3>
<div className="space-y-2">
{requests.map((req) => (
<div
key={req.id}
className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"
>
<span className="text-sm font-mono text-muted-foreground">
User {req.user_id.slice(0, 8)}...
</span>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => onApprove(req.id)}
>
Approve
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive"
onClick={() => onReject(req.id)}
>
Reject
</Button>
</div>
</div>
))}
</div>
</div>
);
}

View file

@ -4,40 +4,72 @@ import { groupService } from '@/services/groupService';
import { logger } from '@/utils/logger';
import type { ExtendedGroup, GroupDetailTab } from './types';
export interface JoinRequest {
id: string;
user_id: string;
status: string;
created_at: string;
}
export function useGroupDetailView(groupId: string) {
const [group, setGroup] = useState<ExtendedGroup | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<GroupDetailTab>('feed');
const [joinRequests, setJoinRequests] = useState<JoinRequest[]>([]);
const loadGroup = useCallback(async () => {
setLoading(true);
try {
const res = await groupService.get(groupId);
setGroup({
...res.group,
membersList: res.membersList || [],
events: res.events || [],
} as ExtendedGroup);
} catch (e) {
logger.error('Failed to load group details', {
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
groupId,
});
toast.error('Failed to load group details');
} finally {
setLoading(false);
}
}, [groupId]);
const loadJoinRequests = useCallback(async () => {
if (!group || (group.userRole !== 'admin' && group.userRole !== 'mod'))
return;
try {
const requests = await groupService.listJoinRequests(groupId);
setJoinRequests(requests);
} catch {
setJoinRequests([]);
}
}, [groupId, group?.userRole]);
useEffect(() => {
const loadGroup = async () => {
setLoading(true);
try {
const res = await groupService.get(groupId);
setGroup({
...res.group,
membersList: res.membersList || [],
events: res.events || [],
} as ExtendedGroup);
} catch (e) {
logger.error('Failed to load group details', {
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
groupId,
});
toast.error('Failed to load group details');
} finally {
setLoading(false);
}
};
loadGroup();
}, [groupId]);
}, [loadGroup]);
useEffect(() => {
if (group && (group.userRole === 'admin' || group.userRole === 'mod')) {
loadJoinRequests();
}
}, [group?.userRole, group?.id, loadJoinRequests]);
const handleJoin = useCallback(async () => {
if (!group) return;
await groupService.join(group.id);
setGroup({ ...group, userRole: 'member', members: group.members + 1 });
toast.success('Joined group!');
if (group.isPrivate) {
await groupService.requestJoin(group.id);
setGroup({ ...group, hasPendingRequest: true });
toast.success('Request sent!');
} else {
await groupService.join(group.id);
setGroup({ ...group, userRole: 'member', members: group.members + 1 });
toast.success('Joined group!');
}
}, [group]);
const handleLeave = useCallback(async () => {
@ -47,6 +79,48 @@ export function useGroupDetailView(groupId: string) {
toast('Left group', { icon: '' });
}, [group]);
const handleApproveRequest = useCallback(
async (requestId: string) => {
if (!group) return;
await groupService.approveRequest(group.id, requestId);
setJoinRequests((r) => r.filter((x) => x.id !== requestId));
setGroup({ ...group, members: group.members + 1 });
toast.success('Request approved');
},
[group],
);
const handleRejectRequest = useCallback(
async (requestId: string) => {
if (!group) return;
await groupService.rejectRequest(group.id, requestId);
setJoinRequests((r) => r.filter((x) => x.id !== requestId));
toast('Request rejected', { icon: '' });
},
[group],
);
const handleInvite = useCallback(
async (emailOrUserId: string) => {
if (!group) return;
const isUuid = /^[0-9a-f-]{36}$/i.test(emailOrUserId);
await groupService.invite(group.id, {
[isUuid ? 'user_id' : 'email']: emailOrUserId,
});
toast.success('Invitation sent');
},
[group],
);
const handleUpdateMemberRole = useCallback(
async (userId: string, role: string) => {
if (!group) return;
await groupService.updateMemberRole(group.id, userId, role);
toast.success('Role updated');
},
[group],
);
return {
group,
loading,
@ -54,5 +128,12 @@ export function useGroupDetailView(groupId: string) {
setActiveTab,
handleJoin,
handleLeave,
handleApproveRequest,
handleRejectRequest,
handleInvite,
handleUpdateMemberRole,
joinRequests,
loadGroup,
loadJoinRequests,
};
}

View file

@ -137,19 +137,101 @@ export const handlersSocial = [
}),
http.get('*/api/v1/social/groups/:id', ({ params }) => {
const id = params.id as string;
return HttpResponse.json({
success: true,
data: {
id: params.id,
id,
name: 'Electronic Music Producers',
member_count: 1200,
is_public: true,
description: 'A community for electronic music producers',
avatar_url: 'https://picsum.photos/800/400',
user_status: {
is_member: true,
role: 'member',
has_pending_request: false,
},
},
});
}),
http.get('*/api/v1/social/groups/mine', () => {
return HttpResponse.json({
success: true,
data: {
groups: [
{
id: 'g1',
name: 'Electronic Music Producers',
member_count: 1200,
is_public: true,
description: 'A community for electronic music producers',
avatar_url: 'https://picsum.photos/800/400',
},
],
total: 1,
},
});
}),
http.post('*/api/v1/social/groups/:id/request', () => {
return HttpResponse.json(
{
success: true,
data: { id: 'req-1', message: 'Request sent' },
},
{ status: 201 }
);
}),
http.get('*/api/v1/social/groups/:id/requests', () => {
return HttpResponse.json({
success: true,
data: {
requests: [
{
id: 'req-1',
user_id: 'user-abc-123',
status: 'pending',
created_at: new Date().toISOString(),
},
],
},
});
}),
http.post('*/api/v1/social/groups/:id/requests/:requestId/approve', () => {
return HttpResponse.json({
success: true,
data: { message: 'Request approved' },
});
}),
http.post('*/api/v1/social/groups/:id/requests/:requestId/reject', () => {
return HttpResponse.json({
success: true,
data: { message: 'Request rejected' },
});
}),
http.post('*/api/v1/social/groups/:id/invite', () => {
return HttpResponse.json(
{
success: true,
data: { message: 'Invitation sent' },
},
{ status: 201 }
);
}),
http.put('*/api/v1/social/groups/:id/members/:userId/role', () => {
return HttpResponse.json({
success: true,
data: { message: 'Role updated' },
});
}),
http.post('*/api/v1/social/groups', async ({ request }) => {
const body = (await request.json()) as { name: string; description?: string; is_public?: boolean };
return HttpResponse.json(

View file

@ -10,13 +10,31 @@ interface ApiGroup {
member_count?: number;
}
function mapApiGroupToSocialGroup(g: ApiGroup, userRole: SocialGroup['userRole'] = 'none'): SocialGroup {
interface UserStatus {
is_member: boolean;
role: string;
has_pending_request: boolean;
}
function mapRoleToUserRole(role: string): SocialGroup['userRole'] {
if (role === 'admin') return 'admin';
if (role === 'moderator') return 'mod';
if (role === 'member') return 'member';
return 'none';
}
function mapApiGroupToSocialGroup(
g: ApiGroup,
userRole: SocialGroup['userRole'] = 'none',
hasPendingRequest = false,
): SocialGroup {
return {
id: g.id,
name: g.name,
members: g.member_count ?? 0,
isPrivate: !(g.is_public ?? true),
userRole,
userRole: hasPendingRequest ? 'none' : userRole,
hasPendingRequest,
description: g.description ?? '',
coverUrl: g.avatar_url ?? 'https://picsum.photos/id/10/800/400',
};
@ -35,11 +53,18 @@ export const groupService = {
},
get: async (id: string) => {
const response = await apiClient.get<ApiGroup>(`/social/groups/${id}`);
const response = await apiClient.get<ApiGroup & { user_status?: UserStatus }>(
`/social/groups/${id}`,
);
const g = response.data;
if (!g) throw new Error('Group not found');
const status = g.user_status;
const userRole = status?.is_member
? mapRoleToUserRole(status.role)
: 'none';
const hasPendingRequest = status?.has_pending_request ?? false;
return {
group: mapApiGroupToSocialGroup(g, 'member'),
group: mapApiGroupToSocialGroup(g, userRole, hasPendingRequest),
membersList: [],
events: [],
};
@ -65,4 +90,47 @@ export const groupService = {
await apiClient.delete(`/social/groups/${id}/leave`);
return { success: true };
},
requestJoin: async (id: string) => {
const response = await apiClient.post<{ id?: string; message?: string }>(
`/social/groups/${id}/request`,
);
return response.data;
},
listJoinRequests: async (id: string) => {
const response = await apiClient.get<{ requests: Array<{ id: string; user_id: string; status: string; created_at: string }> }>(
`/social/groups/${id}/requests`,
);
return response.data?.requests ?? [];
},
approveRequest: async (groupId: string, requestId: string) => {
await apiClient.post(`/social/groups/${groupId}/requests/${requestId}/approve`);
},
rejectRequest: async (groupId: string, requestId: string) => {
await apiClient.post(`/social/groups/${groupId}/requests/${requestId}/reject`);
},
invite: async (id: string, data: { email?: string; user_id?: string }) => {
await apiClient.post(`/social/groups/${id}/invite`, data);
},
updateMemberRole: async (groupId: string, userId: string, role: string) => {
await apiClient.put(`/social/groups/${groupId}/members/${userId}/role`, {
role,
});
},
listMine: async (): Promise<{ groups: SocialGroup[] }> => {
const response = await apiClient.get<{ groups: ApiGroup[]; total?: number }>(
'/social/groups/mine',
{ params: { limit: 50, offset: 0 } },
);
const items = response.data?.groups ?? [];
return {
groups: items.map((g) => mapApiGroupToSocialGroup(g, 'member')),
};
},
};

View file

@ -317,6 +317,7 @@ export interface SocialGroup {
members: number;
isPrivate: boolean;
userRole: 'admin' | 'mod' | 'member' | 'none';
hasPendingRequest?: boolean;
description: string;
coverUrl: string;
}

View file

@ -29,8 +29,10 @@ func (r *APIRouter) setupSocialRoutes(router *gin.RouterGroup) {
social.GET("/groups", groupHandler.ListGroups)
if r.config.AuthMiddleware != nil {
social.GET("/groups/mine", r.config.AuthMiddleware.RequireAuth(), groupHandler.ListMyGroups)
social.GET("/groups/:id", r.config.AuthMiddleware.OptionalAuth(), groupHandler.GetGroup)
} else {
social.GET("/groups/:id", groupHandler.GetGroup)
}
social.GET("/groups/:id", groupHandler.GetGroup)
if r.config.AuthMiddleware != nil {
protected := social.Group("")

View file

@ -424,6 +424,32 @@ func (s *Service) UpdateMemberRole(ctx context.Context, adminID, groupID, target
return nil
}
// GetGroupMemberStatus returns the current user's status in a group (for frontend)
func (s *Service) GetGroupMemberStatus(ctx context.Context, userID, groupID uuid.UUID) (isMember bool, role string, hasPendingRequest bool, err error) {
var member GroupMember
err = s.db.WithContext(ctx).
Where("group_id = ? AND user_id = ?", groupID, userID).
First(&member).Error
if err == nil {
return true, member.Role, false, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return false, "", false, err
}
var req GroupJoinRequest
err = s.db.WithContext(ctx).
Where("group_id = ? AND user_id = ? AND status = ?", groupID, userID, "pending").
First(&req).Error
if err == nil {
return false, "", true, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return false, "", false, err
}
return false, "", false, nil
}
// ListMyGroups returns groups the user is a member of (S2.5)
func (s *Service) ListMyGroups(ctx context.Context, userID uuid.UUID, limit, offset int) ([]Group, int64, error) {
subQuery := s.db.WithContext(ctx).Model(&GroupMember{}).

View file

@ -90,7 +90,7 @@ func (h *GroupHandler) ListGroups(c *gin.Context) {
})
}
// GetGroup returns a group by ID
// GetGroup returns a group by ID. When authenticated, includes user_status (is_member, role, has_pending_request).
func (h *GroupHandler) GetGroup(c *gin.Context) {
groupID, err := uuid.Parse(c.Param("id"))
if err != nil {
@ -108,7 +108,30 @@ func (h *GroupHandler) GetGroup(c *gin.Context) {
return
}
RespondSuccess(c, http.StatusOK, group)
resp := gin.H{
"id": group.ID,
"name": group.Name,
"description": group.Description,
"creator_id": group.CreatorID,
"avatar_url": group.AvatarURL,
"is_public": group.IsPublic,
"member_count": group.MemberCount,
"created_at": group.CreatedAt,
"updated_at": group.UpdatedAt,
}
if userID, ok := GetUserIDUUID(c); ok {
isMember, role, hasPendingRequest, err := h.service.GetGroupMemberStatus(c.Request.Context(), userID, groupID)
if err == nil {
resp["user_status"] = gin.H{
"is_member": isMember,
"role": role,
"has_pending_request": hasPendingRequest,
}
}
}
RespondSuccess(c, http.StatusOK, resp)
}
// JoinGroup adds the authenticated user to a group