feat: member portal Sprint 8 — Member Directory

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 16:55:30 +05:30
parent 57fe9d9bf1
commit 6d89d8a609
6 changed files with 240 additions and 1 deletions
+10 -1
View File
@@ -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);
+60
View File
@@ -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<string, number>();
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');