diff --git a/apps/web/src/components/social/groups/GroupsView.tsx b/apps/web/src/components/social/groups/GroupsView.tsx index a4edf5b31..c1cd8ce5d 100644 --- a/apps/web/src/components/social/groups/GroupsView.tsx +++ b/apps/web/src/components/social/groups/GroupsView.tsx @@ -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 = ({ onOpenGroup }) => { const [groups, setGroups] = useState([]); 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 = ({ onOpenGroup }) => { } finally { setLoading(false); } - }; + }, [activeTab]); + + useEffect(() => { + loadGroups(); + }, [loadGroups]); const handleCreate = async (data: any) => { try { @@ -83,12 +86,9 @@ export const GroupsView: React.FC = ({ 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 (
diff --git a/apps/web/src/components/social/groups/group-detail-view/GroupDetailView.tsx b/apps/web/src/components/social/groups/group-detail-view/GroupDetailView.tsx index a6db19d35..20568c0f0 100644 --- a/apps/web/src/components/social/groups/group-detail-view/GroupDetailView.tsx +++ b/apps/web/src/components/social/groups/group-detail-view/GroupDetailView.tsx @@ -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 = ({ groupId, onBack, }) => { + const [showInviteModal, setShowInviteModal] = useState(false); const { group, loading, @@ -19,8 +22,14 @@ export const GroupDetailView: React.FC = ({ setActiveTab, handleJoin, handleLeave, + handleApproveRequest, + handleRejectRequest, + handleInvite, + joinRequests, } = useGroupDetailView(groupId); + const handleInviteClose = () => setShowInviteModal(false); + if (loading) { return (
@@ -46,16 +55,27 @@ export const GroupDetailView: React.FC = ({ onTabChange={setActiveTab} onJoin={handleJoin} onLeave={handleLeave} + onInvite={() => setShowInviteModal(true)} />
{activeTab === 'feed' && } {activeTab === 'members' && ( - + <> + {(group.userRole === 'admin' || group.userRole === 'mod') && + joinRequests.length > 0 && ( + + )} + + )} {activeTab === 'events' && ( @@ -63,6 +83,13 @@ export const GroupDetailView: React.FC = ({
+ + {showInviteModal && handleInvite && ( + + )}
); }; diff --git a/apps/web/src/components/social/groups/group-detail-view/GroupDetailViewHeader.tsx b/apps/web/src/components/social/groups/group-detail-view/GroupDetailViewHeader.tsx index 9fdb50ba0..f1161ff76 100644 --- a/apps/web/src/components/social/groups/group-detail-view/GroupDetailViewHeader.tsx +++ b/apps/web/src/components/social/groups/group-detail-view/GroupDetailViewHeader.tsx @@ -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 (
@@ -39,6 +41,16 @@ export function GroupDetailViewHeader({
+ {(group.userRole === 'admin' || group.userRole === 'mod') && onInvite && ( + + )} {group.userRole === 'admin' && ( + ) : group.hasPendingRequest ? ( + ) : ( )}
diff --git a/apps/web/src/components/social/groups/group-detail-view/InviteMemberModal.tsx b/apps/web/src/components/social/groups/group-detail-view/InviteMemberModal.tsx new file mode 100644 index 000000000..f1c45b109 --- /dev/null +++ b/apps/web/src/components/social/groups/group-detail-view/InviteMemberModal.tsx @@ -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; +} + +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 ( +
+
+
+

Invite member

+ +
+

+ Enter email or user ID to invite someone to this group. +

+
+ setValue(e.target.value)} + /> +
+ + +
+ +
+
+ ); +} diff --git a/apps/web/src/components/social/groups/group-detail-view/JoinRequestsSection.tsx b/apps/web/src/components/social/groups/group-detail-view/JoinRequestsSection.tsx new file mode 100644 index 000000000..9bde09d6c --- /dev/null +++ b/apps/web/src/components/social/groups/group-detail-view/JoinRequestsSection.tsx @@ -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 ( +
+

+ Pending join requests +

+
+ {requests.map((req) => ( +
+ + User {req.user_id.slice(0, 8)}... + +
+ + +
+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/components/social/groups/group-detail-view/useGroupDetailView.ts b/apps/web/src/components/social/groups/group-detail-view/useGroupDetailView.ts index 44880c483..5c9f294c1 100644 --- a/apps/web/src/components/social/groups/group-detail-view/useGroupDetailView.ts +++ b/apps/web/src/components/social/groups/group-detail-view/useGroupDetailView.ts @@ -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(null); const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState('feed'); + const [joinRequests, setJoinRequests] = useState([]); + + 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, }; } diff --git a/apps/web/src/mocks/handlers-social.ts b/apps/web/src/mocks/handlers-social.ts index 76ca0a63c..acec8373c 100644 --- a/apps/web/src/mocks/handlers-social.ts +++ b/apps/web/src/mocks/handlers-social.ts @@ -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( diff --git a/apps/web/src/services/groupService.ts b/apps/web/src/services/groupService.ts index e38725745..44cd05740 100644 --- a/apps/web/src/services/groupService.ts +++ b/apps/web/src/services/groupService.ts @@ -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(`/social/groups/${id}`); + const response = await apiClient.get( + `/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')), + }; + }, }; diff --git a/apps/web/src/types/v2-v3-types.ts b/apps/web/src/types/v2-v3-types.ts index 2c096eef3..e4596caf8 100644 --- a/apps/web/src/types/v2-v3-types.ts +++ b/apps/web/src/types/v2-v3-types.ts @@ -317,6 +317,7 @@ export interface SocialGroup { members: number; isPrivate: boolean; userRole: 'admin' | 'mod' | 'member' | 'none'; + hasPendingRequest?: boolean; description: string; coverUrl: string; } diff --git a/veza-backend-api/internal/api/routes_social.go b/veza-backend-api/internal/api/routes_social.go index 4d95ca332..24322e1b2 100644 --- a/veza-backend-api/internal/api/routes_social.go +++ b/veza-backend-api/internal/api/routes_social.go @@ -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("") diff --git a/veza-backend-api/internal/core/social/group_service.go b/veza-backend-api/internal/core/social/group_service.go index 17daa6c0d..20676201d 100644 --- a/veza-backend-api/internal/core/social/group_service.go +++ b/veza-backend-api/internal/core/social/group_service.go @@ -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{}). diff --git a/veza-backend-api/internal/handlers/social_group_handler.go b/veza-backend-api/internal/handlers/social_group_handler.go index b6d20b093..03c1b4cf1 100644 --- a/veza-backend-api/internal/handlers/social_group_handler.go +++ b/veza-backend-api/internal/handlers/social_group_handler.go @@ -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