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:
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user