diff --git a/apps/api/src/modules/my/my.controller.ts b/apps/api/src/modules/my/my.controller.ts index b10b846..0b12981 100644 --- a/apps/api/src/modules/my/my.controller.ts +++ b/apps/api/src/modules/my/my.controller.ts @@ -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 { MemberAuth } from '../auth/member-auth.decorator'; import { CurrentMember } from '../auth/current-member.decorator'; @@ -39,6 +39,19 @@ export class MyController { 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') listGroups(@CurrentMember() member: MemberJwtPayload) { return this.service.listGroups(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 ede0995..a75e69d 100644 --- a/apps/api/src/modules/my/my.service.ts +++ b/apps/api/src/modules/my/my.service.ts @@ -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) { const digests = await this.prisma.digest.findMany({ where: { tenantId }, diff --git a/apps/web/app/api/my/profile/route.ts b/apps/web/app/api/my/profile/route.ts index 95e9c11..aec954e 100644 --- a/apps/web/app/api/my/profile/route.ts +++ b/apps/web/app/api/my/profile/route.ts @@ -7,3 +7,14 @@ export async function GET(): Promise { const body = await res.json(); return jsonResponse(body, res.status); } + +export async function PATCH(request: Request): Promise { + 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); +} diff --git a/apps/web/app/my/settings/ProfileForm.tsx b/apps/web/app/my/settings/ProfileForm.tsx new file mode 100644 index 0000000..7383556 --- /dev/null +++ b/apps/web/app/my/settings/ProfileForm.tsx @@ -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(initial); + const [interestInput, setInterestInput] = useState(''); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(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 ( +
+
+ + 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" + /> +
+ +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ +
+ +
+ {form.interests.map((interest) => ( + + {interest} + + + ))} +
+
+ 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" + /> + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ setForm((f) => ({ ...f, directoryVisible: e.target.checked }))} + className="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" + /> + +
+ + {error &&

{error}

} + + +
+ ); +} diff --git a/apps/web/app/my/settings/page.tsx b/apps/web/app/my/settings/page.tsx index dfab028..38ad6d7 100644 --- a/apps/web/app/my/settings/page.tsx +++ b/apps/web/app/my/settings/page.tsx @@ -1,8 +1,40 @@ -import { getMemberToken } from '../../_lib/api'; +import { getMemberToken, getApiBaseUrl } from '../../_lib/api'; import { DeleteAccountButton } from './DeleteAccountButton'; 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(); if (!token) { return ( @@ -13,18 +45,80 @@ export default async function MySettingsPage() { ); } + const data = await fetchDashboard(token); + return ( -
+
+
+

Profile & settings

+ ← Dashboard +
+ + {/* Profile edit */}
-

Session

+

Your profile

+ {data ? ( + + ) : ( +

Could not load profile data.

+ )} +
+ + {/* Privacy center — consent records */} + {data && data.groups.length > 0 && ( +
+

Privacy center

+

+ You gave consent to receive messages from these groups. You can opt out at any time. +

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

{g.name}

+

+ Since {new Date(g.joinedAt).toLocaleDateString()} · {g.consentStatus === 'GRANTED' ? 'Active' : 'Revoked'} +

+
+ {g.consentStatus === 'GRANTED' && ( +
+ + +
+ )} +
+ ))} +
+
+ )} + + {/* Session */} +
+

Session

Sign out of your member portal. Your consent records are kept until you delete your account.

+ {/* Danger zone */}
-

Delete your account

+

Delete your account

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.