From a0dc94ce79e4f80935d903c39b4a3b6000127890 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 17 Jun 2026 16:30:58 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20member=20portal=20Sprint=205=20?= =?UTF-8?q?=E2=80=94=20onboarding=20portal=20experience=20+=20restore=20Th?= =?UTF-8?q?read=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/api/prisma/schema.prisma | 61 +++-- apps/web/app/onboard/OnboardingForm.tsx | 344 +++++++++++++++++------- apps/web/app/onboard/page.tsx | 26 +- 3 files changed, 309 insertions(+), 122 deletions(-) diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index b76e343..1b9e584 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -91,6 +91,7 @@ model Tenant { digestConfig DigestConfig? digests Digest[] events Event[] + threads Thread[] } enum AdminRole { @@ -235,6 +236,7 @@ model Group { consents ConsentRecord[] claimTokens GroupClaimToken[] groupAccesses GroupAccess[] + threads Thread[] @@unique([platform, platformId]) @@index([accountId]) @@ -247,37 +249,62 @@ model Group { // ============================================================================ model Message { - id String @id @default(cuid()) - tenantId String - tenant Tenant @relation(fields: [tenantId], references: [id]) - platform String - platformMsgId String - sourceGroupId String - sourceGroup Group @relation(fields: [sourceGroupId], references: [id]) - senderJid String - senderName String? - senderTowerUserId String? - senderTowerUser TowerUser? @relation("senderTowerUser", fields: [senderTowerUserId], references: [id]) - content String - mediaUrl String? - tags String[] - status MessageStatus @default(PENDING) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + tenantId String + tenant Tenant @relation(fields: [tenantId], references: [id]) + platform String + platformMsgId String + sourceGroupId String + sourceGroup Group @relation(fields: [sourceGroupId], references: [id]) + senderJid String + senderName String? + senderTowerUserId String? + senderTowerUser TowerUser? @relation("senderTowerUser", fields: [senderTowerUserId], references: [id]) + content String + mediaUrl String? + tags String[] + status MessageStatus @default(PENDING) + expiresAt DateTime? + quotedPlatformMsgId String? + threadId String? + thread Thread? @relation(fields: [threadId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt approval Approval? @@unique([platform, platformMsgId]) @@index([tenantId]) @@index([senderTowerUserId]) + @@index([status, expiresAt]) + @@index([threadId]) } enum MessageStatus { + RAW PENDING APPROVED REJECTED DISTRIBUTED 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 { diff --git a/apps/web/app/onboard/OnboardingForm.tsx b/apps/web/app/onboard/OnboardingForm.tsx index 2481f8a..43de356 100644 --- a/apps/web/app/onboard/OnboardingForm.tsx +++ b/apps/web/app/onboard/OnboardingForm.tsx @@ -4,14 +4,49 @@ import { useState } from 'react'; interface Props { token: string; + groupName: string; + tenantName: string; defaultScopes: string[]; defaultRetentionDays: number; } -type Step = 'phone' | 'code' | 'done'; +type Step = 'welcome' | 'phone' | 'consent' | 'code' | 'done'; -export function OnboardingForm({ token, defaultScopes, defaultRetentionDays }: Props) { - const [step, setStep] = useState('phone'); +const STEP_ORDER: Step[] = ['welcome', 'phone', 'consent', 'code', 'done']; + +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('welcome'); const [phone, setPhone] = useState(''); const [challengeId, setChallengeId] = useState(null); const [code, setCode] = useState(''); @@ -20,11 +55,14 @@ export function OnboardingForm({ token, defaultScopes, defaultRetentionDays }: P const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const stepIndex = STEP_ORDER.indexOf(step); + const progressPct = (stepIndex / (STEP_ORDER.length - 1)) * 100; + async function requestOtp() { setError(null); setBusy(true); 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', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, 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 }; setChallengeId(data.challengeId); - setStep('code'); + setStep('consent'); } finally { setBusy(false); } @@ -49,14 +87,7 @@ export function OnboardingForm({ token, defaultScopes, defaultRetentionDays }: P const res = await fetch('/api/onboard/verify-otp', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, - body: JSON.stringify({ - onboardingToken: token, - challengeId, - phone, - code, - scopes, - retentionDays, - }), + body: JSON.stringify({ onboardingToken: token, challengeId, phone, code, scopes, retentionDays }), }); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { message?: string }; @@ -69,95 +100,222 @@ export function OnboardingForm({ token, defaultScopes, defaultRetentionDays }: P } } - if (step === 'done') { - return ( -
-

You're verified. Your session is set.

- - Go to your portal → - -
- ); + function toggleScope(key: string, checked: boolean) { + setScopes((prev) => (checked ? [...prev, key] : prev.filter((s) => s !== key))); } - if (step === 'phone') { - return ( -
-