From 6d89d8a609e1eeb06a729981174c22a530ec1929 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 17 Jun 2026 16:55:30 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20member=20portal=20Sprint=208=20?= =?UTF-8?q?=E2=80=94=20Member=20Directory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /my/directory in MyService/MyController with q + interest filters - Respects directoryVisible; excludes self; never exposes jid/phone - q searches displayName/hometown/currentLocation (case-insensitive); interest filter via array `has`; aggregates top-20 interest filter chips - BFF route forwards query string - /my/directory page: server-rendered cards (avatar initials, location, interest tags) - DirectorySearch client component: debounced search box + toggleable interest chips that drive URL params - Directory nav item Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/modules/my/my.controller.ts | 11 ++- apps/api/src/modules/my/my.service.ts | 60 ++++++++++++ apps/web/app/api/my/directory/route.ts | 11 +++ apps/web/app/my/directory/DirectorySearch.tsx | 64 +++++++++++++ apps/web/app/my/directory/page.tsx | 94 +++++++++++++++++++ apps/web/app/my/layout.tsx | 1 + 6 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 apps/web/app/api/my/directory/route.ts create mode 100644 apps/web/app/my/directory/DirectorySearch.tsx create mode 100644 apps/web/app/my/directory/page.tsx diff --git a/apps/api/src/modules/my/my.controller.ts b/apps/api/src/modules/my/my.controller.ts index a2c78c0..947c2b7 100644 --- a/apps/api/src/modules/my/my.controller.ts +++ b/apps/api/src/modules/my/my.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { MyService } from './my.service'; import { SevaService } from '../seva/seva.service'; import { CirclesService } from '../circles/circles.service'; @@ -45,6 +45,15 @@ export class MyController { return this.seva.cancel(member.sub, member.tenantId, id); } + @Get('directory') + directory( + @CurrentMember() member: MemberJwtPayload, + @Query('q') q?: string, + @Query('interest') interest?: string, + ) { + return this.service.getDirectory(member.sub, member.tenantId, q, interest); + } + @Get('circles') circlesList(@CurrentMember() member: MemberJwtPayload) { return this.circles.listForMember(member.sub, member.tenantId); diff --git a/apps/api/src/modules/my/my.service.ts b/apps/api/src/modules/my/my.service.ts index dd09004..bb475ca 100644 --- a/apps/api/src/modules/my/my.service.ts +++ b/apps/api/src/modules/my/my.service.ts @@ -126,6 +126,66 @@ export class MyService { })); } + async getDirectory(userId: string, tenantId: string, q?: string, interest?: string) { + const where: any = { + tenantId, + directoryVisible: true, + id: { not: userId }, + }; + if (interest) where.interests = { has: interest }; + if (q && q.trim()) { + const term = q.trim(); + where.OR = [ + { displayName: { contains: term, mode: 'insensitive' } }, + { hometown: { contains: term, mode: 'insensitive' } }, + { currentLocation: { contains: term, mode: 'insensitive' } }, + ]; + } + + const [members, allVisible] = await Promise.all([ + this.prisma.towerUser.findMany({ + where, + take: 100, + orderBy: { displayName: 'asc' }, + select: { + id: true, + displayName: true, + avatar: true, + hometown: true, + currentLocation: true, + interests: true, + }, + }), + // Aggregate interest filter chips across the whole visible directory. + this.prisma.towerUser.findMany({ + where: { tenantId, directoryVisible: true, id: { not: userId } }, + select: { interests: true }, + }), + ]); + + const interestCounts = new Map(); + for (const u of allVisible) { + for (const i of u.interests) interestCounts.set(i, (interestCounts.get(i) ?? 0) + 1); + } + const topInterests = [...interestCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 20) + .map(([name, count]) => ({ name, count })); + + return { + total: members.length, + members: members.map((m) => ({ + id: m.id, + displayName: m.displayName ?? 'Member', + avatar: m.avatar, + hometown: m.hometown, + currentLocation: m.currentLocation, + interests: m.interests, + })), + interests: topInterests, + }; + } + async getDashboard(userId: string, tenantId: string) { const user = await this.prisma.towerUser.findFirst({ where: { id: userId, tenantId } }); if (!user) throw new NotFoundException('User not found'); diff --git a/apps/web/app/api/my/directory/route.ts b/apps/web/app/api/my/directory/route.ts new file mode 100644 index 0000000..c79883c --- /dev/null +++ b/apps/web/app/api/my/directory/route.ts @@ -0,0 +1,11 @@ +import { jsonResponse, memberApiFetch } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: Request): Promise { + const url = new URL(request.url); + const qs = url.searchParams.toString(); + const res = await memberApiFetch(`/my/directory${qs ? `?${qs}` : ''}`); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/my/directory/DirectorySearch.tsx b/apps/web/app/my/directory/DirectorySearch.tsx new file mode 100644 index 0000000..03714a9 --- /dev/null +++ b/apps/web/app/my/directory/DirectorySearch.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import { useState, useEffect } from 'react'; + +interface Props { + interests: Array<{ name: string; count: number }>; +} + +export function DirectorySearch({ interests }: Props) { + const router = useRouter(); + const searchParams = useSearchParams(); + const activeInterest = searchParams.get('interest'); + const [q, setQ] = useState(searchParams.get('q') ?? ''); + + // Debounced push of the search term into the URL. + useEffect(() => { + const handle = setTimeout(() => { + const params = new URLSearchParams(searchParams.toString()); + if (q.trim()) params.set('q', q.trim()); + else params.delete('q'); + router.replace(`/my/directory${params.toString() ? `?${params.toString()}` : ''}`); + }, 300); + return () => clearTimeout(handle); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [q]); + + const toggleInterest = (name: string) => { + const params = new URLSearchParams(searchParams.toString()); + if (activeInterest === name) params.delete('interest'); + else params.set('interest', name); + router.replace(`/my/directory${params.toString() ? `?${params.toString()}` : ''}`); + }; + + return ( +
+ setQ(e.target.value)} + placeholder="Search by name, hometown, or location…" + className="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> + {interests.length > 0 && ( +
+ {interests.map((i) => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/web/app/my/directory/page.tsx b/apps/web/app/my/directory/page.tsx new file mode 100644 index 0000000..df5b48e --- /dev/null +++ b/apps/web/app/my/directory/page.tsx @@ -0,0 +1,94 @@ +import { getMemberToken, getApiBaseUrl } from '../../_lib/api'; +import { DirectorySearch } from './DirectorySearch'; +import Link from 'next/link'; + +interface DirectoryMember { + id: string; + displayName: string; + avatar: string | null; + hometown: string | null; + currentLocation: string | null; + interests: string[]; +} + +interface DirectoryData { + total: number; + members: DirectoryMember[]; + interests: Array<{ name: string; count: number }>; +} + +async function fetchDirectory(token: string, q?: string, interest?: string): Promise { + const params = new URLSearchParams(); + if (q) params.set('q', q); + if (interest) params.set('interest', interest); + const res = await fetch(`${getApiBaseUrl()}/my/directory${params.toString() ? `?${params.toString()}` : ''}`, { + headers: { Authorization: `Bearer ${token}` }, + cache: 'no-store', + }).catch(() => null); + if (!res || !res.ok) return null; + return (await res.json()) as DirectoryData; +} + +function initials(name: string): string { + return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p.charAt(0).toUpperCase()).join('') || '?'; +} + +export default async function DirectoryPage({ + searchParams, +}: { + searchParams: Promise<{ q?: string; interest?: string }>; +}) { + const token = await getMemberToken(); + if (!token) return null; + + const { q, interest } = await searchParams; + const data = await fetchDirectory(token, q, interest); + + return ( +
+
+

Member directory

+ ← Dashboard +
+ + + + {!data || data.members.length === 0 ? ( +
+

No members found.

+ {(q || interest) &&

Try clearing your search or filters.

} +
+ ) : ( +
+ {data.members.map((m) => ( +
+
+ {initials(m.displayName)} +
+
+

{m.displayName}

+ {(m.hometown || m.currentLocation) && ( +

+ {[m.hometown, m.currentLocation].filter(Boolean).join(' · ')} +

+ )} + {m.interests.length > 0 && ( +
+ {m.interests.slice(0, 3).map((i) => ( + + {i} + + ))} + {m.interests.length > 3 && ( + +{m.interests.length - 3} + )} +
+ )} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/apps/web/app/my/layout.tsx b/apps/web/app/my/layout.tsx index 3396b2c..797cd71 100644 --- a/apps/web/app/my/layout.tsx +++ b/apps/web/app/my/layout.tsx @@ -8,6 +8,7 @@ const NAV = [ { href: '/my/events', label: 'Events' }, { href: '/my/seva', label: 'Seva & Points' }, { href: '/my/circles', label: 'Circles' }, + { href: '/my/directory', label: 'Directory' }, { href: '/my/groups', label: 'My Groups' }, { href: '/my/settings', label: 'Settings' }, ];