feat: member portal Sprint 1 — dashboard with MemberShell layout and TowerUser profile fields
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
@@ -365,14 +365,21 @@ enum MemberOptOutReason {
|
|||||||
|
|
||||||
// Hashed identity: SHA-256 of E.164 phone number (pepper via JWT_SECRET).
|
// Hashed identity: SHA-256 of E.164 phone number (pepper via JWT_SECRET).
|
||||||
model TowerUser {
|
model TowerUser {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String
|
tenantId String
|
||||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
phoneHash String
|
phoneHash String
|
||||||
jid String
|
jid String
|
||||||
displayName String?
|
displayName String?
|
||||||
createdAt DateTime @default(now())
|
avatar String?
|
||||||
updatedAt DateTime @updatedAt
|
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[]
|
consents ConsentRecord[]
|
||||||
optOuts MemberOptOut[]
|
optOuts MemberOptOut[]
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ class OptInDto {
|
|||||||
export class MyController {
|
export class MyController {
|
||||||
constructor(private readonly service: MyService) {}
|
constructor(private readonly service: MyService) {}
|
||||||
|
|
||||||
|
@Get('dashboard')
|
||||||
|
dashboard(@CurrentMember() member: MemberJwtPayload) {
|
||||||
|
return this.service.getDashboard(member.sub, member.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('profile')
|
@Get('profile')
|
||||||
profile(@CurrentMember() member: MemberJwtPayload) {
|
profile(@CurrentMember() member: MemberJwtPayload) {
|
||||||
return this.service.getProfile(member.sub, member.tenantId);
|
return this.service.getProfile(member.sub, member.tenantId);
|
||||||
|
|||||||
@@ -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<MemberGroupSummary[]> {
|
async listGroups(userId: string, tenantId: string): Promise<MemberGroupSummary[]> {
|
||||||
const consents = await this.prisma.consentRecord.findMany({
|
const consents = await this.prisma.consentRecord.findMany({
|
||||||
where: { userId, tenantId },
|
where: { userId, tenantId },
|
||||||
|
|||||||
@@ -85,25 +85,7 @@ export function Sidebar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (MEMBER_PATHS.some((p) => pathname.startsWith(p))) {
|
if (MEMBER_PATHS.some((p) => pathname.startsWith(p))) {
|
||||||
return (
|
return null;
|
||||||
<nav className="w-52 shrink-0 bg-white border-r border-gray-200 p-4 flex flex-col">
|
|
||||||
<span className="font-bold text-base mb-4">TOWER</span>
|
|
||||||
<div className="flex flex-col gap-1 flex-1">
|
|
||||||
<Link href="/my" className="rounded px-3 py-2 text-sm hover:bg-gray-100">
|
|
||||||
Profile
|
|
||||||
</Link>
|
|
||||||
<Link href="/my/groups" className="rounded px-3 py-2 text-sm hover:bg-gray-100">
|
|
||||||
Groups
|
|
||||||
</Link>
|
|
||||||
<Link href="/my/settings" className="rounded px-3 py-2 text-sm hover:bg-gray-100">
|
|
||||||
Settings
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="border-t border-gray-200 pt-3 mt-3 text-xs text-gray-500 px-3">
|
|
||||||
Member portal
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { jsonResponse, memberApiFetch } from '../../../_lib/api';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET(): Promise<Response> {
|
||||||
|
const res = await memberApiFetch('/my/dashboard');
|
||||||
|
const body = await res.json();
|
||||||
|
return jsonResponse(body, res.status);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex h-full -m-6 min-h-screen">
|
||||||
|
{/* Left sidebar */}
|
||||||
|
<nav className="w-[220px] shrink-0 bg-white border-r border-gray-200 flex flex-col p-4">
|
||||||
|
<span className="font-bold text-base mb-6 px-2">TOWER</span>
|
||||||
|
<div className="flex flex-col gap-1 flex-1">
|
||||||
|
{NAV.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className="rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-gray-200 pt-3 mt-3">
|
||||||
|
<p className="px-3 text-xs text-gray-400 uppercase tracking-wide">Member portal</p>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="flex-1 overflow-auto p-6 bg-gray-50">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Right activity rail */}
|
||||||
|
<aside className="w-[280px] shrink-0 bg-white border-l border-gray-200 p-4 hidden lg:block">
|
||||||
|
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-4">Quick actions</p>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Link
|
||||||
|
href="/my/settings"
|
||||||
|
className="text-sm text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Privacy settings
|
||||||
|
</Link>
|
||||||
|
<form method="POST" action="/api/my/logout">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full text-left text-sm text-red-500 hover:text-red-700 px-3 py-2 rounded-md hover:bg-red-50"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+143
-48
@@ -1,74 +1,169 @@
|
|||||||
import { getMemberToken, getApiBaseUrl } from '../_lib/api';
|
import { getMemberToken, getApiBaseUrl } from '../_lib/api';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
interface MemberProfile {
|
interface DashboardData {
|
||||||
id: string;
|
profile: {
|
||||||
tenantId: string;
|
id: string;
|
||||||
jid: string;
|
jid: string;
|
||||||
displayName: string | null;
|
displayName: string | null;
|
||||||
createdAt: string;
|
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<MemberProfile | null> {
|
async function fetchDashboard(token: string): Promise<DashboardData | null> {
|
||||||
const res = await fetch(`${getApiBaseUrl()}/my/profile`, {
|
const res = await fetch(`${getApiBaseUrl()}/my/dashboard`, {
|
||||||
headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
if (!res || !res.ok) return null;
|
if (!res || !res.ok) return null;
|
||||||
return (await res.json()) as MemberProfile;
|
return (await res.json()) as DashboardData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function MyPage() {
|
function StatCard({ label, value }: { label: string; value: number }) {
|
||||||
const token = await getMemberToken();
|
return (
|
||||||
if (!token) {
|
<div className="bg-white rounded-xl border border-gray-200 p-5 flex flex-col gap-1">
|
||||||
return (
|
<span className="text-2xl font-bold text-gray-900">{value}</span>
|
||||||
<div className="max-w-md mx-auto mt-12 p-6 rounded-lg border border-yellow-200 bg-yellow-50">
|
<span className="text-xs text-gray-500 uppercase tracking-wide">{label}</span>
|
||||||
<h1 className="text-lg font-semibold text-yellow-800">Member portal</h1>
|
</div>
|
||||||
<p className="text-sm text-yellow-700">
|
);
|
||||||
No member session. Complete onboarding via the link a group admin sent you.
|
}
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile = await fetchProfile(token);
|
export default async function DashboardPage() {
|
||||||
if (!profile) {
|
const token = await getMemberToken();
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
const data = await fetchDashboard(token);
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto mt-12 p-6 rounded-lg border border-red-200 bg-red-50">
|
<div className="max-w-md mx-auto mt-12 p-6 rounded-lg border border-red-200 bg-red-50">
|
||||||
<h1 className="text-lg font-semibold text-red-800">Couldn't load your account</h1>
|
<h1 className="text-lg font-semibold text-red-800">Could not load dashboard</h1>
|
||||||
<p className="text-sm text-red-700">
|
<p className="text-sm text-red-700 mt-1">Your session may have expired. Please sign out and onboard again.</p>
|
||||||
Your session may have expired. Please complete onboarding again.
|
|
||||||
</p>
|
|
||||||
<form method="POST" action="/api/my/logout" className="mt-4">
|
<form method="POST" action="/api/my/logout" className="mt-4">
|
||||||
<button type="submit" className="text-sm text-blue-600 underline">
|
<button type="submit" className="text-sm text-blue-600 underline">Sign out</button>
|
||||||
Sign out
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { profile, stats, groups } = data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto mt-12 p-6 rounded-lg border border-gray-200 bg-white">
|
<div className="max-w-2xl mx-auto space-y-8">
|
||||||
<h1 className="text-xl font-semibold mb-4">Your account</h1>
|
{/* Hero */}
|
||||||
<dl className="text-sm space-y-1 mb-6">
|
<div className="bg-white rounded-xl border border-gray-200 p-6 flex items-center gap-5">
|
||||||
<div>
|
<div className="w-14 h-14 rounded-full bg-indigo-100 flex items-center justify-center text-xl font-bold text-indigo-600 shrink-0">
|
||||||
<dt className="inline font-medium">Display name: </dt>
|
{profile.displayName ? profile.displayName.charAt(0).toUpperCase() : '?'}
|
||||||
<dd className="inline">{profile.displayName ?? '—'}</dd>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<dt className="inline font-medium">JID: </dt>
|
<h1 className="text-xl font-semibold text-gray-900 truncate">
|
||||||
<dd className="inline">{profile.jid}</dd>
|
{profile.displayName ?? 'Community Member'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500 truncate">{profile.jid}</p>
|
||||||
|
{(profile.hometown || profile.currentLocation) && (
|
||||||
|
<p className="text-sm text-gray-400 mt-0.5">
|
||||||
|
{[profile.hometown, profile.currentLocation].filter(Boolean).join(' · ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<Link
|
||||||
<dt className="inline font-medium">Joined: </dt>
|
href="/my/settings"
|
||||||
<dd className="inline">{new Date(profile.createdAt).toLocaleDateString()}</dd>
|
className="ml-auto text-sm text-indigo-600 hover:underline shrink-0"
|
||||||
</div>
|
>
|
||||||
</dl>
|
Edit profile
|
||||||
<div className="flex flex-col gap-2 text-sm">
|
</Link>
|
||||||
<Link href="/my/groups" className="text-blue-600 underline">Manage your groups →</Link>
|
|
||||||
<Link href="/my/settings" className="text-blue-600 underline">Privacy & account settings →</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<StatCard label="Groups joined" value={stats.groupsJoined} />
|
||||||
|
<StatCard label="Messages shared" value={stats.messagesContributed} />
|
||||||
|
<StatCard label="Active consents" value={stats.activeConsents} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Groups */}
|
||||||
|
{groups.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||||
|
Your groups
|
||||||
|
</h2>
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
|
||||||
|
{groups.map((g) => (
|
||||||
|
<div key={g.id} className="flex items-center justify-between px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{g.name}</p>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Joined {new Date(g.joinedAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||||
|
g.consentStatus === 'GRANTED'
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-gray-100 text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{g.consentStatus === 'GRANTED' ? 'Active' : 'Revoked'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-right">
|
||||||
|
<Link href="/my/groups" className="text-sm text-indigo-600 hover:underline">
|
||||||
|
Manage all groups →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groups.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-gray-400 text-sm">
|
||||||
|
<p>No group memberships yet.</p>
|
||||||
|
<p className="mt-1">An admin will send you an onboarding link when you are added to a group.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Interests */}
|
||||||
|
{profile.interests.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||||
|
Interests
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{profile.interests.map((interest) => (
|
||||||
|
<span
|
||||||
|
key={interest}
|
||||||
|
className="text-xs bg-indigo-50 text-indigo-700 rounded-full px-3 py-1"
|
||||||
|
>
|
||||||
|
{interest}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Member since */}
|
||||||
|
<p className="text-xs text-gray-400 text-center">
|
||||||
|
Member since {new Date(profile.createdAt).toLocaleDateString('en-IN', { year: 'numeric', month: 'long', day: 'numeric' })}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user