From 566690bd045d4d10f161b091c26a95cc2a56020f Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 17 Jun 2026 15:44:58 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20member=20portal=20Sprint=201=20?= =?UTF-8?q?=E2=80=94=20dashboard=20with=20MemberShell=20layout=20and=20Tow?= =?UTF-8?q?erUser=20profile=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add avatar, hometown, currentLocation, interests, language, digestPreference, directoryVisible fields to TowerUser - Migration: 20260617200000_add_tower_user_profile_fields - GET /my/dashboard API endpoint with stats (groups, messages, consents) - BFF route /api/my/dashboard - MemberShell 3-column layout (220px nav + flex-1 main + 280px rail) - Dashboard page: hero card, stat cards, group list, interests chips - Sidebar returns null for /my paths (MemberShell owns the nav) Co-Authored-By: Claude Sonnet 4.6 --- .../migration.sql | 7 + apps/api/prisma/schema.prisma | 23 ++- apps/api/src/modules/my/my.controller.ts | 5 + apps/api/src/modules/my/my.service.ts | 40 ++++ apps/web/app/_lib/sidebar.tsx | 20 +- apps/web/app/api/my/dashboard/route.ts | 9 + apps/web/app/my/layout.tsx | 63 ++++++ apps/web/app/my/page.tsx | 191 +++++++++++++----- 8 files changed, 283 insertions(+), 75 deletions(-) create mode 100644 apps/api/prisma/migrations/20260617200000_add_tower_user_profile_fields/migration.sql create mode 100644 apps/web/app/api/my/dashboard/route.ts create mode 100644 apps/web/app/my/layout.tsx diff --git a/apps/api/prisma/migrations/20260617200000_add_tower_user_profile_fields/migration.sql b/apps/api/prisma/migrations/20260617200000_add_tower_user_profile_fields/migration.sql new file mode 100644 index 0000000..86fba60 --- /dev/null +++ b/apps/api/prisma/migrations/20260617200000_add_tower_user_profile_fields/migration.sql @@ -0,0 +1,7 @@ +ALTER TABLE "TowerUser" ADD COLUMN "avatar" TEXT; +ALTER TABLE "TowerUser" ADD COLUMN "hometown" TEXT; +ALTER TABLE "TowerUser" ADD COLUMN "currentLocation" TEXT; +ALTER TABLE "TowerUser" ADD COLUMN "interests" TEXT[] NOT NULL DEFAULT '{}'; +ALTER TABLE "TowerUser" ADD COLUMN "language" TEXT NOT NULL DEFAULT 'en'; +ALTER TABLE "TowerUser" ADD COLUMN "digestPreference" TEXT NOT NULL DEFAULT 'daily'; +ALTER TABLE "TowerUser" ADD COLUMN "directoryVisible" BOOLEAN NOT NULL DEFAULT true; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 52b984c..81f8a47 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -365,14 +365,21 @@ enum MemberOptOutReason { // Hashed identity: SHA-256 of E.164 phone number (pepper via JWT_SECRET). model TowerUser { - id String @id @default(cuid()) - tenantId String - tenant Tenant @relation(fields: [tenantId], references: [id]) - phoneHash String - jid String - displayName String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + tenantId String + tenant Tenant @relation(fields: [tenantId], references: [id]) + phoneHash String + jid String + displayName String? + avatar String? + hometown String? + currentLocation String? + interests String[] + language String @default("en") + digestPreference String @default("daily") + directoryVisible Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt consents ConsentRecord[] optOuts MemberOptOut[] diff --git a/apps/api/src/modules/my/my.controller.ts b/apps/api/src/modules/my/my.controller.ts index 7373889..66db7ff 100644 --- a/apps/api/src/modules/my/my.controller.ts +++ b/apps/api/src/modules/my/my.controller.ts @@ -24,6 +24,11 @@ class OptInDto { export class MyController { constructor(private readonly service: MyService) {} + @Get('dashboard') + dashboard(@CurrentMember() member: MemberJwtPayload) { + return this.service.getDashboard(member.sub, member.tenantId); + } + @Get('profile') profile(@CurrentMember() member: MemberJwtPayload) { return this.service.getProfile(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 8dbf9c4..64bcb11 100644 --- a/apps/api/src/modules/my/my.service.ts +++ b/apps/api/src/modules/my/my.service.ts @@ -26,6 +26,46 @@ export class MyService { }; } + 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'); + + const [activeConsents, messagesCount] = await Promise.all([ + this.prisma.consentRecord.findMany({ + where: { userId, tenantId, status: 'GRANTED' }, + include: { group: { select: { id: true, name: true } } }, + }), + this.prisma.message.count({ where: { senderTowerUserId: userId, tenantId } }), + ]); + + return { + profile: { + id: user.id, + jid: user.jid, + displayName: user.displayName, + avatar: user.avatar, + hometown: user.hometown, + currentLocation: user.currentLocation, + interests: user.interests, + language: user.language, + digestPreference: user.digestPreference, + directoryVisible: user.directoryVisible, + createdAt: user.createdAt.toISOString(), + }, + stats: { + groupsJoined: activeConsents.length, + messagesContributed: messagesCount, + activeConsents: activeConsents.length, + }, + groups: activeConsents.map((c) => ({ + id: c.group.id, + name: c.group.name, + joinedAt: c.effectiveAt.toISOString(), + consentStatus: c.status, + })), + }; + } + async listGroups(userId: string, tenantId: string): Promise { const consents = await this.prisma.consentRecord.findMany({ where: { userId, tenantId }, diff --git a/apps/web/app/_lib/sidebar.tsx b/apps/web/app/_lib/sidebar.tsx index 365b4f1..14f45a5 100644 --- a/apps/web/app/_lib/sidebar.tsx +++ b/apps/web/app/_lib/sidebar.tsx @@ -85,25 +85,7 @@ export function Sidebar() { } if (MEMBER_PATHS.some((p) => pathname.startsWith(p))) { - return ( - - ); + return null; } return ( diff --git a/apps/web/app/api/my/dashboard/route.ts b/apps/web/app/api/my/dashboard/route.ts new file mode 100644 index 0000000..4de5795 --- /dev/null +++ b/apps/web/app/api/my/dashboard/route.ts @@ -0,0 +1,9 @@ +import { jsonResponse, memberApiFetch } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET(): Promise { + const res = await memberApiFetch('/my/dashboard'); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/my/layout.tsx b/apps/web/app/my/layout.tsx new file mode 100644 index 0000000..949d171 --- /dev/null +++ b/apps/web/app/my/layout.tsx @@ -0,0 +1,63 @@ +import Link from 'next/link'; +import { getMemberToken } from '../_lib/api'; +import { redirect } from 'next/navigation'; + +const NAV = [ + { href: '/my', label: 'Dashboard' }, + { href: '/my/groups', label: 'My Groups' }, + { href: '/my/settings', label: 'Settings' }, +]; + +export default async function MemberLayout({ children }: { children: React.ReactNode }) { + const token = await getMemberToken(); + if (!token) redirect('/onboard'); + + return ( +
+ {/* Left sidebar */} + + + {/* Main content */} +
+ {children} +
+ + {/* Right activity rail */} + +
+ ); +} diff --git a/apps/web/app/my/page.tsx b/apps/web/app/my/page.tsx index 90d1ad2..84c0252 100644 --- a/apps/web/app/my/page.tsx +++ b/apps/web/app/my/page.tsx @@ -1,74 +1,169 @@ import { getMemberToken, getApiBaseUrl } from '../_lib/api'; import Link from 'next/link'; -interface MemberProfile { - id: string; - tenantId: string; - jid: string; - displayName: string | null; - createdAt: string; +interface DashboardData { + profile: { + id: string; + jid: string; + displayName: string | null; + avatar: string | null; + hometown: string | null; + currentLocation: string | null; + interests: string[]; + language: string; + digestPreference: string; + directoryVisible: boolean; + createdAt: string; + }; + stats: { + groupsJoined: number; + messagesContributed: number; + activeConsents: number; + }; + groups: Array<{ + id: string; + name: string; + joinedAt: string; + consentStatus: string; + }>; } -async function fetchProfile(token: string): Promise { - const res = await fetch(`${getApiBaseUrl()}/my/profile`, { - headers: { Accept: 'application/json', Authorization: `Bearer ${token}` }, +async function fetchDashboard(token: string): Promise { + const res = await fetch(`${getApiBaseUrl()}/my/dashboard`, { + headers: { Authorization: `Bearer ${token}` }, cache: 'no-store', }).catch(() => null); if (!res || !res.ok) return null; - return (await res.json()) as MemberProfile; + return (await res.json()) as DashboardData; } -export default async function MyPage() { - const token = await getMemberToken(); - if (!token) { - return ( -
-

Member portal

-

- No member session. Complete onboarding via the link a group admin sent you. -

-
- ); - } +function StatCard({ label, value }: { label: string; value: number }) { + return ( +
+ {value} + {label} +
+ ); +} - const profile = await fetchProfile(token); - if (!profile) { +export default async function DashboardPage() { + const token = await getMemberToken(); + if (!token) return null; + + const data = await fetchDashboard(token); + + if (!data) { return (
-

Couldn't load your account

-

- Your session may have expired. Please complete onboarding again. -

+

Could not load dashboard

+

Your session may have expired. Please sign out and onboard again.

- +
); } + const { profile, stats, groups } = data; + return ( -
-

Your account

-
-
-
Display name:
-
{profile.displayName ?? '—'}
+
+ {/* Hero */} +
+
+ {profile.displayName ? profile.displayName.charAt(0).toUpperCase() : '?'}
-
-
JID:
-
{profile.jid}
+
+

+ {profile.displayName ?? 'Community Member'} +

+

{profile.jid}

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

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

+ )}
-
-
Joined:
-
{new Date(profile.createdAt).toLocaleDateString()}
-
-
-
- Manage your groups → - Privacy & account settings → + + Edit profile +
+ + {/* Stats */} +
+ + + +
+ + {/* Groups */} + {groups.length > 0 && ( +
+

+ Your groups +

+
+ {groups.map((g) => ( +
+
+

{g.name}

+

+ Joined {new Date(g.joinedAt).toLocaleDateString()} +

+
+ + {g.consentStatus === 'GRANTED' ? 'Active' : 'Revoked'} + +
+ ))} +
+
+ + Manage all groups → + +
+
+ )} + + {groups.length === 0 && ( +
+

No group memberships yet.

+

An admin will send you an onboarding link when you are added to a group.

+
+ )} + + {/* Interests */} + {profile.interests.length > 0 && ( +
+

+ Interests +

+
+ {profile.interests.map((interest) => ( + + {interest} + + ))} +
+
+ )} + + {/* Member since */} +

+ Member since {new Date(profile.createdAt).toLocaleDateString('en-IN', { year: 'numeric', month: 'long', day: 'numeric' })} +

); }