feat(groups): S2 frontend - request join, invite, roles, my groups, MSW handlers
This commit is contained in:
parent
7ca8d14283
commit
d2a55b405e
12 changed files with 489 additions and 53 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -317,6 +317,7 @@ export interface SocialGroup {
|
|||
members: number;
|
||||
isPrivate: boolean;
|
||||
userRole: 'admin' | 'mod' | 'member' | 'none';
|
||||
hasPendingRequest?: boolean;
|
||||
description: string;
|
||||
coverUrl: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("")
|
||||
|
|
|
|||
|
|
@ -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{}).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue