feat: member portal Sprint 3 — profile edit form and privacy center

- PATCH /my/profile API endpoint (displayName, hometown, currentLocation, interests, language, digestPreference, directoryVisible)
- BFF PATCH /api/my/profile route
- ProfileForm client component: interest chips, digest preference, language, directory toggle
- Settings page rewrite: profile edit + privacy center (consent list + opt-out) + session + account delete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 16:06:20 +05:30
parent bc9fe7c145
commit f7922454ca
5 changed files with 334 additions and 6 deletions
+14 -1
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
import { MyService } from './my.service'; import { MyService } from './my.service';
import { MemberAuth } from '../auth/member-auth.decorator'; import { MemberAuth } from '../auth/member-auth.decorator';
import { CurrentMember } from '../auth/current-member.decorator'; import { CurrentMember } from '../auth/current-member.decorator';
@@ -39,6 +39,19 @@ export class MyController {
return this.service.getProfile(member.sub, member.tenantId); return this.service.getProfile(member.sub, member.tenantId);
} }
@Patch('profile')
updateProfile(@CurrentMember() member: MemberJwtPayload, @Body() body: {
displayName?: string;
hometown?: string;
currentLocation?: string;
interests?: string[];
language?: string;
digestPreference?: string;
directoryVisible?: boolean;
}) {
return this.service.updateProfile(member.sub, member.tenantId, body);
}
@Get('groups') @Get('groups')
listGroups(@CurrentMember() member: MemberJwtPayload) { listGroups(@CurrentMember() member: MemberJwtPayload) {
return this.service.listGroups(member.sub, member.tenantId); return this.service.listGroups(member.sub, member.tenantId);
+33
View File
@@ -26,6 +26,39 @@ export class MyService {
}; };
} }
async updateProfile(userId: string, tenantId: string, body: {
displayName?: string;
hometown?: string;
currentLocation?: string;
interests?: string[];
language?: string;
digestPreference?: string;
directoryVisible?: boolean;
}) {
const user = await this.prisma.towerUser.findFirst({ where: { id: userId, tenantId } });
if (!user) throw new NotFoundException('User not found');
const updated = await this.prisma.towerUser.update({
where: { id: userId },
data: {
...(body.displayName !== undefined && { displayName: body.displayName }),
...(body.hometown !== undefined && { hometown: body.hometown }),
...(body.currentLocation !== undefined && { currentLocation: body.currentLocation }),
...(body.interests !== undefined && { interests: body.interests }),
...(body.language !== undefined && { language: body.language }),
...(body.digestPreference !== undefined && { digestPreference: body.digestPreference }),
...(body.directoryVisible !== undefined && { directoryVisible: body.directoryVisible }),
},
select: {
id: true, jid: true, displayName: true, avatar: true,
hometown: true, currentLocation: true, interests: true,
language: true, digestPreference: true, directoryVisible: true,
},
});
return updated;
}
async getDigests(tenantId: string) { async getDigests(tenantId: string) {
const digests = await this.prisma.digest.findMany({ const digests = await this.prisma.digest.findMany({
where: { tenantId }, where: { tenantId },
+11
View File
@@ -7,3 +7,14 @@ export async function GET(): Promise<Response> {
const body = await res.json(); const body = await res.json();
return jsonResponse(body, res.status); return jsonResponse(body, res.status);
} }
export async function PATCH(request: Request): Promise<Response> {
const body = await request.json();
const res = await memberApiFetch('/my/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const resBody = await res.json();
return jsonResponse(resBody, res.status);
}
+177
View File
@@ -0,0 +1,177 @@
'use client';
import { useState } from 'react';
interface ProfileData {
displayName: string | null;
hometown: string | null;
currentLocation: string | null;
interests: string[];
language: string;
digestPreference: string;
directoryVisible: boolean;
}
interface Props {
initial: ProfileData;
}
export function ProfileForm({ initial }: Props) {
const [form, setForm] = useState<ProfileData>(initial);
const [interestInput, setInterestInput] = useState('');
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setError(null);
setSaved(false);
try {
const res = await fetch('/api/my/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
});
if (!res.ok) throw new Error(await res.text());
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setSaving(false);
}
};
const addInterest = () => {
const val = interestInput.trim();
if (val && !form.interests.includes(val)) {
setForm((f) => ({ ...f, interests: [...f.interests, val] }));
}
setInterestInput('');
};
const removeInterest = (item: string) => {
setForm((f) => ({ ...f, interests: f.interests.filter((i) => i !== item) }));
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Display name</label>
<input
type="text"
value={form.displayName ?? ''}
onChange={(e) => setForm((f) => ({ ...f, displayName: e.target.value }))}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Your name"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Hometown</label>
<input
type="text"
value={form.hometown ?? ''}
onChange={(e) => setForm((f) => ({ ...f, hometown: e.target.value }))}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="e.g. Mumbai"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Current location</label>
<input
type="text"
value={form.currentLocation ?? ''}
onChange={(e) => setForm((f) => ({ ...f, currentLocation: e.target.value }))}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="e.g. Pune"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Interests</label>
<div className="flex flex-wrap gap-2 mb-2">
{form.interests.map((interest) => (
<span
key={interest}
className="text-xs bg-indigo-50 text-indigo-700 rounded-full px-3 py-1 flex items-center gap-1"
>
{interest}
<button type="button" onClick={() => removeInterest(interest)} className="text-indigo-400 hover:text-indigo-700 ml-0.5">×</button>
</span>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={interestInput}
onChange={(e) => setInterestInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addInterest(); } }}
className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Add interest and press Enter"
/>
<button
type="button"
onClick={addInterest}
className="px-3 py-2 text-sm bg-gray-100 rounded-lg hover:bg-gray-200 text-gray-700"
>
Add
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Digest preference</label>
<select
value={form.digestPreference}
onChange={(e) => setForm((f) => ({ ...f, digestPreference: e.target.value }))}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="never">Never</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Language</label>
<select
value={form.language}
onChange={(e) => setForm((f) => ({ ...f, language: e.target.value }))}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="en">English</option>
<option value="hi">ि</option>
</select>
</div>
</div>
<div className="flex items-center gap-3">
<input
id="directoryVisible"
type="checkbox"
checked={form.directoryVisible}
onChange={(e) => setForm((f) => ({ ...f, directoryVisible: e.target.checked }))}
className="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<label htmlFor="directoryVisible" className="text-sm text-gray-700">
Show my name in the member directory
</label>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={saving}
className="w-full rounded-lg bg-indigo-600 text-white text-sm font-medium py-2.5 hover:bg-indigo-700 disabled:opacity-50 transition-colors"
>
{saving ? 'Saving…' : saved ? 'Saved ✓' : 'Save profile'}
</button>
</form>
);
}
+99 -5
View File
@@ -1,8 +1,40 @@
import { getMemberToken } from '../../_lib/api'; import { getMemberToken, getApiBaseUrl } from '../../_lib/api';
import { DeleteAccountButton } from './DeleteAccountButton'; import { DeleteAccountButton } from './DeleteAccountButton';
import { MemberLogoutButton } from './MemberLogoutButton'; import { MemberLogoutButton } from './MemberLogoutButton';
import { ProfileForm } from './ProfileForm';
import Link from 'next/link';
export default async function MySettingsPage() { interface FullProfile {
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;
}
interface GroupSummary {
id: string;
name: string;
joinedAt: string;
consentStatus: string;
}
async function fetchDashboard(token: string) {
const res = await fetch(`${getApiBaseUrl()}/my/dashboard`, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
}).catch(() => null);
if (!res || !res.ok) return null;
return res.json() as Promise<{ profile: FullProfile; groups: GroupSummary[] }>;
}
export default async function SettingsPage() {
const token = await getMemberToken(); const token = await getMemberToken();
if (!token) { if (!token) {
return ( return (
@@ -13,18 +45,80 @@ export default async function MySettingsPage() {
); );
} }
const data = await fetchDashboard(token);
return ( return (
<div className="max-w-2xl mx-auto mt-12 p-6 flex flex-col gap-6"> <div className="max-w-2xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-gray-900">Profile &amp; settings</h1>
<Link href="/my" className="text-sm text-indigo-600 hover:underline"> Dashboard</Link>
</div>
{/* Profile edit */}
<section className="rounded-xl border border-gray-200 bg-white p-5"> <section className="rounded-xl border border-gray-200 bg-white p-5">
<h2 className="text-base font-semibold mb-2">Session</h2> <h2 className="text-sm font-semibold text-gray-700 mb-4">Your profile</h2>
{data ? (
<ProfileForm
initial={{
displayName: data.profile.displayName,
hometown: data.profile.hometown,
currentLocation: data.profile.currentLocation,
interests: data.profile.interests,
language: data.profile.language,
digestPreference: data.profile.digestPreference,
directoryVisible: data.profile.directoryVisible,
}}
/>
) : (
<p className="text-sm text-gray-500">Could not load profile data.</p>
)}
</section>
{/* Privacy center — consent records */}
{data && data.groups.length > 0 && (
<section className="rounded-xl border border-gray-200 bg-white p-5">
<h2 className="text-sm font-semibold text-gray-700 mb-1">Privacy center</h2>
<p className="text-xs text-gray-400 mb-4">
You gave consent to receive messages from these groups. You can opt out at any time.
</p>
<div className="divide-y divide-gray-100">
{data.groups.map((g) => (
<div key={g.id} className="flex items-center justify-between py-3">
<div>
<p className="text-sm font-medium text-gray-900">{g.name}</p>
<p className="text-xs text-gray-400">
Since {new Date(g.joinedAt).toLocaleDateString()} · {g.consentStatus === 'GRANTED' ? 'Active' : 'Revoked'}
</p>
</div>
{g.consentStatus === 'GRANTED' && (
<form method="POST" action="/api/my/opt-out">
<input type="hidden" name="groupId" value={g.id} />
<button
type="submit"
className="text-xs text-red-500 hover:text-red-700 border border-red-200 rounded-md px-3 py-1.5 hover:bg-red-50"
>
Opt out
</button>
</form>
)}
</div>
))}
</div>
</section>
)}
{/* Session */}
<section className="rounded-xl border border-gray-200 bg-white p-5">
<h2 className="text-sm font-semibold text-gray-700 mb-2">Session</h2>
<p className="text-sm text-gray-600 mb-3"> <p className="text-sm text-gray-600 mb-3">
Sign out of your member portal. Your consent records are kept until you delete your account. Sign out of your member portal. Your consent records are kept until you delete your account.
</p> </p>
<MemberLogoutButton /> <MemberLogoutButton />
</section> </section>
{/* Danger zone */}
<section className="rounded-xl border border-red-200 bg-red-50 p-5"> <section className="rounded-xl border border-red-200 bg-red-50 p-5">
<h2 className="text-base font-semibold text-red-800 mb-2">Delete your account</h2> <h2 className="text-sm font-semibold text-red-800 mb-2">Delete your account</h2>
<p className="text-sm text-red-700 mb-3"> <p className="text-sm text-red-700 mb-3">
This permanently deletes your TOWER user record, all consent records, opt-out history, and This permanently deletes your TOWER user record, all consent records, opt-out history, and
sessions. The messages themselves stay in their original groups. This cannot be undone. sessions. The messages themselves stay in their original groups. This cannot be undone.