feat: member portal Sprint 5 — onboarding portal experience + restore Thread schema

- Redesign onboarding into a 5-step wizard: welcome → phone → consent → code → done
- Progress bar, human-readable consent scopes (INGEST/ARCHIVE/REPLICATE/DISPLAY) with descriptions
- Resend-code action, back navigation, indigo theme matching member portal
- "Done" step introduces portal features (digests, events, privacy) with CTA
- Onboard page shell: rounded card, policy footer
- Restore Thread model + Message.expiresAt/quotedPlatformMsgId/threadId + RAW/DNC statuses to schema.prisma (wiped by prior reset; matches existing add_hybrid_ai_architecture migration) — unblocks ThreadsModule registration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 16:30:58 +05:30
parent 0ffdc362b5
commit a0dc94ce79
3 changed files with 309 additions and 122 deletions
+27
View File
@@ -91,6 +91,7 @@ model Tenant {
digestConfig DigestConfig? digestConfig DigestConfig?
digests Digest[] digests Digest[]
events Event[] events Event[]
threads Thread[]
} }
enum AdminRole { enum AdminRole {
@@ -235,6 +236,7 @@ model Group {
consents ConsentRecord[] consents ConsentRecord[]
claimTokens GroupClaimToken[] claimTokens GroupClaimToken[]
groupAccesses GroupAccess[] groupAccesses GroupAccess[]
threads Thread[]
@@unique([platform, platformId]) @@unique([platform, platformId])
@@index([accountId]) @@index([accountId])
@@ -262,6 +264,10 @@ model Message {
mediaUrl String? mediaUrl String?
tags String[] tags String[]
status MessageStatus @default(PENDING) status MessageStatus @default(PENDING)
expiresAt DateTime?
quotedPlatformMsgId String?
threadId String?
thread Thread? @relation(fields: [threadId], references: [id])
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -270,14 +276,35 @@ model Message {
@@unique([platform, platformMsgId]) @@unique([platform, platformMsgId])
@@index([tenantId]) @@index([tenantId])
@@index([senderTowerUserId]) @@index([senderTowerUserId])
@@index([status, expiresAt])
@@index([threadId])
} }
enum MessageStatus { enum MessageStatus {
RAW
PENDING PENDING
APPROVED APPROVED
REJECTED REJECTED
DISTRIBUTED DISTRIBUTED
ARCHIVED ARCHIVED
DNC
}
model Thread {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
sourceGroupId String
sourceGroup Group @relation(fields: [sourceGroupId], references: [id])
lastActivityAt DateTime @default(now())
messageCount Int @default(0)
topic String?
createdAt DateTime @default(now())
messages Message[]
@@index([tenantId])
@@index([sourceGroupId, lastActivityAt])
} }
model Approval { model Approval {
+225 -67
View File
@@ -4,14 +4,49 @@ import { useState } from 'react';
interface Props { interface Props {
token: string; token: string;
groupName: string;
tenantName: string;
defaultScopes: string[]; defaultScopes: string[];
defaultRetentionDays: number; defaultRetentionDays: number;
} }
type Step = 'phone' | 'code' | 'done'; type Step = 'welcome' | 'phone' | 'consent' | 'code' | 'done';
export function OnboardingForm({ token, defaultScopes, defaultRetentionDays }: Props) { const STEP_ORDER: Step[] = ['welcome', 'phone', 'consent', 'code', 'done'];
const [step, setStep] = useState<Step>('phone');
interface ScopeMeta {
key: string;
label: string;
description: string;
}
const SCOPE_META: ScopeMeta[] = [
{
key: 'INGEST',
label: 'Read group messages',
description: 'Let TOWER read messages you send here so they can be organised and surfaced.',
},
{
key: 'ARCHIVE',
label: 'Save to searchable archive',
description: 'Keep a record of relevant messages so members can find them later.',
},
{
key: 'REPLICATE',
label: 'Share across connected groups',
description: 'Allow approved messages to be forwarded to connected sister groups.',
},
{
key: 'DISPLAY',
label: 'Show in portal & digests',
description: 'Let your contributions appear in the member portal and daily digests.',
},
];
const API_BASE = process.env['NEXT_PUBLIC_API_URL'] ?? 'http://localhost:3001';
export function OnboardingForm({ token, groupName, tenantName, defaultScopes, defaultRetentionDays }: Props) {
const [step, setStep] = useState<Step>('welcome');
const [phone, setPhone] = useState(''); const [phone, setPhone] = useState('');
const [challengeId, setChallengeId] = useState<string | null>(null); const [challengeId, setChallengeId] = useState<string | null>(null);
const [code, setCode] = useState(''); const [code, setCode] = useState('');
@@ -20,11 +55,14 @@ export function OnboardingForm({ token, defaultScopes, defaultRetentionDays }: P
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const stepIndex = STEP_ORDER.indexOf(step);
const progressPct = (stepIndex / (STEP_ORDER.length - 1)) * 100;
async function requestOtp() { async function requestOtp() {
setError(null); setError(null);
setBusy(true); setBusy(true);
try { try {
const res = await fetch(`${process.env['NEXT_PUBLIC_API_URL'] ?? 'http://localhost:3001'}/public/auth/request-otp`, { const res = await fetch(`${API_BASE}/public/auth/request-otp`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ onboardingToken: token, phone }), body: JSON.stringify({ onboardingToken: token, phone }),
@@ -36,7 +74,7 @@ export function OnboardingForm({ token, defaultScopes, defaultRetentionDays }: P
} }
const data = (await res.json()) as { challengeId: string }; const data = (await res.json()) as { challengeId: string };
setChallengeId(data.challengeId); setChallengeId(data.challengeId);
setStep('code'); setStep('consent');
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -49,14 +87,7 @@ export function OnboardingForm({ token, defaultScopes, defaultRetentionDays }: P
const res = await fetch('/api/onboard/verify-otp', { const res = await fetch('/api/onboard/verify-otp', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ body: JSON.stringify({ onboardingToken: token, challengeId, phone, code, scopes, retentionDays }),
onboardingToken: token,
challengeId,
phone,
code,
scopes,
retentionDays,
}),
}); });
if (!res.ok) { if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { message?: string }; const body = (await res.json().catch(() => ({}))) as { message?: string };
@@ -69,95 +100,222 @@ export function OnboardingForm({ token, defaultScopes, defaultRetentionDays }: P
} }
} }
if (step === 'done') { function toggleScope(key: string, checked: boolean) {
return ( setScopes((prev) => (checked ? [...prev, key] : prev.filter((s) => s !== key)));
<div>
<p className="text-sm text-green-700 mb-4">You&apos;re verified. Your session is set.</p>
<a href="/my" className="inline-block px-4 py-2 bg-blue-600 text-white rounded text-sm">
Go to your portal
</a>
</div>
);
} }
if (step === 'phone') {
return ( return (
<div className="space-y-3"> <div>
<label className="block text-sm"> {/* Progress bar */}
<span className="font-medium">Your WhatsApp phone number</span> {step !== 'welcome' && step !== 'done' && (
<div className="mb-6">
<div className="h-1.5 w-full bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-600 transition-all duration-300"
style={{ width: `${progressPct}%` }}
/>
</div>
<p className="text-xs text-gray-400 mt-2">Step {stepIndex} of 3</p>
</div>
)}
{step === 'welcome' && (
<div className="space-y-5">
<div className="w-12 h-12 rounded-xl bg-indigo-100 flex items-center justify-center text-2xl">
👋
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900">Welcome to {groupName}</h2>
<p className="text-sm text-gray-500 mt-1">Managed by {tenantName}</p>
</div>
<p className="text-sm text-gray-600 leading-relaxed">
TOWER keeps your community&apos;s important messages organised, searchable, and shareable
while keeping you in control of your data. Let&apos;s get you set up. It takes about a minute.
</p>
<button
type="button"
onClick={() => setStep('phone')}
className="w-full rounded-lg bg-indigo-600 text-white text-sm font-medium py-2.5 hover:bg-indigo-700 transition-colors"
>
Get started
</button>
</div>
)}
{step === 'phone' && (
<div className="space-y-4">
<div>
<h2 className="text-base font-semibold text-gray-900">Your WhatsApp number</h2>
<p className="text-sm text-gray-500 mt-1">We&apos;ll send a 6-digit code to this number on WhatsApp.</p>
</div>
<input <input
type="tel" type="tel"
value={phone} value={phone}
onChange={(e) => setPhone(e.target.value)} onChange={(e) => setPhone(e.target.value)}
placeholder="+1 234 567 8900" placeholder="+91 98765 43210"
className="mt-1 w-full border rounded px-3 py-2" className="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
autoFocus
/> />
</label>
{error && <p className="text-sm text-red-600">{error}</p>} {error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex gap-2">
<button
type="button"
onClick={() => setStep('welcome')}
className="rounded-lg border border-gray-300 text-gray-600 text-sm font-medium px-4 py-2.5 hover:bg-gray-50"
>
Back
</button>
<button <button
type="button" type="button"
onClick={requestOtp} onClick={requestOtp}
disabled={busy || phone.length < 6} disabled={busy || phone.replace(/\D/g, '').length < 8}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50" className="flex-1 rounded-lg bg-indigo-600 text-white text-sm font-medium py-2.5 hover:bg-indigo-700 disabled:opacity-50 transition-colors"
> >
Send verification code {busy ? 'Sending…' : 'Send code'}
</button> </button>
<p className="text-xs text-gray-500"> </div>
We&apos;ll DM a 6-digit code to your WhatsApp. </div>
)}
{step === 'consent' && (
<div className="space-y-4">
<div>
<h2 className="text-base font-semibold text-gray-900">What you&apos;re agreeing to</h2>
<p className="text-sm text-gray-500 mt-1">
You stay in control change or revoke any of these anytime from your portal.
</p> </p>
</div> </div>
); <div className="space-y-2">
} {SCOPE_META.map((s) => {
const checked = scopes.includes(s.key);
return ( return (
<div className="space-y-3"> <label
<label className="block text-sm"> key={s.key}
<span className="font-medium">Verification code</span> className={`flex gap-3 rounded-lg border p-3 cursor-pointer transition-colors ${
<input checked ? 'border-indigo-300 bg-indigo-50/50' : 'border-gray-200 hover:bg-gray-50'
type="text" }`}
inputMode="numeric" >
pattern="[0-9]{6}"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="123456"
className="mt-1 w-full border rounded px-3 py-2"
/>
</label>
<fieldset className="text-sm">
<legend className="font-medium mb-1">Scopes</legend>
{['INGEST', 'ARCHIVE', 'REPLICATE', 'DISPLAY'].map((s) => (
<label key={s} className="block">
<input <input
type="checkbox" type="checkbox"
checked={scopes.includes(s)} checked={checked}
onChange={(e) => onChange={(e) => toggleScope(s.key, e.target.checked)}
setScopes((prev) => (e.target.checked ? [...prev, s] : prev.filter((x) => x !== s))) className="mt-0.5 w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 shrink-0"
} />
/>{' '} <span>
{s} <span className="block text-sm font-medium text-gray-900">{s.label}</span>
<span className="block text-xs text-gray-500 mt-0.5">{s.description}</span>
</span>
</label> </label>
))} );
</fieldset> })}
<label className="block text-sm"> </div>
<span className="font-medium">Retention (days)</span> <label className="block">
<span className="text-xs font-medium text-gray-700">Keep my messages for</span>
<div className="flex items-center gap-2 mt-1">
<input <input
type="number" type="number"
min={1} min={1}
max={3650} max={3650}
value={retentionDays} value={retentionDays}
onChange={(e) => setRetentionDays(Number(e.target.value))} onChange={(e) => setRetentionDays(Number(e.target.value))}
className="mt-1 w-32 border rounded px-3 py-2" className="w-24 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/> />
<span className="text-sm text-gray-500">days</span>
</div>
</label> </label>
{error && <p className="text-sm text-red-600">{error}</p>} {error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex gap-2">
<button
type="button"
onClick={() => setStep('phone')}
className="rounded-lg border border-gray-300 text-gray-600 text-sm font-medium px-4 py-2.5 hover:bg-gray-50"
>
Back
</button>
<button
type="button"
onClick={() => { setError(null); setStep('code'); }}
className="flex-1 rounded-lg bg-indigo-600 text-white text-sm font-medium py-2.5 hover:bg-indigo-700 transition-colors"
>
Continue
</button>
</div>
</div>
)}
{step === 'code' && (
<div className="space-y-4">
<div>
<h2 className="text-base font-semibold text-gray-900">Enter your code</h2>
<p className="text-sm text-gray-500 mt-1">
We sent a 6-digit code to <span className="font-medium text-gray-700">{phone}</span> on WhatsApp.
</p>
</div>
<input
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
placeholder="000000"
className="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-center text-lg tracking-[0.3em] font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500"
autoFocus
/>
<button
type="button"
onClick={requestOtp}
disabled={busy}
className="text-xs text-indigo-600 hover:underline disabled:opacity-50"
>
Didn&apos;t get it? Resend code
</button>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex gap-2">
<button
type="button"
onClick={() => setStep('consent')}
className="rounded-lg border border-gray-300 text-gray-600 text-sm font-medium px-4 py-2.5 hover:bg-gray-50"
>
Back
</button>
<button <button
type="button" type="button"
onClick={verifyOtp} onClick={verifyOtp}
disabled={busy || code.length < 6} disabled={busy || code.length < 6}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50" className="flex-1 rounded-lg bg-indigo-600 text-white text-sm font-medium py-2.5 hover:bg-indigo-700 disabled:opacity-50 transition-colors"
> >
Verify and join {busy ? 'Verifying…' : 'Verify & join'}
</button> </button>
</div> </div>
</div>
)}
{step === 'done' && (
<div className="space-y-5 text-center">
<div className="w-14 h-14 rounded-full bg-green-100 flex items-center justify-center text-2xl mx-auto">
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900">You&apos;re all set!</h2>
<p className="text-sm text-gray-500 mt-1">Welcome to {groupName}.</p>
</div>
<div className="text-left bg-gray-50 rounded-lg p-4 space-y-2">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide">In your portal you can</p>
<ul className="text-sm text-gray-600 space-y-1.5">
<li>📋 Read daily digests of what matters</li>
<li>📅 RSVP to community events</li>
<li>🔒 Manage your privacy &amp; consent anytime</li>
</ul>
</div>
<a
href="/my"
className="block w-full rounded-lg bg-indigo-600 text-white text-sm font-medium py-2.5 hover:bg-indigo-700 transition-colors"
>
Go to my portal
</a>
</div>
)}
</div>
); );
} }
+10 -8
View File
@@ -42,25 +42,27 @@ export default async function OnboardPage({
if (error || !info) { if (error || !info) {
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-2xl border border-red-200 bg-red-50">
<h1 className="text-lg font-semibold text-red-800">Cannot start onboarding</h1> <h1 className="text-lg font-semibold text-red-800">Cannot start onboarding</h1>
<p className="text-sm text-red-700">{error ?? 'Unknown error'}</p> <p className="text-sm text-red-700 mt-1">{error ?? 'Unknown error'}</p>
</div> </div>
); );
} }
return ( return (
<div className="max-w-md mx-auto mt-12 p-6 rounded-lg border border-gray-200 bg-white"> <div className="max-w-md mx-auto mt-12">
<h1 className="text-xl font-semibold mb-2">Join {info.groupName}</h1> <div className="p-6 sm:p-7 rounded-2xl border border-gray-200 bg-white shadow-sm">
<p className="text-sm text-gray-600 mb-1">Managed by {info.tenantName}</p>
<p className="text-xs text-gray-500 mb-4">
Policy version: {info.policyVersion} · Default retention: {info.defaultRetentionDays} days
</p>
<OnboardingForm <OnboardingForm
token={token} token={token}
groupName={info.groupName}
tenantName={info.tenantName}
defaultScopes={info.defaultScopes} defaultScopes={info.defaultScopes}
defaultRetentionDays={info.defaultRetentionDays} defaultRetentionDays={info.defaultRetentionDays}
/> />
</div> </div>
<p className="text-center text-xs text-gray-400 mt-4">
Policy {info.policyVersion} · Your data, your control
</p>
</div>
); );
} }