From 3a7175baa5a62124023f49a9c1e74828b399e5c2 Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Thu, 4 Jun 2026 06:15:22 +0100 Subject: [PATCH 001/389] fix(web): improve public profile and sidebar search --- .../commerce/search/dto/search-query.dto.ts | 9 +- .../domains/commerce/search/search.service.ts | 102 ++++++- apps/web/src/app/(public)/search/page.tsx | 94 +++++- .../u/[username]/PublicUserProfileClient.tsx | 273 ++++++++++++++++++ .../src/app/(public)/u/[username]/page.tsx | 113 ++------ .../buyer/settings/SettingsClient.tsx | 6 + apps/web/src/components/layout/RightPanel.tsx | 244 +++++++++++++++- 7 files changed, 735 insertions(+), 106 deletions(-) create mode 100644 apps/web/src/app/(public)/u/[username]/PublicUserProfileClient.tsx diff --git a/apps/backend/src/domains/commerce/search/dto/search-query.dto.ts b/apps/backend/src/domains/commerce/search/dto/search-query.dto.ts index 4af34c93..d37da243 100644 --- a/apps/backend/src/domains/commerce/search/dto/search-query.dto.ts +++ b/apps/backend/src/domains/commerce/search/dto/search-query.dto.ts @@ -14,7 +14,12 @@ import { const toOptionalString = (value: unknown): unknown => value == null ? value : String(value); -export type SearchResultType = "products" | "stores" | "posts" | "all"; +export type SearchResultType = + | "products" + | "stores" + | "users" + | "posts" + | "all"; export class SearchQueryDto { @Transform(({ value }) => toOptionalString(value)) @@ -25,7 +30,7 @@ export class SearchQueryDto { @Transform(({ value }) => toOptionalString(value)) @IsOptional() - @IsIn(["products", "stores", "posts", "all"]) + @IsIn(["products", "stores", "users", "posts", "all"]) type?: SearchResultType; @Transform(({ value }) => toOptionalString(value)) diff --git a/apps/backend/src/domains/commerce/search/search.service.ts b/apps/backend/src/domains/commerce/search/search.service.ts index d88417a5..85c1fc93 100644 --- a/apps/backend/src/domains/commerce/search/search.service.ts +++ b/apps/backend/src/domains/commerce/search/search.service.ts @@ -5,6 +5,7 @@ import { Prisma, ProductStatus, StoreTier, + UserRole, } from "@prisma/client"; import { PrismaService } from "../../../core/prisma/prisma.service"; @@ -47,6 +48,7 @@ export interface SearchPage { export interface GroupedSearchResponse { products: SearchPage; stores: SearchPage; + users: SearchPage; posts: SearchPage; } @@ -92,6 +94,17 @@ export interface StoreSearchResult { followerCount: number; } +export interface UserSearchResult { + id: string; + username: string | null; + displayName: string | null; + bio: string | null; + profilePhotoUrl: string | null; + followerCount: number; + followingCount: number; + postCount: number; +} + export interface PostSearchResult { id: string; type: PostType; @@ -131,6 +144,10 @@ type StoreRecord = Prisma.StoreProfileGetPayload<{ select: typeof STORE_SEARCH_SELECT; }>; +type UserRecord = Prisma.UserGetPayload<{ + select: typeof USER_SEARCH_SELECT; +}>; + type PostRecord = Prisma.PostGetPayload<{ select: typeof POST_SEARCH_SELECT; }>; @@ -201,6 +218,21 @@ const STORE_SEARCH_SELECT = { }, } satisfies Prisma.StoreProfileSelect; +const USER_SEARCH_SELECT = { + id: true, + username: true, + displayName: true, + bio: true, + profilePhotoUrl: true, + _count: { + select: { + followerRelations: true, + followingRelations: true, + posts: true, + }, + }, +} satisfies Prisma.UserSelect; + const POST_SEARCH_SELECT = { id: true, type: true, @@ -272,6 +304,7 @@ export class SearchService { | GroupedSearchResponse | SingleSearchResponse | SingleSearchResponse + | SingleSearchResponse | SingleSearchResponse > { const type = query.type ?? "all"; @@ -285,18 +318,23 @@ export class SearchService { const page = await this.searchStores(params); return { type, ...page }; } + if (type === "users") { + const page = await this.searchUsers(params); + return { type, ...page }; + } if (type === "posts") { const page = await this.searchPosts(params); return { type, ...page }; } - const [products, stores, posts] = await Promise.all([ + const [products, stores, users, posts] = await Promise.all([ this.searchProducts({ ...params, cursor: null }), this.searchStores({ ...params, cursor: null }), + this.searchUsers({ ...params, cursor: null }), this.searchPosts({ ...params, cursor: null }), ]); - return { products, stores, posts }; + return { products, stores, users, posts }; } async searchProducts( @@ -374,6 +412,52 @@ export class SearchService { }; } + async searchUsers( + params: NormalizedSearchParams, + ): Promise> { + this.assertCursorType(params.cursor, "users"); + const textFilter: Prisma.UserWhereInput = params.q + ? { + OR: [ + { + username: { + contains: params.q, + mode: Prisma.QueryMode.insensitive, + }, + }, + { + displayName: { + contains: params.q, + mode: Prisma.QueryMode.insensitive, + }, + }, + { bio: { contains: params.q, mode: Prisma.QueryMode.insensitive } }, + ], + } + : {}; + + const users: UserRecord[] = await this.prisma.user.findMany({ + where: { + role: UserRole.USER, + isActive: true, + deletedAt: null, + username: { not: null }, + ...textFilter, + }, + select: USER_SEARCH_SELECT, + orderBy: [{ updatedAt: "desc" }, { id: "asc" }], + cursor: params.cursor ? { id: params.cursor.id } : undefined, + skip: params.cursor ? 1 : 0, + take: params.limit + 1, + }); + + const page = this.takePage(users, params.limit); + return { + items: page.items.map((user) => this.toUserResult(user)), + nextCursor: this.encodeNextCursor("users", page.next), + }; + } + async searchPosts( params: NormalizedSearchParams, ): Promise> { @@ -759,6 +843,7 @@ export class SearchService { return ( (candidate.type === "products" || candidate.type === "stores" || + candidate.type === "users" || candidate.type === "posts") && typeof candidate.id === "string" && (candidate.score === undefined || typeof candidate.score === "number") @@ -886,6 +971,19 @@ export class SearchService { }; } + private toUserResult(user: UserRecord): UserSearchResult { + return { + id: user.id, + username: user.username, + displayName: user.displayName, + bio: user.bio, + profilePhotoUrl: user.profilePhotoUrl, + followerCount: user._count.followerRelations, + followingCount: user._count.followingRelations, + postCount: user._count.posts, + }; + } + private toPostResult(post: PostRecord): PostSearchResult { const taggedProduct = post.taggedProduct && diff --git a/apps/web/src/app/(public)/search/page.tsx b/apps/web/src/app/(public)/search/page.tsx index d7a1152d..894a9f52 100644 --- a/apps/web/src/app/(public)/search/page.tsx +++ b/apps/web/src/app/(public)/search/page.tsx @@ -1,11 +1,11 @@ import type { Metadata } from "next"; import Image from "next/image"; import Link from "next/link"; -import { Package, Store } from "lucide-react"; +import { Package, Store, UserRound } from "lucide-react"; import { AppShell } from "@/components/layout/AppShell"; import { EmptyState, ErrorState } from "@/components/ui/State"; -import { getPublicStoreHref } from "@/lib/routes"; +import { getPublicStoreHref, getPublicUserHref } from "@/lib/routes"; import { formatKobo } from "@/lib/utils"; export const dynamic = "force-dynamic"; @@ -40,9 +40,24 @@ interface ProductResult { }; } +interface UserResult { + id: string; + username: string | null; + displayName: string | null; + bio: string | null; + profilePhotoUrl: string | null; + followerCount: number; + postCount: number; +} + type SearchState = | { status: "idle" } - | { status: "loaded"; stores: StoreResult[]; products: ProductResult[] } + | { + status: "loaded"; + stores: StoreResult[]; + users: UserResult[]; + products: ProductResult[]; + } | { status: "error"; message: string }; function isRecord(value: unknown): value is Record { @@ -64,6 +79,11 @@ function isProductResult(value: unknown): value is ProductResult { return isRecord(value.store); } +function isUserResult(value: unknown): value is UserResult { + if (!isRecord(value) || typeof value.id !== "string") return false; + return typeof value.followerCount === "number"; +} + function normalizeProduct(value: ProductResult): ProductResult { return { id: value.id, @@ -103,21 +123,26 @@ async function fetchSearchResults(query: string): Promise { } const payload = (await response.json()) as unknown; - const body = isRecord(payload) && "data" in payload ? payload.data : payload; + const body = + isRecord(payload) && "data" in payload ? payload.data : payload; if (!isRecord(body)) { return { status: "error", message: "Search returned an invalid result." }; } const storesPage = isRecord(body.stores) ? body.stores : null; + const usersPage = isRecord(body.users) ? body.users : null; const productsPage = isRecord(body.products) ? body.products : null; const stores = Array.isArray(storesPage?.items) ? storesPage.items.filter(isStoreResult) : []; + const users = Array.isArray(usersPage?.items) + ? usersPage.items.filter(isUserResult) + : []; const products = Array.isArray(productsPage?.items) ? productsPage.items.filter(isProductResult).map(normalizeProduct) : []; - return { status: "loaded", stores, products }; + return { status: "loaded", stores, users, products }; } catch { return { status: "error", message: "Search is unavailable right now." }; } @@ -155,13 +180,28 @@ export default async function SearchPage({ searchParams }: SearchPageProps) { ) : null} {state.status === "loaded" ? ( - state.stores.length === 0 && state.products.length === 0 ? ( + state.stores.length === 0 && + state.users.length === 0 && + state.products.length === 0 ? ( ) : (
+ {state.users.length > 0 ? ( +
+

+ Users +

+
+ {state.users.map((user) => ( + + ))} +
+
+ ) : null} + {state.stores.length > 0 ? (

@@ -194,6 +234,46 @@ export default async function SearchPage({ searchParams }: SearchPageProps) { ); } +function UserSearchCard({ user }: { user: UserResult }) { + const username = user.username?.replace(/^@+/, "") ?? null; + const displayName = user.displayName ?? username ?? "twizrr user"; + + return ( + +
+
+ {user.profilePhotoUrl ? ( + + ) : ( +
+
+

+ {displayName} +

+ {username ? ( +

+ {username} +

+ ) : null} + {user.bio ? ( +

+ {user.bio} +

+ ) : null} +
+
+ + ); +} + function StoreSearchCard({ store }: { store: StoreResult }) { const handle = store.handle?.replace(/^@+/, "") ?? null; const content = ( diff --git a/apps/web/src/app/(public)/u/[username]/PublicUserProfileClient.tsx b/apps/web/src/app/(public)/u/[username]/PublicUserProfileClient.tsx new file mode 100644 index 00000000..d9d56fde --- /dev/null +++ b/apps/web/src/app/(public)/u/[username]/PublicUserProfileClient.tsx @@ -0,0 +1,273 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { + CalendarDays, + MessageCircle, + Pencil, + UserPlus, + UserRound, +} from "lucide-react"; +import { AppShell } from "@/components/layout"; +import { Badge } from "@/components/ui/Badge"; +import { Button } from "@/components/ui/Button"; +import { useOwnProfile } from "@/hooks/useOwnProfile"; +import { useOwnerStore } from "@/hooks/useOwnerStore"; +import { cn } from "@/lib/utils"; +import type { PublicUserProfile } from "./page"; + +interface PublicUserProfileClientProps { + user: PublicUserProfile; + requestedUsername: string; +} + +type ProfileTab = { + label: string; + body: string; + soon?: boolean; +}; + +const PROFILE_TABS: ProfileTab[] = [ + { label: "Posts", body: "Posts from this shopper will appear here soon." }, + { + label: "Replies", + body: "Replies will appear here once feed replies ship.", + }, + { label: "Photos", body: "Photo posts will appear here soon." }, + { + label: "Twizz", + body: "Short videos are coming in a later release.", + soon: true, + }, +] as const; + +export function PublicUserProfileClient({ + user, + requestedUsername, +}: PublicUserProfileClientProps) { + const { profile, isLoading: profileLoading } = useOwnProfile(); + const { hasStore, isLoading: ownerStoreLoading } = useOwnerStore(); + + const displayName = user.displayName || user.username || "twizrr user"; + const username = user.username ?? requestedUsername; + const viewerUsername = normalizeUsername(profile?.username); + const profileUsername = normalizeUsername(username); + const isOwnProfile = + Boolean(viewerUsername) && + Boolean(profileUsername) && + viewerUsername === profileUsername; + + return ( + +
+
+

+ Profile +

+

+ {displayName} +

+
+ +
+
+
+
+ {user.profilePhotoUrl ? ( + {displayName} + ) : ( +
+
+ )} +
+ +
+
+

+ {displayName} +

+ {isOwnProfile ? You : null} +
+ + {username ? ( +

+ {username} +

+ ) : null} + + {user.bio ? ( +

+ {user.bio} +

+ ) : ( +

+ This profile does not have a bio yet. +

+ )} +
+
+ + +
+ +
+ + + +
+ +
+

About

+
+ {user.memberSince ? ( +

+

+ ) : null} + {username ? ( +

+ Profile URL{" "} + + /u/{username} + +

+ ) : null} +
+
+
+ +
+
+ {PROFILE_TABS.map((tab, index) => ( + + ))} +
+ +
+
+

+ No public posts yet +

+

+ {PROFILE_TABS[0].body} +

+
+
+
+
+
+ ); +} + +function ProfileActions({ + isOwnProfile, + isResolvingViewer, +}: { + isOwnProfile: boolean; + isResolvingViewer: boolean; +}) { + if (isResolvingViewer) { + return ( +

+ + ); + } -function ProfileStat({ label, value }: { label: string; value: number }) { return ( -
-
- {label} -
-
- {value.toLocaleString("en-NG")} -
-
+ ); } -function formatMonthYear(value: string): string { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return "recently"; - return new Intl.DateTimeFormat("en-NG", { - month: "long", - year: "numeric", - }).format(date); -} - async function fetchPublicUser( username: string, ): Promise< diff --git a/apps/web/src/app/(shopper)/buyer/settings/SettingsClient.tsx b/apps/web/src/app/(shopper)/buyer/settings/SettingsClient.tsx index 069a8ac7..d62ab40f 100644 --- a/apps/web/src/app/(shopper)/buyer/settings/SettingsClient.tsx +++ b/apps/web/src/app/(shopper)/buyer/settings/SettingsClient.tsx @@ -2,6 +2,7 @@ import Image from "next/image"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { useEffect, useRef, useState, type ChangeEvent } from "react"; import { AlertCircle, @@ -23,6 +24,7 @@ import { type CroppedImageResult, } from "@/lib/image-crop"; import { cn } from "@/lib/utils"; +import { getPublicUserHref } from "@/lib/routes"; import { fetchOwnProfile, updateMe, @@ -103,6 +105,7 @@ function formatLockDate(iso: string | undefined): string | null { export function SettingsClient() { const { toast } = useToast(); + const router = useRouter(); const fileInputRef = useRef(null); const [state, setState] = useState("loading"); @@ -267,6 +270,9 @@ export function SettingsClient() { revokeObjectUrl(avatarDraft?.previewUrl); setAvatarDraft(null); toast("Changes saved.", { variant: "success" }); + if (persisted.username) { + router.push(getPublicUserHref(persisted.username)); + } } catch (err) { if (isUpdateProfileError(err) && err.code === "USERNAME_LOCK_ACTIVE") { const formatted = formatLockDate(err.unlocksAt); diff --git a/apps/web/src/components/layout/RightPanel.tsx b/apps/web/src/components/layout/RightPanel.tsx index 0409d474..d99dfbeb 100644 --- a/apps/web/src/components/layout/RightPanel.tsx +++ b/apps/web/src/components/layout/RightPanel.tsx @@ -1,11 +1,39 @@ "use client"; -import { FormEvent, useEffect, useState } from "react"; +import { FormEvent, type ReactNode, useEffect, useState } from "react"; +import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { Hash, Search, Store } from "lucide-react"; +import { Hash, Search, Store, UserRound } from "lucide-react"; import { api, type FeedResponse, type StoreCardFeedItem } from "@/lib/api"; -import { getPublicStoreHref } from "@/lib/routes"; +import { getPublicStoreHref, getPublicUserHref } from "@/lib/routes"; + +interface SearchStoreResult { + id: string; + handle: string | null; + name: string | null; + bio: string | null; + logoUrl: string | null; + tier: string; +} + +interface SearchUserResult { + id: string; + username: string | null; + displayName: string | null; + bio: string | null; + profilePhotoUrl: string | null; + followerCount: number; +} + +interface SidebarSearchResponse { + stores?: { + items?: SearchStoreResult[]; + }; + users?: { + items?: SearchUserResult[]; + }; +} interface TrendingHashtag { name: string; @@ -44,6 +72,11 @@ export function RightPanel() { const [query, setQuery] = useState(""); const [stores, setStores] = useState([]); const [hashtags, setHashtags] = useState([]); + const [searchState, setSearchState] = useState< + "idle" | "loading" | "loaded" | "error" + >("idle"); + const [matchedStores, setMatchedStores] = useState([]); + const [matchedUsers, setMatchedUsers] = useState([]); useEffect(() => { let active = true; @@ -78,6 +111,52 @@ export function RightPanel() { }; }, []); + useEffect(() => { + let active = true; + const nextQuery = query.trim(); + + if (nextQuery.length < 2) { + setSearchState("idle"); + setMatchedStores([]); + setMatchedUsers([]); + return () => { + active = false; + }; + } + + setSearchState("loading"); + + const timeoutId = setTimeout(() => { + api + .get( + `/search?q=${encodeURIComponent(nextQuery)}&type=all&limit=5`, + ) + .then((results) => { + if (!active) { + return; + } + + setMatchedStores(results.stores?.items ?? []); + setMatchedUsers(results.users?.items ?? []); + setSearchState("loaded"); + }) + .catch(() => { + if (!active) { + return; + } + + setMatchedStores([]); + setMatchedUsers([]); + setSearchState("error"); + }); + }, 250); + + return () => { + active = false; + clearTimeout(timeoutId); + }; + }, [query]); + function handleSubmit(event: FormEvent) { event.preventDefault(); const nextQuery = query.trim(); @@ -110,6 +189,65 @@ export function RightPanel() { + {query.trim().length >= 2 ? ( +
+
+

+ Search matches +

+ + View all + +
+ + {searchState === "loading" ? ( +
+ + + +
+ ) : null} + + {searchState === "error" ? ( +

+ Search is unavailable right now. +

+ ) : null} + + {searchState === "loaded" ? ( + matchedStores.length > 0 || matchedUsers.length > 0 ? ( +
+ {matchedUsers.length > 0 ? ( + + {matchedUsers.map((user) => ( + + ))} + + ) : null} + + {matchedStores.length > 0 ? ( + + {matchedStores.map((store) => ( + + ))} + + ) : null} +
+ ) : ( +

+ No matching stores or users found. +

+ ) + ) : null} +
+ ) : null} +
); } + +function SearchResultGroup({ + title, + children, +}: { + title: string; + children: ReactNode; +}) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + +function UserSearchResultRow({ user }: { user: SearchUserResult }) { + const username = cleanHandle(user.username); + const displayName = user.displayName ?? username ?? "twizrr user"; + + return ( + + + {user.profilePhotoUrl ? ( + + ) : ( + + )} + + + + {displayName} + + {username ? ( + + {username} + + ) : null} + + + ); +} + +function StoreSearchResultRow({ store }: { store: SearchStoreResult }) { + const handle = cleanHandle(store.handle); + + return ( + + + {store.logoUrl ? ( + + ) : ( + + )} + + + + {store.name ?? "Verified store"} + + {handle ? ( + + {handle} + + ) : null} + + + ); +} + +function SearchSkeletonRow() { + return ( +
+
+
+
+
+
+
+ ); +} From 1fc8af9e341c5f351ce63e8f0425eab2cd213e33 Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Thu, 4 Jun 2026 07:06:50 +0100 Subject: [PATCH 002/389] fix: address profile search review feedback --- .../domains/commerce/search/search.service.ts | 24 ++++++++++++-- .../src/domains/users/user/user.service.ts | 31 +++++++++++++++++-- apps/web/src/components/layout/RightPanel.tsx | 18 +++++------ 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/apps/backend/src/domains/commerce/search/search.service.ts b/apps/backend/src/domains/commerce/search/search.service.ts index 85c1fc93..657383c1 100644 --- a/apps/backend/src/domains/commerce/search/search.service.ts +++ b/apps/backend/src/domains/commerce/search/search.service.ts @@ -15,6 +15,24 @@ const DEFAULT_LIMIT = 20; const MAX_LIMIT = 50; const POSTGRES_BIGINT_MAX = 9223372036854775807n; const VECTOR_DIMENSION = 1408; +const PUBLIC_POST_COUNT_WHERE = { + type: { + in: [PostType.PRODUCT_POST, PostType.IMAGE_POST, PostType.GIST], + }, + isActive: true, + moderationStatus: { not: ModerationStatus.BLOCKED }, + OR: [ + { taggedProductId: null }, + { + taggedProduct: { + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + storeProfile: { tier: { not: StoreTier.TIER_0 } }, + }, + }, + ], +} satisfies Prisma.PostWhereInput; type SingleSearchType = Exclude; @@ -228,7 +246,7 @@ const USER_SEARCH_SELECT = { select: { followerRelations: true, followingRelations: true, - posts: true, + posts: { where: PUBLIC_POST_COUNT_WHERE }, }, }, } satisfies Prisma.UserSelect; @@ -399,7 +417,7 @@ export class SearchService { ...textFilter, }, select: STORE_SEARCH_SELECT, - orderBy: [{ updatedAt: "desc" }, { id: "asc" }], + orderBy: [{ createdAt: "desc" }, { id: "asc" }], cursor: params.cursor ? { id: params.cursor.id } : undefined, skip: params.cursor ? 1 : 0, take: params.limit + 1, @@ -445,7 +463,7 @@ export class SearchService { ...textFilter, }, select: USER_SEARCH_SELECT, - orderBy: [{ updatedAt: "desc" }, { id: "asc" }], + orderBy: [{ createdAt: "desc" }, { id: "asc" }], cursor: params.cursor ? { id: params.cursor.id } : undefined, skip: params.cursor ? 1 : 0, take: params.limit + 1, diff --git a/apps/backend/src/domains/users/user/user.service.ts b/apps/backend/src/domains/users/user/user.service.ts index ce1dcf8d..7a0d8b18 100644 --- a/apps/backend/src/domains/users/user/user.service.ts +++ b/apps/backend/src/domains/users/user/user.service.ts @@ -4,7 +4,14 @@ import { Injectable, NotFoundException, } from "@nestjs/common"; -import { FollowTargetType, Prisma } from "@prisma/client"; +import { + FollowTargetType, + ModerationStatus, + PostType, + Prisma, + ProductStatus, + StoreTier, +} from "@prisma/client"; import { randomUUID } from "crypto"; import { PrismaService } from "../../../core/prisma/prisma.service"; @@ -14,6 +21,24 @@ import { UpdateUserDto } from "./dto/update-user.dto"; const USERNAME_LOCK_DAYS = 7; const USERNAME_LOCK_MS = USERNAME_LOCK_DAYS * 24 * 60 * 60 * 1000; +const PUBLIC_POST_COUNT_WHERE = { + type: { + in: [PostType.PRODUCT_POST, PostType.IMAGE_POST, PostType.GIST], + }, + isActive: true, + moderationStatus: { not: ModerationStatus.BLOCKED }, + OR: [ + { taggedProductId: null }, + { + taggedProduct: { + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + storeProfile: { tier: { not: StoreTier.TIER_0 } }, + }, + }, + ], +} satisfies Prisma.PostWhereInput; interface UserCountShape { followerRelations: number; @@ -462,7 +487,7 @@ export class UserService { select: { followerRelations: true, followingRelations: true, - posts: true, + posts: { where: PUBLIC_POST_COUNT_WHERE }, }, }, } satisfies Prisma.UserSelect; @@ -480,7 +505,7 @@ export class UserService { select: { followerRelations: true, followingRelations: true, - posts: true, + posts: { where: PUBLIC_POST_COUNT_WHERE }, }, }, } satisfies Prisma.UserSelect; diff --git a/apps/web/src/components/layout/RightPanel.tsx b/apps/web/src/components/layout/RightPanel.tsx index d99dfbeb..014317c7 100644 --- a/apps/web/src/components/layout/RightPanel.tsx +++ b/apps/web/src/components/layout/RightPanel.tsx @@ -127,11 +127,11 @@ export function RightPanel() { setSearchState("loading"); const timeoutId = setTimeout(() => { - api - .get( - `/search?q=${encodeURIComponent(nextQuery)}&type=all&limit=5`, - ) - .then((results) => { + void (async () => { + try { + const results = await api.get( + `/search?q=${encodeURIComponent(nextQuery)}&type=all&limit=5`, + ); if (!active) { return; } @@ -139,8 +139,7 @@ export function RightPanel() { setMatchedStores(results.stores?.items ?? []); setMatchedUsers(results.users?.items ?? []); setSearchState("loaded"); - }) - .catch(() => { + } catch { if (!active) { return; } @@ -148,7 +147,8 @@ export function RightPanel() { setMatchedStores([]); setMatchedUsers([]); setSearchState("error"); - }); + } + })(); }, 250); return () => { @@ -462,7 +462,7 @@ function StoreSearchResultRow({ store }: { store: SearchStoreResult }) { function SearchSkeletonRow() { return ( -
+
From 14fc743b7ffd416c8ede51090a15c3f58e3fe921 Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Fri, 5 Jun 2026 16:44:24 +0100 Subject: [PATCH 003/389] feat(whatsapp): add account linking and guest discovery flow --- apps/backend/src/channels/whatsapp/AGENTS.md | 10 +- .../channels/whatsapp/image-search.service.ts | 4 +- .../whatsapp/whatsapp-auth.service.spec.ts | 111 +++ .../whatsapp/whatsapp-auth.service.ts | 21 +- ...whatsapp-product-discovery.service.spec.ts | 97 +++ .../whatsapp-product-discovery.service.ts | 97 +++ .../whatsapp/whatsapp-session.service.spec.ts | 96 +++ .../whatsapp/whatsapp-session.service.ts | 109 +++ .../whatsapp/whatsapp-shared.module.ts | 6 + .../whatsapp/whatsapp.service.spec.ts | 270 +++++++ .../src/channels/whatsapp/whatsapp.service.ts | 160 +++- .../src/modules/auth/auth.controller.ts | 8 + .../src/modules/auth/auth.service.spec.ts | 89 +++ apps/backend/src/modules/auth/auth.service.ts | 77 +- apps/web/.env.example | 2 +- apps/web/AGENTS.md | 2 +- apps/web/WEB_DESIGN.md | 4 +- .../whatsapp/WhatsAppAssistantClient.tsx | 703 ++++++++++++++++++ apps/web/src/app/(app)/whatsapp/page.tsx | 11 + apps/web/src/components/layout/AppShell.tsx | 2 - .../web/src/components/layout/WhatsAppFAB.tsx | 14 - apps/web/src/components/layout/index.ts | 1 - apps/web/src/components/layout/navigation.ts | 17 +- apps/web/src/lib/route-policy.ts | 1 + 24 files changed, 1865 insertions(+), 47 deletions(-) create mode 100644 apps/backend/src/channels/whatsapp/whatsapp-auth.service.spec.ts create mode 100644 apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.spec.ts create mode 100644 apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.ts create mode 100644 apps/backend/src/channels/whatsapp/whatsapp-session.service.spec.ts create mode 100644 apps/backend/src/channels/whatsapp/whatsapp-session.service.ts create mode 100644 apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts create mode 100644 apps/backend/src/modules/auth/auth.service.spec.ts create mode 100644 apps/web/src/app/(app)/whatsapp/WhatsAppAssistantClient.tsx create mode 100644 apps/web/src/app/(app)/whatsapp/page.tsx delete mode 100644 apps/web/src/components/layout/WhatsAppFAB.tsx diff --git a/apps/backend/src/channels/whatsapp/AGENTS.md b/apps/backend/src/channels/whatsapp/AGENTS.md index 6202c8e0..c152c354 100644 --- a/apps/backend/src/channels/whatsapp/AGENTS.md +++ b/apps/backend/src/channels/whatsapp/AGENTS.md @@ -53,7 +53,7 @@ src/channels/whatsapp/ ├── whatsapp.controller.ts ← webhook endpoint ├── whatsapp.processor.ts ← BullMQ consumer ├── services/ -│ ├── session.service.ts ← consent + session per phone number +├── whatsapp-session.service.ts ← consent + session per phone number │ ├── intent.service.ts ← Gemini function-calling + routing │ ├── search.service.ts ← hybrid search pipeline │ ├── vision.service.ts ← Cloud Vision + Vertex AI image processing @@ -137,7 +137,7 @@ export class WhatsAppProcessor { const { from, type, text, image } = message // STEP 1: Check/create session + consent - const session = await this.sessionService.getOrCreate(from) + const session = await this.whatsappSessionService.recordInbound(from, userId) if (!session.consentGiven) { await this.templateService.sendConsentTemplate(from) return @@ -195,7 +195,7 @@ CONSENT FLOW: 2. getOrCreate(phone) → creates session with consentGiven = false 3. Processor checks: consentGiven = false → send consent template 4. Shopper taps [I Agree] button on consent template -5. Interactive reply received → updateConsent(phone) → consentGiven = true +5. Interactive reply received → markConsentGiven(phone) → consentGiven = true 6. Next message → normal flow begins CONSENT TEMPLATE (twizrr_onboarding_consent): @@ -215,7 +215,9 @@ If shopper taps [No Thanks]: ACCOUNT LINKING: └── Shopper can link their WhatsApp to their twizrr account Command: "link my account" OR via twizrr.com settings - Sends OTP to phone → enters OTP → session.userId updated + Web-first: sends OTP to WhatsApp → enters OTP on /whatsapp + WhatsApp-first: asks for email → sends OTP to email → replies with OTP + Once verified → WhatsAppLink created and session.userId updated Once linked: personalised results, order history accessible via WhatsApp ``` diff --git a/apps/backend/src/channels/whatsapp/image-search.service.ts b/apps/backend/src/channels/whatsapp/image-search.service.ts index dca25dcc..9d1cbd1e 100644 --- a/apps/backend/src/channels/whatsapp/image-search.service.ts +++ b/apps/backend/src/channels/whatsapp/image-search.service.ts @@ -82,7 +82,7 @@ export class ImageSearchService { if (fallbackProducts.length > 0) { const rows = fallbackProducts.map((product) => ({ - id: `buy_${product.id}_1`, + id: `product_${product.id}`, title: product.name.substring(0, 24), description: `NGN ${(Number(product.retailPriceKobo || product.pricePerUnitKobo || 0) / 100).toLocaleString("en-NG")} | ${product.storeProfile?.businessName || "Verified Shop"}`.substring( @@ -130,7 +130,7 @@ export class ImageSearchService { } const rows = products.slice(0, 10).map((product) => ({ - id: `buy_${product.id}_1`, + id: `product_${product.id}`, title: product.name.substring(0, 24), description: `NGN ${(Number(product.retailPriceKobo || product.pricePerUnitKobo || 0) / 100).toLocaleString("en-NG")} | ${product.storeProfile?.businessName || "Verified Shop"}`.substring( diff --git a/apps/backend/src/channels/whatsapp/whatsapp-auth.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-auth.service.spec.ts new file mode 100644 index 00000000..c6eb2ecf --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-auth.service.spec.ts @@ -0,0 +1,111 @@ +import { WhatsAppAuthService } from "./whatsapp-auth.service"; +import { + SessionState, + WA_OTP_PREFIX, + WA_SESSION_PREFIX, +} from "./whatsapp.constants"; + +describe("WhatsAppAuthService account linking", () => { + const configService = { + get: jest.fn(), + }; + const prisma = { + user: { + findUnique: jest.fn(), + }, + whatsAppLink: { + upsert: jest.fn(), + }, + }; + const redisService = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + }; + const emailService = { + sendVerificationOTP: jest.fn(), + }; + const whatsappSessionService = { + normalizePhone: jest.fn(), + attachUser: jest.fn(), + }; + + let service: WhatsAppAuthService; + + beforeEach(() => { + jest.clearAllMocks(); + configService.get.mockReturnValue(undefined); + whatsappSessionService.normalizePhone.mockImplementation((phone: string) => + phone.startsWith("+") ? phone : `+${phone}`, + ); + service = new WhatsAppAuthService( + configService as any, + prisma as any, + redisService as any, + emailService as any, + whatsappSessionService as any, + ); + }); + + it("creates WhatsAppLink and attaches session after WhatsApp-first email OTP verification", async () => { + const phone = "+2348012345678"; + const sessionKey = `${WA_SESSION_PREFIX}${phone}`; + const otpKey = `${WA_OTP_PREFIX}${phone}`; + redisService.get.mockImplementation(async (key: string) => { + if (key === sessionKey) { + return JSON.stringify({ + state: SessionState.AWAITING_OTP, + data: { + userId: "user-1", + userName: "Ameen", + }, + }); + } + + if (key === otpKey) { + return "123456"; + } + + return null; + }); + + const response = await service.handleLinkingFlow(phone, "123456"); + + expect(prisma.whatsAppLink.upsert).toHaveBeenCalledWith({ + where: { phone }, + update: { userId: "user-1", isActive: true }, + create: { phone, userId: "user-1", isActive: true }, + }); + expect(whatsappSessionService.attachUser).toHaveBeenCalledWith( + phone, + "user-1", + ); + expect(response).toContain("Account linked."); + }); + + it("rejects WhatsApp-first linking when user already has a different WhatsAppLink", async () => { + const phone = "+2348012345678"; + redisService.get.mockResolvedValue( + JSON.stringify({ + state: SessionState.AWAITING_EMAIL, + data: {}, + }), + ); + prisma.user.findUnique.mockResolvedValue({ + id: "user-1", + firstName: "Ameen", + whatsappLink: { phone: "+2349012345678" }, + }); + + const response = await service.handleLinkingFlow( + phone, + "ameen@example.com", + ); + + expect(response).toBe( + "This phone number is already linked to a twizrr account.", + ); + expect(redisService.set).not.toHaveBeenCalled(); + expect(emailService.sendVerificationOTP).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-auth.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-auth.service.ts index 0acae04c..05067ad3 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp-auth.service.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp-auth.service.ts @@ -16,6 +16,7 @@ import { INVALID_OTP, OTP_EXPIRED, } from "./whatsapp.constants"; +import { WhatsAppSessionService } from "./whatsapp-session.service"; interface SessionData { state: SessionState; @@ -44,12 +45,15 @@ export class WhatsAppAuthService { private prisma: PrismaService, private redisService: RedisService, private emailService: EmailService, + private whatsappSessionService: WhatsAppSessionService, ) {} async resolvePhone(phone: string): Promise { + const normalizedPhone = this.whatsappSessionService.normalizePhone(phone); + try { const link = await this.prisma.whatsAppLink.findUnique({ - where: { phone }, + where: { phone: normalizedPhone }, select: { userId: true, isActive: true }, }); @@ -65,7 +69,8 @@ export class WhatsAppAuthService { } async handleLinkingFlow(phone: string, messageText: string): Promise { - const sessionKey = `${WA_SESSION_PREFIX}${phone}`; + const normalizedPhone = this.whatsappSessionService.normalizePhone(phone); + const sessionKey = `${WA_SESSION_PREFIX}${normalizedPhone}`; try { const sessionRaw = await this.redisService.get(sessionKey); @@ -90,10 +95,15 @@ export class WhatsAppAuthService { switch (session.state) { case SessionState.AWAITING_EMAIL: - return this.handleEmailStep(phone, messageText, sessionKey); + return this.handleEmailStep(normalizedPhone, messageText, sessionKey); case SessionState.AWAITING_OTP: - return this.handleOtpStep(phone, messageText, sessionKey, session); + return this.handleOtpStep( + normalizedPhone, + messageText, + sessionKey, + session, + ); default: await this.redisService.del(sessionKey); @@ -200,6 +210,7 @@ export class WhatsAppAuthService { update: { userId: session.data.userId, isActive: true }, create: { phone, userId: session.data.userId, isActive: true }, }); + await this.whatsappSessionService.attachUser(phone, session.data.userId); } catch (error) { this.logger.error( `Failed to create WhatsAppLink for ${maskPhone(phone)}: ${ @@ -217,6 +228,6 @@ export class WhatsAppAuthService { `WhatsApp linked: phone=${maskPhone(phone)}, userId=${session.data.userId}`, ); - return `Account linked. ✅\n\nHi ${session.data.userName || "there"}, you can now search products, track orders, and confirm delivery from WhatsApp.`; + return `Account linked.\n\nHi ${session.data.userName || "there"}, you can now search products, track orders, and confirm delivery from WhatsApp.`; } } diff --git a/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.spec.ts new file mode 100644 index 00000000..1fb68f14 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.spec.ts @@ -0,0 +1,97 @@ +import { Prisma } from "@prisma/client"; +import { WhatsAppProductDiscoveryService } from "./whatsapp-product-discovery.service"; + +describe("WhatsAppProductDiscoveryService", () => { + const prisma = { + product: { + findMany: jest.fn(), + }, + }; + const interactiveService = { + sendTextMessage: jest.fn(), + sendReplyButtons: jest.fn(), + sendListMessage: jest.fn(), + }; + + let service: WhatsAppProductDiscoveryService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new WhatsAppProductDiscoveryService( + prisma as any, + interactiveService as any, + ); + }); + + it("sends generic product results without user or personalized filters", async () => { + prisma.product.findMany.mockResolvedValue([ + { + id: "product-1", + name: "Red Shoes", + retailPriceKobo: 250000n, + pricePerUnitKobo: null, + storeProfile: { businessName: "Style Hub" }, + }, + ]); + + await service.sendGenericProductSearch("2348012345678", "red shoes"); + + expect(prisma.product.findMany).toHaveBeenCalledWith({ + where: { + isActive: true, + OR: [ + { + name: { + contains: "red shoes", + mode: Prisma.QueryMode.insensitive, + }, + }, + { + title: { + contains: "red shoes", + mode: Prisma.QueryMode.insensitive, + }, + }, + { + shortDescription: { + contains: "red shoes", + mode: Prisma.QueryMode.insensitive, + }, + }, + { + description: { + contains: "red shoes", + mode: Prisma.QueryMode.insensitive, + }, + }, + { + categoryTag: { + contains: "red shoes", + mode: Prisma.QueryMode.insensitive, + }, + }, + ], + }, + include: { storeProfile: { select: { businessName: true } } }, + take: 10, + orderBy: { createdAt: "desc" }, + }); + expect(interactiveService.sendListMessage).toHaveBeenCalledWith( + "2348012345678", + 'Here are products matching "red shoes".', + "View products", + [ + { + title: "Products", + rows: [ + { + id: "product_product-1", + title: "Red Shoes", + description: "NGN 2,500 | Style Hub", + }, + ], + }, + ], + ); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.ts new file mode 100644 index 00000000..44e2884a --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.ts @@ -0,0 +1,97 @@ +import { Injectable } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; +import { PrismaService } from "../../prisma/prisma.service"; +import { WhatsAppInteractiveService } from "./whatsapp-interactive.service"; + +@Injectable() +export class WhatsAppProductDiscoveryService { + constructor( + private prisma: PrismaService, + private interactiveService: WhatsAppInteractiveService, + ) {} + + async sendGenericProductSearch(phone: string, query: string): Promise { + const searchText = query.trim(); + + if (!searchText) { + await this.interactiveService.sendTextMessage( + phone, + "Tell me what you want to find. For example, send a product name, category, colour, or style.", + ); + return; + } + + const products = await this.searchProducts(searchText); + + if (products.length === 0) { + await this.interactiveService.sendReplyButtons( + phone, + `I couldn't find products matching "${searchText}" right now. Try a different product name or browse categories.`, + [ + { id: "browse_categories", title: "Browse" }, + { id: "search_products", title: "Search again" }, + ], + ); + return; + } + + const rows = products.slice(0, 10).map((product) => ({ + id: `product_${product.id}`, + title: product.name.substring(0, 24), + description: this.formatProductDescription(product).substring(0, 72), + })); + + await this.interactiveService.sendListMessage( + phone, + `Here are products matching "${searchText}".`, + "View products", + [{ title: "Products", rows }], + ); + } + + private async searchProducts(query: string) { + return this.prisma.product.findMany({ + where: { + isActive: true, + OR: [ + { name: { contains: query, mode: Prisma.QueryMode.insensitive } }, + { title: { contains: query, mode: Prisma.QueryMode.insensitive } }, + { + shortDescription: { + contains: query, + mode: Prisma.QueryMode.insensitive, + }, + }, + { + description: { + contains: query, + mode: Prisma.QueryMode.insensitive, + }, + }, + { + categoryTag: { + contains: query, + mode: Prisma.QueryMode.insensitive, + }, + }, + ], + }, + include: { storeProfile: { select: { businessName: true } } }, + take: 10, + orderBy: { createdAt: "desc" }, + }); + } + + private formatProductDescription(product: { + retailPriceKobo?: bigint | number | null; + pricePerUnitKobo?: bigint | number | null; + storeProfile?: { businessName?: string | null } | null; + }): string { + const amountKobo = product.retailPriceKobo ?? product.pricePerUnitKobo ?? 0; + const amountNaira = Number(amountKobo) / 100; + const price = amountNaira.toLocaleString("en-NG"); + const storeName = product.storeProfile?.businessName || "Verified shop"; + + return `NGN ${price} | ${storeName}`; + } +} diff --git a/apps/backend/src/channels/whatsapp/whatsapp-session.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-session.service.spec.ts new file mode 100644 index 00000000..3bfcdd5f --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-session.service.spec.ts @@ -0,0 +1,96 @@ +import { WhatsAppSessionService } from "./whatsapp-session.service"; + +describe("WhatsAppSessionService", () => { + const prisma = { + whatsAppSession: { + upsert: jest.fn(), + updateMany: jest.fn(), + }, + }; + + let service: WhatsAppSessionService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new WhatsAppSessionService(prisma as any); + }); + + it("normalizes Nigerian WhatsApp numbers to E.164 format", () => { + expect(service.normalizePhone("2348012345678")).toBe("+2348012345678"); + expect(service.normalizePhone("08012345678")).toBe("+2348012345678"); + expect(service.normalizePhone("+2348012345678")).toBe("+2348012345678"); + }); + + it("upserts an inbound WhatsApp session and preserves linked user context", async () => { + prisma.whatsAppSession.upsert.mockResolvedValue({ + phone: "+2348012345678", + userId: "user-1", + consentGiven: false, + }); + + await service.recordInbound("2348012345678", "user-1"); + + expect(prisma.whatsAppSession.upsert).toHaveBeenCalledWith({ + where: { phone: "+2348012345678" }, + update: expect.objectContaining({ + userId: "user-1", + lastMessageAt: expect.any(Date), + }), + create: expect.objectContaining({ + phone: "+2348012345678", + userId: "user-1", + lastMessageAt: expect.any(Date), + }), + }); + }); + + it("records consent separately from account linking", async () => { + prisma.whatsAppSession.upsert.mockResolvedValue({ + phone: "+2348012345678", + consentGiven: true, + }); + + await service.markConsentGiven("2348012345678"); + + expect(prisma.whatsAppSession.upsert).toHaveBeenCalledWith({ + where: { phone: "+2348012345678" }, + update: expect.objectContaining({ + consentGiven: true, + consentAt: expect.any(Date), + lastMessageAt: expect.any(Date), + }), + create: expect.objectContaining({ + phone: "+2348012345678", + consentGiven: true, + consentAt: expect.any(Date), + lastMessageAt: expect.any(Date), + }), + }); + }); + + it("attaches an existing WhatsApp session to a verified user account", async () => { + await service.attachUser("2348012345678", "user-1"); + + expect(prisma.whatsAppSession.updateMany).toHaveBeenCalledWith({ + where: { phone: "+2348012345678" }, + data: { userId: "user-1" }, + }); + }); + + it("detects consent accept and decline replies", () => { + expect(service.isConsentAcceptance("I agree")).toBe(true); + expect( + service.isConsentAcceptance(undefined, { + id: "twizrr_consent_accept", + title: "I Agree", + }), + ).toBe(true); + expect(service.isConsentDecline("No Thanks")).toBe(true); + expect( + service.isConsentDecline(undefined, { + id: "twizrr_consent_decline", + title: "No Thanks", + }), + ).toBe(true); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-session.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-session.service.ts new file mode 100644 index 00000000..c529a55a --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-session.service.ts @@ -0,0 +1,109 @@ +import { Injectable } from "@nestjs/common"; +import { WhatsAppSession } from "@prisma/client"; +import { PrismaService } from "../../prisma/prisma.service"; + +export const WHATSAPP_CONSENT_ACCEPT_ID = "twizrr_consent_accept"; +export const WHATSAPP_CONSENT_DECLINE_ID = "twizrr_consent_decline"; + +export const WHATSAPP_CONSENT_MESSAGE = + "Welcome to twizrr Shopping Assistant.\n\nBy continuing, you agree that twizrr may process your WhatsApp messages to help you find and purchase products. You can stop anytime by replying No Thanks."; + +export const WHATSAPP_CONSENT_DECLINED_MESSAGE = + "No problem. You can start shopping anytime by messaging us again."; + +@Injectable() +export class WhatsAppSessionService { + constructor(private readonly prisma: PrismaService) {} + + normalizePhone(phone: string): string { + const cleaned = phone.trim().replace(/[\s\-()]/g, ""); + + if (cleaned.startsWith("+")) { + return cleaned; + } + + if (cleaned.startsWith("0")) { + return `+234${cleaned.slice(1)}`; + } + + return `+${cleaned}`; + } + + async recordInbound( + phone: string, + userId?: string | null, + ): Promise { + const normalizedPhone = this.normalizePhone(phone); + return this.prisma.whatsAppSession.upsert({ + where: { phone: normalizedPhone }, + update: { + lastMessageAt: new Date(), + ...(userId ? { userId } : {}), + }, + create: { + phone: normalizedPhone, + userId: userId ?? null, + lastMessageAt: new Date(), + }, + }); + } + + async markConsentGiven(phone: string): Promise { + const normalizedPhone = this.normalizePhone(phone); + return this.prisma.whatsAppSession.upsert({ + where: { phone: normalizedPhone }, + update: { + consentGiven: true, + consentAt: new Date(), + lastMessageAt: new Date(), + }, + create: { + phone: normalizedPhone, + consentGiven: true, + consentAt: new Date(), + lastMessageAt: new Date(), + }, + }); + } + + async attachUser(phone: string, userId: string): Promise { + await this.prisma.whatsAppSession.updateMany({ + where: { phone: this.normalizePhone(phone) }, + data: { userId }, + }); + } + + isConsentAcceptance( + messageText?: string, + interactiveReply?: { id: string; title: string }, + ): boolean { + const id = interactiveReply?.id?.toLowerCase(); + const title = interactiveReply?.title?.toLowerCase(); + const text = messageText?.trim().toLowerCase(); + + return ( + id === WHATSAPP_CONSENT_ACCEPT_ID || + title === "i agree" || + text === "i agree" || + text === "agree" || + text === "yes" + ); + } + + isConsentDecline( + messageText?: string, + interactiveReply?: { id: string; title: string }, + ): boolean { + const id = interactiveReply?.id?.toLowerCase(); + const title = interactiveReply?.title?.toLowerCase(); + const text = messageText?.trim().toLowerCase(); + + return ( + id === WHATSAPP_CONSENT_DECLINE_ID || + title === "no thanks" || + text === "no thanks" || + text === "no" || + text === "stop" + ); + } +} diff --git a/apps/backend/src/channels/whatsapp/whatsapp-shared.module.ts b/apps/backend/src/channels/whatsapp/whatsapp-shared.module.ts index a8ed53cd..080f99d1 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp-shared.module.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp-shared.module.ts @@ -10,6 +10,8 @@ import { ImageSearchService } from "./image-search.service"; import { WhatsAppInteractiveService } from "./whatsapp-interactive.service"; import { WhatsAppIntentService } from "./whatsapp-intent.service"; import { WhatsAppLoggerService } from "./whatsapp-logger.service"; +import { WhatsAppProductDiscoveryService } from "./whatsapp-product-discovery.service"; +import { WhatsAppSessionService } from "./whatsapp-session.service"; @Module({ imports: [ @@ -25,12 +27,16 @@ import { WhatsAppLoggerService } from "./whatsapp-logger.service"; WhatsAppIntentService, ImageSearchService, WhatsAppLoggerService, + WhatsAppProductDiscoveryService, + WhatsAppSessionService, ], exports: [ WhatsAppInteractiveService, WhatsAppIntentService, ImageSearchService, WhatsAppLoggerService, + WhatsAppProductDiscoveryService, + WhatsAppSessionService, AiModule, CloudinaryModule, MetaWhatsAppModule, diff --git a/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts new file mode 100644 index 00000000..91215d74 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts @@ -0,0 +1,270 @@ +import { WhatsAppService } from "./whatsapp.service"; +import { + WHATSAPP_CONSENT_ACCEPT_ID, + WHATSAPP_CONSENT_DECLINE_ID, + WHATSAPP_CONSENT_DECLINED_MESSAGE, + WHATSAPP_CONSENT_MESSAGE, +} from "./whatsapp-session.service"; +import { FRIENDLY_FALLBACK } from "./whatsapp.constants"; + +describe("WhatsAppService", () => { + const configService = { get: jest.fn() }; + const prisma = {}; + const authService = { + resolvePhone: jest.fn(), + handleLinkingFlow: jest.fn(), + }; + const intentService = { + parseIntent: jest.fn(), + }; + const imageSearchService = { + handleImageSearch: jest.fn(), + }; + const productDiscoveryService = { + sendGenericProductSearch: jest.fn(), + }; + const redisService = { + get: jest.fn(), + del: jest.fn(), + }; + const interactiveService = { + sendTextMessage: jest.fn(), + sendReplyButtons: jest.fn(), + }; + const metaWhatsAppClient = { + sendTextMessage: jest.fn(), + }; + const whatsappSessionService = { + normalizePhone: jest.fn(), + recordInbound: jest.fn(), + isConsentDecline: jest.fn(), + isConsentAcceptance: jest.fn(), + markConsentGiven: jest.fn(), + }; + + let service: WhatsAppService; + + beforeEach(() => { + jest.clearAllMocks(); + configService.get.mockReturnValue(undefined); + redisService.get.mockResolvedValue(null); + whatsappSessionService.normalizePhone.mockImplementation((phone: string) => + phone.startsWith("+") ? phone : `+${phone}`, + ); + service = new WhatsAppService( + configService as any, + prisma as any, + authService as any, + intentService as any, + imageSearchService as any, + productDiscoveryService as any, + redisService as any, + interactiveService as any, + metaWhatsAppClient as any, + whatsappSessionService as any, + ); + }); + + it("creates a persistent session and sends consent before processing assistant intents", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + consentGiven: false, + }); + whatsappSessionService.isConsentDecline.mockReturnValue(false); + whatsappSessionService.isConsentAcceptance.mockReturnValue(false); + + await service.processMessage("2348012345678", "find shoes", "message-1"); + + expect(whatsappSessionService.recordInbound).toHaveBeenCalledWith( + "+2348012345678", + null, + ); + expect(interactiveService.sendReplyButtons).toHaveBeenCalledWith( + "2348012345678", + WHATSAPP_CONSENT_MESSAGE, + [ + { id: WHATSAPP_CONSENT_ACCEPT_ID, title: "I Agree" }, + { id: WHATSAPP_CONSENT_DECLINE_ID, title: "No Thanks" }, + ], + ); + expect(intentService.parseIntent).not.toHaveBeenCalled(); + expect(imageSearchService.handleImageSearch).not.toHaveBeenCalled(); + }); + + it("records consent and shows the menu for unlinked phones", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + consentGiven: false, + }); + whatsappSessionService.isConsentDecline.mockReturnValue(false); + whatsappSessionService.isConsentAcceptance.mockReturnValue(true); + whatsappSessionService.markConsentGiven.mockResolvedValue({ + consentGiven: true, + }); + + await service.processMessage("2348012345678", undefined, "message-1", { + type: "button_reply", + id: WHATSAPP_CONSENT_ACCEPT_ID, + title: "I Agree", + }); + + expect(whatsappSessionService.markConsentGiven).toHaveBeenCalledWith( + "+2348012345678", + ); + expect(authService.handleLinkingFlow).not.toHaveBeenCalled(); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("Send a product name or photo to search"), + ); + }); + + it("does not process assistant intents when consent is declined", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + consentGiven: false, + }); + whatsappSessionService.isConsentDecline.mockReturnValue(true); + whatsappSessionService.isConsentAcceptance.mockReturnValue(false); + + await service.processMessage("2348012345678", "no thanks", "message-1"); + + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + WHATSAPP_CONSENT_DECLINED_MESSAGE, + ); + expect(intentService.parseIntent).not.toHaveBeenCalled(); + }); + + it("continues normal assistant flow for linked phones with consent", async () => { + authService.resolvePhone.mockResolvedValue("user-1"); + whatsappSessionService.recordInbound.mockResolvedValue({ + consentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "friendly_fallback", + }); + + await service.processMessage("2348012345678", "hello", "message-1"); + + expect(authService.handleLinkingFlow).not.toHaveBeenCalled(); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + FRIENDLY_FALLBACK, + ); + }); + + it("allows consented unlinked text search as generic product discovery", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + consentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "search_products", + params: { query: "red shoes" }, + }); + + await service.processMessage( + "2348012345678", + "find red shoes", + "message-1", + ); + + expect(authService.handleLinkingFlow).not.toHaveBeenCalled(); + expect( + productDiscoveryService.sendGenericProductSearch, + ).toHaveBeenCalledWith("2348012345678", "red shoes"); + }); + + it("allows consented unlinked image search", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + consentGiven: true, + }); + + await service.processMessage( + "2348012345678", + undefined, + "message-1", + undefined, + "image-1", + ); + + expect(intentService.parseIntent).not.toHaveBeenCalled(); + expect(imageSearchService.handleImageSearch).toHaveBeenCalledWith( + "2348012345678", + "image-1", + ); + }); + + it("asks consented unlinked users to link before checkout actions", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + consentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "search_products", + params: { query: "checkout this product" }, + }); + + await service.processMessage( + "2348012345678", + "checkout this product", + "message-1", + ); + + expect( + productDiscoveryService.sendGenericProductSearch, + ).not.toHaveBeenCalled(); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("Please link your twizrr account"), + ); + }); + + it("asks consented unlinked users to link before order tracking", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + consentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "track_order", + params: {}, + }); + + await service.processMessage( + "2348012345678", + "where is my order", + "message-1", + ); + + expect( + productDiscoveryService.sendGenericProductSearch, + ).not.toHaveBeenCalled(); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("Please link your twizrr account"), + ); + }); + + it("lets linked users reach account action handlers", async () => { + authService.resolvePhone.mockResolvedValue("user-1"); + whatsappSessionService.recordInbound.mockResolvedValue({ + consentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "track_order", + params: {}, + }); + + await service.processMessage( + "2348012345678", + "where is my order", + "message-1", + ); + + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("Your account is linked"), + ); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/whatsapp.service.ts b/apps/backend/src/channels/whatsapp/whatsapp.service.ts index f4b08a98..69c889e0 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp.service.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp.service.ts @@ -7,6 +7,14 @@ import { WhatsAppIntentService } from "./whatsapp-intent.service"; import { ImageSearchService } from "./image-search.service"; import { WhatsAppInteractiveService } from "./whatsapp-interactive.service"; import { FRIENDLY_FALLBACK } from "./whatsapp.constants"; +import { WhatsAppProductDiscoveryService } from "./whatsapp-product-discovery.service"; +import { + WHATSAPP_CONSENT_ACCEPT_ID, + WHATSAPP_CONSENT_DECLINE_ID, + WHATSAPP_CONSENT_DECLINED_MESSAGE, + WHATSAPP_CONSENT_MESSAGE, + WhatsAppSessionService, +} from "./whatsapp-session.service"; import { MetaWhatsAppClient } from "../../integrations/meta-whatsapp/meta-whatsapp.client"; function maskPhone(phone: string): string { @@ -26,9 +34,11 @@ export class WhatsAppService { private authService: WhatsAppAuthService, private intentService: WhatsAppIntentService, private imageSearchService: ImageSearchService, + private productDiscoveryService: WhatsAppProductDiscoveryService, private redisService: RedisService, private interactiveService: WhatsAppInteractiveService, private metaWhatsAppClient: MetaWhatsAppClient, + private whatsappSessionService: WhatsAppSessionService, ) {} async processMessage( @@ -39,7 +49,6 @@ export class WhatsAppService { imageId?: string, ): Promise { void messageId; - void interactiveReply; const pauseKey = `ai_paused_${phone}`; const isPaused = await this.redisService.get(pauseKey); @@ -59,24 +68,90 @@ export class WhatsAppService { } try { - if (imageId) { - await this.imageSearchService.handleImageSearch(phone, imageId); - return; - } + const normalizedPhone = this.whatsappSessionService.normalizePhone(phone); + const userId = await this.authService.resolvePhone(normalizedPhone); + const session = await this.whatsappSessionService.recordInbound( + normalizedPhone, + userId, + ); + + if (!session.consentGiven) { + if ( + this.whatsappSessionService.isConsentDecline( + messageText, + interactiveReply, + ) + ) { + await this.interactiveService.sendTextMessage( + phone, + WHATSAPP_CONSENT_DECLINED_MESSAGE, + ); + return; + } + + if ( + this.whatsappSessionService.isConsentAcceptance( + messageText, + interactiveReply, + ) + ) { + await this.whatsappSessionService.markConsentGiven(normalizedPhone); + + await this.sendShopperMenu(phone); + return; + } - const userId = await this.authService.resolvePhone(phone); - if (!userId) { - const response = await this.authService.handleLinkingFlow( + await this.interactiveService.sendReplyButtons( phone, - messageText || "", + WHATSAPP_CONSENT_MESSAGE, + [ + { id: WHATSAPP_CONSENT_ACCEPT_ID, title: "I Agree" }, + { id: WHATSAPP_CONSENT_DECLINE_ID, title: "No Thanks" }, + ], ); - await this.interactiveService.sendTextMessage(phone, response); + return; + } + + if (imageId) { + await this.imageSearchService.handleImageSearch(phone, imageId); return; } const intent = await this.intentService.parseIntent(messageText || ""); + if ( + this.isAccountActionRequest(messageText || "", intent.functionName) && + intent.functionName !== "track_order" && + intent.functionName !== "confirm_delivery" + ) { + if (!(await this.requireLinkedAccount(phone, userId))) { + return; + } + + await this.sendLinkedAccountActionPlaceholder(phone, "commerce"); + return; + } + switch (intent.functionName) { + case "search_products": + await this.productDiscoveryService.sendGenericProductSearch( + phone, + this.getSearchQuery(intent.params, messageText), + ); + return; + + case "track_order": + case "confirm_delivery": + if (!(await this.requireLinkedAccount(phone, userId))) { + return; + } + + await this.sendLinkedAccountActionPlaceholder( + phone, + intent.functionName, + ); + return; + case "show_menu": await this.sendShopperMenu(phone); return; @@ -101,6 +176,71 @@ export class WhatsAppService { } } + private isAccountActionRequest( + messageText: string, + functionName?: string, + ): boolean { + if (functionName === "track_order" || functionName === "confirm_delivery") { + return true; + } + + const lower = messageText.toLowerCase(); + return [ + "buy", + "checkout", + "cart", + "wishlist", + "save", + "saved", + "payment", + "pay", + "order", + "delivery confirmation", + "confirm delivery", + ].some((keyword) => lower.includes(keyword)); + } + + private getSearchQuery( + params: Record | undefined, + fallback?: string, + ): string { + const query = params?.query; + return typeof query === "string" && query.trim() ? query : fallback || ""; + } + + private async requireLinkedAccount( + phone: string, + userId: string | null, + ): Promise { + if (userId) { + return true; + } + + await this.interactiveService.sendTextMessage( + phone, + "Please link your twizrr account before checkout, orders, cart, wishlist, delivery confirmation, or account support. Reply with your registered email address to connect this WhatsApp number, or open twizrr.com/whatsapp while signed in.", + ); + + return false; + } + + private async sendLinkedAccountActionPlaceholder( + phone: string, + action: string, + ): Promise { + const actionLabel = + action === "track_order" + ? "track orders" + : action === "confirm_delivery" + ? "confirm delivery" + : "continue with checkout or account actions"; + + await this.interactiveService.sendTextMessage( + phone, + `Your account is linked. To ${actionLabel}, open your twizrr dashboard for now. WhatsApp account actions are coming next.`, + ); + } + async sendDirectOrderNotification( storeId: string, metadata: Record, diff --git a/apps/backend/src/modules/auth/auth.controller.ts b/apps/backend/src/modules/auth/auth.controller.ts index e724379c..1b853a28 100644 --- a/apps/backend/src/modules/auth/auth.controller.ts +++ b/apps/backend/src/modules/auth/auth.controller.ts @@ -147,6 +147,14 @@ export class AuthController { return this.authService.verifyPhoneOtp(dto, user.sub); } + @UseGuards(JwtAuthGuard) + @Throttle({ default: { limit: 30, ttl: 60000 } }) + @Get("whatsapp/status") + @HttpCode(HttpStatus.OK) + async getWhatsAppStatus(@CurrentUser() user: JwtPayload) { + return this.authService.getWhatsAppStatus(user.sub); + } + @UseGuards(JwtAuthGuard) @Throttle({ default: { limit: 3, ttl: 60000 } }) @Post("whatsapp/link") diff --git a/apps/backend/src/modules/auth/auth.service.spec.ts b/apps/backend/src/modules/auth/auth.service.spec.ts new file mode 100644 index 00000000..ca107f8d --- /dev/null +++ b/apps/backend/src/modules/auth/auth.service.spec.ts @@ -0,0 +1,89 @@ +import { ConflictException } from "@nestjs/common"; +import { AuthService } from "./auth.service"; + +describe("AuthService WhatsApp linking", () => { + const redis = { + get: jest.fn(), + del: jest.fn(), + }; + const tx = { + whatsAppLink: { + findUnique: jest.fn(), + upsert: jest.fn(), + }, + user: { + update: jest.fn(), + }, + whatsAppSession: { + updateMany: jest.fn(), + }, + }; + const prisma = { + user: { + findUnique: jest.fn(), + }, + $transaction: jest.fn(), + }; + + let service: AuthService; + + beforeEach(() => { + jest.clearAllMocks(); + service = Object.create(AuthService.prototype) as AuthService; + Object.assign(service as any, { prisma, redis }); + redis.get.mockResolvedValue("123456"); + prisma.user.findUnique.mockResolvedValue({ + id: "user-1", + phone: "+2348012345678", + }); + prisma.$transaction.mockImplementation(async (callback: any) => + callback(tx), + ); + tx.whatsAppLink.findUnique.mockResolvedValue(null); + }); + + it("marks User.phoneVerified when verified WhatsApp phone equals User.phone", async () => { + await service.verifyWhatsAppLink("user-1", "08012345678", "123456"); + + expect(tx.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { phoneVerified: true }, + }); + expect(tx.whatsAppLink.upsert).toHaveBeenCalledWith({ + where: { userId: "user-1" }, + update: { phone: "+2348012345678", isActive: true }, + create: { userId: "user-1", phone: "+2348012345678", isActive: true }, + }); + }); + + it("does not mark User.phoneVerified when verified WhatsApp phone differs from User.phone", async () => { + await service.verifyWhatsAppLink("user-1", "09012345678", "123456"); + + expect(tx.user.update).not.toHaveBeenCalled(); + expect(tx.whatsAppLink.upsert).toHaveBeenCalledWith({ + where: { userId: "user-1" }, + update: { phone: "+2349012345678", isActive: true }, + create: { userId: "user-1", phone: "+2349012345678", isActive: true }, + }); + }); + + it("attaches an existing WhatsAppSession to the authenticated user", async () => { + await service.verifyWhatsAppLink("user-1", "09012345678", "123456"); + + expect(tx.whatsAppSession.updateMany).toHaveBeenCalledWith({ + where: { phone: "+2349012345678" }, + data: { userId: "user-1" }, + }); + }); + + it("rejects verify-time phone conflicts before upserting WhatsAppLink", async () => { + tx.whatsAppLink.findUnique.mockResolvedValue({ userId: "user-2" }); + + await expect( + service.verifyWhatsAppLink("user-1", "09012345678", "123456"), + ).rejects.toBeInstanceOf(ConflictException); + + expect(tx.whatsAppLink.upsert).not.toHaveBeenCalled(); + expect(tx.whatsAppSession.updateMany).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/modules/auth/auth.service.ts b/apps/backend/src/modules/auth/auth.service.ts index 04edd15b..8ec5051c 100644 --- a/apps/backend/src/modules/auth/auth.service.ts +++ b/apps/backend/src/modules/auth/auth.service.ts @@ -386,6 +386,55 @@ export class AuthService { }; } + async getWhatsAppStatus(userId: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { + phone: true, + phoneVerified: true, + whatsappLink: { + select: { + phone: true, + linkedAt: true, + isActive: true, + }, + }, + }, + }); + + if (!user) { + throw new NotFoundException("User not found"); + } + + const linkedPhone = + user.whatsappLink?.isActive === true ? user.whatsappLink.phone : null; + const session = linkedPhone + ? await this.prisma.whatsAppSession.findUnique({ + where: { phone: linkedPhone }, + select: { + consentGiven: true, + consentAt: true, + lastMessageAt: true, + }, + }) + : null; + + return { + phone: user.phone, + phoneVerified: user.phoneVerified, + isLinked: Boolean(linkedPhone), + linkedPhone, + linkedAt: linkedPhone ? (user.whatsappLink?.linkedAt ?? null) : null, + assistantSession: linkedPhone + ? { + consentGiven: session?.consentGiven ?? false, + consentAt: session?.consentAt ?? null, + lastMessageAt: session?.lastMessageAt ?? null, + } + : null, + }; + } + async forgotPassword(dto: ForgotPasswordDto): Promise<{ message: string }> { const user = await this.prisma.user.findUnique({ where: { email: dto.email }, @@ -760,19 +809,39 @@ export class AuthService { const user = await this.prisma.user.findUnique({ where: { id: userId }, + select: { id: true, phone: true }, }); if (!user) throw new NotFoundException("User not found"); await this.prisma.$transaction(async (tx) => { + const existingLink = await tx.whatsAppLink.findUnique({ + where: { phone: normalizedPhone }, + select: { userId: true }, + }); + + if (existingLink && existingLink.userId !== userId) { + throw new ConflictException({ + code: "WHATSAPP_PHONE_ALREADY_LINKED", + message: "This WhatsApp number is already linked to another account.", + }); + } + await tx.whatsAppLink.upsert({ where: { userId }, update: { phone: normalizedPhone, isActive: true }, create: { userId, phone: normalizedPhone, isActive: true }, }); - // Also mark phone as verified on the main user record - await tx.user.update({ - where: { id: userId }, - data: { phoneVerified: true }, + + if (normalizePhone(user.phone) === normalizedPhone) { + await tx.user.update({ + where: { id: userId }, + data: { phoneVerified: true }, + }); + } + + await tx.whatsAppSession.updateMany({ + where: { phone: normalizedPhone }, + data: { userId }, }); }); diff --git a/apps/web/.env.example b/apps/web/.env.example index 089b3929..745635af 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -17,7 +17,7 @@ NEXT_PUBLIC_API_URL=http://localhost:4000 NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY= # ── WHATSAPP ────────────────────────────────── -# The WhatsApp number displayed in the UI and on the FAB button +# Public WhatsApp number used by the shopping assistant page CTA NEXT_PUBLIC_WHATSAPP_NUMBER= # ── PLATFORM ────────────────────────────────── diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 5556a20a..797b34e8 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -136,7 +136,7 @@ apps/web/src/ │ │ ├── RightPanel.tsx ← Right context panel (desktop only, 300px) │ │ │ Search bar + Suggested stores + Trending + New stores │ │ ├── StoreMenuDrawer.tsx ← Store mode [≡] side drawer -│ │ └── WhatsAppFAB.tsx ← Floating WhatsApp button (bottom-right) +│ │ └── navigation.ts ← Includes WhatsApp assistant sidebar link │ │ │ ├── product/ ← Product components │ │ ├── ProductCard.tsx ← Feed card (full-bleed in center column) diff --git a/apps/web/WEB_DESIGN.md b/apps/web/WEB_DESIGN.md index e7525bd8..b16a675e 100644 --- a/apps/web/WEB_DESIGN.md +++ b/apps/web/WEB_DESIGN.md @@ -311,7 +311,7 @@ PRIMARY BRAND: #E8A020 (Saffron) --color-badge-berry: #C2185B; /* ── Third-party Brand Colors ────────────── */ - --color-whatsapp: #25D366; /* WhatsApp FAB — intentional */ + --color-whatsapp: #25D366; /* WhatsApp assistant link — intentional */ --color-whatsapp-dark: #128C7E; /* ── Spacing Base Unit: 4px ─────────────── */ @@ -1103,7 +1103,7 @@ COLOUR: Error: #EF4444 Info: #3B82F6 -EXCEPTION — WhatsApp FAB: +EXCEPTION — WhatsApp assistant link: Uses WhatsApp brand green (#25D366) — intentional third-party branding Not a twizrr colour — do not use #25D366 for anything else ``` diff --git a/apps/web/src/app/(app)/whatsapp/WhatsAppAssistantClient.tsx b/apps/web/src/app/(app)/whatsapp/WhatsAppAssistantClient.tsx new file mode 100644 index 00000000..d7148e4b --- /dev/null +++ b/apps/web/src/app/(app)/whatsapp/WhatsAppAssistantClient.tsx @@ -0,0 +1,703 @@ +"use client"; + +import { + useCallback, + useEffect, + useMemo, + useState, + type FormEvent, +} from "react"; +import { + ArrowUpRight, + BadgeCheck, + Bot, + CheckCircle2, + Circle, + Clock, + Link2, + MessageCircle, + ShieldCheck, +} from "lucide-react"; +import { Button } from "@/components/ui/Button"; +import { Input } from "@/components/ui/Input"; +import { api, type ApiError } from "@/lib/api"; +import { cn } from "@/lib/utils"; + +type LoadState = "loading" | "ready" | "error"; +type LinkStep = "idle" | "code-sent"; +type PendingAction = "send" | "verify" | null; +type ReadinessState = "done" | "waiting" | "pending"; + +interface WhatsAppStatus { + phone?: string | null; + phoneVerified?: boolean | null; + isLinked?: boolean | null; + linkedPhone?: string | null; + linkedAt?: string | null; + assistantSession?: { + consentGiven?: boolean | null; + consentAt?: string | null; + lastMessageAt?: string | null; + } | null; +} + +const WHATSAPP_MESSAGE = "Hi twizrr, I want to shop"; + +export function WhatsAppAssistantClient() { + const [loadState, setLoadState] = useState("loading"); + const [phone, setPhone] = useState(""); + const [code, setCode] = useState(""); + const [status, setStatus] = useState(null); + const [linkStep, setLinkStep] = useState("idle"); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [pendingAction, setPendingAction] = useState(null); + + const whatsappHref = useMemo(() => getWhatsAppHref(), []); + const canOpenWhatsApp = Boolean(whatsappHref); + const isLinked = status?.isLinked === true; + + const loadStatus = useCallback(async () => { + setLoadState("loading"); + setError(null); + + try { + const data = await api.get("/auth/whatsapp/status"); + + setStatus(data); + setPhone(data.linkedPhone ?? data.phone ?? ""); + setLoadState("ready"); + } catch { + setStatus(null); + setLoadState("error"); + setError("We could not check your WhatsApp connection right now."); + } + }, []); + + useEffect(() => { + void loadStatus(); + }, [loadStatus]); + + async function sendCode(event: FormEvent) { + event.preventDefault(); + const normalizedPhone = normalizeNigerianPhone(phone); + + if (!normalizedPhone) { + setError("Enter a valid Nigerian WhatsApp number."); + return; + } + + setPendingAction("send"); + setError(null); + setSuccess(null); + + try { + await api.post<{ message?: string }>("/auth/whatsapp/link", { + phone: normalizedPhone, + }); + setPhone(normalizedPhone); + setCode(""); + setLinkStep("code-sent"); + setSuccess("Verification code sent to WhatsApp."); + } catch (err) { + setError(getApiErrorMessage(err, "Could not send the WhatsApp code.")); + } finally { + setPendingAction(null); + } + } + + async function verifyCode(event: FormEvent) { + event.preventDefault(); + const normalizedPhone = normalizeNigerianPhone(phone); + const cleanCode = code.trim(); + + if (!normalizedPhone) { + setError("Enter a valid Nigerian WhatsApp number."); + return; + } + + if (!/^\d{6}$/.test(cleanCode)) { + setError("Enter the 6-digit code sent to WhatsApp."); + return; + } + + setPendingAction("verify"); + setError(null); + setSuccess(null); + + try { + await api.post<{ message?: string }>("/auth/whatsapp/link/verify", { + phone: normalizedPhone, + code: cleanCode, + }); + const nextStatus = await api.get("/auth/whatsapp/status"); + setStatus(nextStatus); + setCode(""); + setLinkStep("idle"); + setSuccess("WhatsApp is now connected to your twizrr account."); + } catch (err) { + setError(getApiErrorMessage(err, "Could not verify the WhatsApp code.")); + } finally { + setPendingAction(null); + } + } + + return ( +
+
+

+ WhatsApp +

+

+ Twizrr shopping assistant +

+

+ Chat with Twizrr on WhatsApp to discover products, continue shopping, + and receive order updates from the assistant. +

+
+ +
+ + + + + {loadState === "loading" ? ( + + ) : loadState === "error" ? ( + + ) : isLinked ? ( + + ) : ( + + )} + + {(error || success) && ( +
+ {error ?? success} +
+ )} +
+
+ ); +} + +function HowItWorksCard() { + const steps = [ + "Verify your WhatsApp number so Twizrr can attach it to your account.", + "Open WhatsApp and accept the assistant consent prompt if it appears.", + "Shop, search, track orders, and confirm delivery inside WhatsApp.", + ]; + + return ( +
+

+ How it works +

+
    + {steps.map((step, index) => ( +
  1. + + {index + 1} + + + {step} + +
  2. + ))} +
+
+ ); +} + +function ActionLoadingCard() { + return ( +
+
+ +
+ + +
+
+
+ ); +} + +function ConnectionErrorCard({ onRetry }: { onRetry: () => void }) { + return ( +
+
+ + +
+

+ Connection status unavailable +

+

+ Try again before linking your number so we do not mistake a + temporary connection issue for an unlinked account. +

+ +
+
+
+ ); +} + +function ShopOnWhatsAppCard({ + href, + canOpenWhatsApp, + status, +}: { + href: string | null; + canOpenWhatsApp: boolean; + status: WhatsAppStatus | null; +}) { + const session = status?.assistantSession; + const hasConsent = session?.consentGiven === true; + const helperText = hasConsent + ? "Your account is linked and consent is recorded. Open WhatsApp to continue shopping with Twizrr." + : "Your account is linked. Open WhatsApp and accept the consent prompt before the assistant starts shopping with you."; + + return ( +
+
+ + +
+

+ Shop on WhatsApp +

+

+ {helperText} +

+
+ {status?.linkedPhone ? ( +

+ Linked number{" "} + + {maskPhone(status.linkedPhone)} + +

+ ) : null} + {status?.linkedAt ? ( +

Linked {formatDate(status.linkedAt)}

+ ) : null} + {session?.lastMessageAt ? ( +

Last assistant message {formatDate(session.lastMessageAt)}

+ ) : null} +
+
+
+ +
+ + Shop on WhatsApp + + {!canOpenWhatsApp ? ( +

+ Add NEXT_PUBLIC_WHATSAPP_NUMBER to enable this link. +

+ ) : null} +
+
+ ); +} + +function StatusCard({ + isLoading, + isError, + status, +}: { + isLoading: boolean; + isError: boolean; + status: WhatsAppStatus | null; +}) { + const isLinked = status?.isLinked === true; + const hasConsent = status?.assistantSession?.consentGiven === true; + const statusLabel = isLoading + ? "Checking connection" + : isError + ? "Status unavailable" + : isLinked + ? hasConsent + ? "Connected and ready" + : "Connected" + : "Not connected"; + const statusDescription = isError + ? "We could not confirm your WhatsApp connection status right now." + : isLinked + ? hasConsent + ? "Your account is linked and the assistant can continue your WhatsApp shopping session." + : "Your account is linked. The assistant will confirm consent in WhatsApp before shopping starts." + : "Connect your account so WhatsApp chats can match your Twizrr profile."; + + return ( +
+
+
+ + {isLinked ? ( + +
+

+ Account connection +

+

+ {statusDescription} +

+
+
+ + {statusLabel} + +
+
+ ); +} + +function ReadinessCard({ + isLoading, + isError, + status, +}: { + isLoading: boolean; + isError: boolean; + status: WhatsAppStatus | null; +}) { + const isLinked = status?.isLinked === true; + const hasConsent = status?.assistantSession?.consentGiven === true; + const hasSession = Boolean(status?.assistantSession); + + const items: Array<{ + title: string; + description: string; + state: ReadinessState; + }> = [ + { + title: "Account link", + description: isLinked + ? "This WhatsApp number is attached to your Twizrr account." + : "Verify your WhatsApp number so account history can be used safely.", + state: isLinked ? "done" : "pending", + }, + { + title: "Assistant consent", + description: hasConsent + ? "Consent is recorded for the WhatsApp assistant." + : isLinked + ? "Open WhatsApp and accept the consent prompt if the assistant asks." + : "Consent is recorded only after you start the assistant on WhatsApp.", + state: hasConsent ? "done" : isLinked ? "waiting" : "pending", + }, + { + title: "WhatsApp session", + description: hasSession + ? "Twizrr has seen activity from the linked WhatsApp number." + : isLinked + ? "No WhatsApp assistant activity is recorded yet for this number." + : "A previous WhatsApp session can be attached after number verification.", + state: hasSession ? "done" : "pending", + }, + ]; + + return ( +
+
+ + +
+

+ Assistant readiness +

+

+ Twizrr separates phone verification from assistant consent. Both + must be ready before the WhatsApp assistant can personalize + shopping. +

+
+
+ +
+ {items.map((item) => ( +
+ +
+

+ {item.title} +

+

+ {isLoading + ? "Checking current status..." + : isError + ? "Status unavailable right now." + : item.description} +

+
+
+ ))} +
+
+ ); +} + +function ReadinessIcon({ state }: { state: ReadinessState }) { + if (state === "done") { + return ( +
); } diff --git a/apps/web/src/components/layout/WhatsAppFAB.tsx b/apps/web/src/components/layout/WhatsAppFAB.tsx deleted file mode 100644 index ca9fb327..00000000 --- a/apps/web/src/components/layout/WhatsAppFAB.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import Link from 'next/link' -import { MessageCircle } from 'lucide-react' - -export function WhatsAppFAB() { - return ( - - - - ) -} diff --git a/apps/web/src/components/layout/index.ts b/apps/web/src/components/layout/index.ts index 5db646a7..0aeedef6 100644 --- a/apps/web/src/components/layout/index.ts +++ b/apps/web/src/components/layout/index.ts @@ -8,5 +8,4 @@ export { StoreMenuDrawer } from "./StoreMenuDrawer"; export type { StoreMenuDrawerProps } from "./StoreMenuDrawer"; export { StoreModeBar } from "./StoreModeBar"; export type { StoreModeBarProps } from "./StoreModeBar"; -export { WhatsAppFAB } from "./WhatsAppFAB"; export type { AppShellMode, ShellNavItem } from "./navigation"; diff --git a/apps/web/src/components/layout/navigation.ts b/apps/web/src/components/layout/navigation.ts index 409d80d6..954b7f35 100644 --- a/apps/web/src/components/layout/navigation.ts +++ b/apps/web/src/components/layout/navigation.ts @@ -3,6 +3,7 @@ import { Banknote, BarChart3, Bell, + Bot, Bookmark, Boxes, ClipboardList, @@ -91,12 +92,26 @@ export function getShoppingNavItems( variant: NavVariant = "left", ): ShellNavItem[] { const profileHref = getOwnProfileHref(username); - const items = + const baseItems = variant === "bottom" ? BASE_SHOPPING_BOTTOM_NAV_ITEMS : BASE_SHOPPING_LEFT_NAV_ITEMS; + const items = + variant === "left" + ? [...baseItems, ...getWhatsAppAssistantNavItems()] + : baseItems; return items.map((item) => item.label === "Profile" ? { ...item, href: profileHref } : item, ); } + +function getWhatsAppAssistantNavItems(): ShellNavItem[] { + return [ + { + label: "WhatsApp", + href: "/whatsapp", + icon: Bot, + }, + ]; +} diff --git a/apps/web/src/lib/route-policy.ts b/apps/web/src/lib/route-policy.ts index 4cba6e19..37cdaf29 100644 --- a/apps/web/src/lib/route-policy.ts +++ b/apps/web/src/lib/route-policy.ts @@ -7,6 +7,7 @@ const ACCOUNT_REQUIRED_PREFIXES = [ "/saved", "/messages", "/notifications", + "/whatsapp", "/settings", ]; const ADMIN_REQUIRED_PREFIXES = ["/admin"]; From f760dabcb02e12af36da497330cec7e05ac33dda Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Sat, 6 Jun 2026 07:35:46 +0100 Subject: [PATCH 004/389] fix(whatsapp): address assistant review feedback --- apps/backend/src/channels/whatsapp/AGENTS.md | 4 +- .../channels/whatsapp/image-search.service.ts | 8 ++- .../whatsapp/whatsapp-auth.service.spec.ts | 17 +++-- .../whatsapp/whatsapp-auth.service.ts | 19 +++-- .../whatsapp/whatsapp-intent.service.ts | 2 +- ...whatsapp-product-discovery.service.spec.ts | 3 +- .../whatsapp-product-discovery.service.ts | 3 +- .../whatsapp/whatsapp-session.service.spec.ts | 6 ++ .../whatsapp/whatsapp-session.service.ts | 6 +- .../channels/whatsapp/whatsapp.constants.ts | 4 +- .../whatsapp/whatsapp.service.spec.ts | 4 +- .../src/channels/whatsapp/whatsapp.service.ts | 16 +++-- .../whatsapp/WhatsAppAssistantClient.tsx | 20 +++--- apps/web/src/components/layout/BottomNav.tsx | 7 +- .../src/components/layout/MobileHeader.tsx | 72 +++++++++++++------ .../components/layout/ProfileMenuDrawer.tsx | 7 ++ apps/web/src/components/layout/navigation.ts | 1 + 17 files changed, 138 insertions(+), 61 deletions(-) diff --git a/apps/backend/src/channels/whatsapp/AGENTS.md b/apps/backend/src/channels/whatsapp/AGENTS.md index c152c354..cedb0ece 100644 --- a/apps/backend/src/channels/whatsapp/AGENTS.md +++ b/apps/backend/src/channels/whatsapp/AGENTS.md @@ -174,7 +174,7 @@ export class WhatsAppProcessor { --- -## 4. Session Service (services/session.service.ts) +## 4. Session Service (whatsapp-session.service.ts) ``` PURPOSE: NDPR consent management + session state per phone number @@ -192,7 +192,7 @@ WhatsAppSession { CONSENT FLOW: 1. New phone number messages twizrr -2. getOrCreate(phone) → creates session with consentGiven = false +2. recordInbound(from, userId) → creates/updates session with consentGiven = false 3. Processor checks: consentGiven = false → send consent template 4. Shopper taps [I Agree] button on consent template 5. Interactive reply received → markConsentGiven(phone) → consentGiven = true diff --git a/apps/backend/src/channels/whatsapp/image-search.service.ts b/apps/backend/src/channels/whatsapp/image-search.service.ts index 9d1cbd1e..a6d9360d 100644 --- a/apps/backend/src/channels/whatsapp/image-search.service.ts +++ b/apps/backend/src/channels/whatsapp/image-search.service.ts @@ -1,5 +1,5 @@ import { Injectable, Logger } from "@nestjs/common"; -import { Prisma } from "@prisma/client"; +import { Prisma, StoreTier } from "@prisma/client"; import { GeminiClient } from "../../integrations/ai/gemini.client"; import { VisionClient } from "../../integrations/ai/vision.client"; import { MetaWhatsAppClient } from "../../integrations/meta-whatsapp/meta-whatsapp.client"; @@ -74,7 +74,10 @@ export class ImageSearchService { const bodyText = `I identified the item as *${extractedTerms[0]}*, but I couldn't find a direct match right now.\n\nHere are some other popular products you might like instead:`; const fallbackProducts = await this.prisma.product.findMany({ - where: { isActive: true }, + where: { + isActive: true, + storeProfile: { tier: { not: StoreTier.TIER_0 } }, + }, include: { storeProfile: { select: { businessName: true } } }, take: 5, orderBy: { createdAt: "desc" }, @@ -189,6 +192,7 @@ export class ImageSearchService { return this.prisma.product.findMany({ where: { AND: [{ isActive: true }, { OR: searchConditions }], + storeProfile: { tier: { not: StoreTier.TIER_0 } }, }, include: { storeProfile: { select: { businessName: true } } }, take: 20, diff --git a/apps/backend/src/channels/whatsapp/whatsapp-auth.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-auth.service.spec.ts index c6eb2ecf..2ed46ee1 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp-auth.service.spec.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp-auth.service.spec.ts @@ -16,6 +16,10 @@ describe("WhatsAppAuthService account linking", () => { whatsAppLink: { upsert: jest.fn(), }, + whatsAppSession: { + updateMany: jest.fn(), + }, + $transaction: jest.fn(), }; const redisService = { get: jest.fn(), @@ -38,6 +42,9 @@ describe("WhatsAppAuthService account linking", () => { whatsappSessionService.normalizePhone.mockImplementation((phone: string) => phone.startsWith("+") ? phone : `+${phone}`, ); + prisma.$transaction.mockImplementation(async (queries: unknown[]) => + Promise.all(queries), + ); service = new WhatsAppAuthService( configService as any, prisma as any, @@ -71,15 +78,17 @@ describe("WhatsAppAuthService account linking", () => { const response = await service.handleLinkingFlow(phone, "123456"); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); expect(prisma.whatsAppLink.upsert).toHaveBeenCalledWith({ where: { phone }, update: { userId: "user-1", isActive: true }, create: { phone, userId: "user-1", isActive: true }, }); - expect(whatsappSessionService.attachUser).toHaveBeenCalledWith( - phone, - "user-1", - ); + expect(prisma.whatsAppSession.updateMany).toHaveBeenCalledWith({ + where: { phone }, + data: { userId: "user-1" }, + }); + expect(whatsappSessionService.attachUser).not.toHaveBeenCalled(); expect(response).toContain("Account linked."); }); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-auth.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-auth.service.ts index 05067ad3..d163faa6 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp-auth.service.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp-auth.service.ts @@ -205,12 +205,17 @@ export class WhatsAppAuthService { } try { - await this.prisma.whatsAppLink.upsert({ - where: { phone }, - update: { userId: session.data.userId, isActive: true }, - create: { phone, userId: session.data.userId, isActive: true }, - }); - await this.whatsappSessionService.attachUser(phone, session.data.userId); + await this.prisma.$transaction([ + this.prisma.whatsAppLink.upsert({ + where: { phone }, + update: { userId: session.data.userId, isActive: true }, + create: { phone, userId: session.data.userId, isActive: true }, + }), + this.prisma.whatsAppSession.updateMany({ + where: { phone }, + data: { userId: session.data.userId }, + }), + ]); } catch (error) { this.logger.error( `Failed to create WhatsAppLink for ${maskPhone(phone)}: ${ @@ -228,6 +233,6 @@ export class WhatsAppAuthService { `WhatsApp linked: phone=${maskPhone(phone)}, userId=${session.data.userId}`, ); - return `Account linked.\n\nHi ${session.data.userName || "there"}, you can now search products, track orders, and confirm delivery from WhatsApp.`; + return `Account linked.\n\nHi ${session.data.userName || "there"}, you can now search products and use account actions from your twizrr dashboard.`; } } diff --git a/apps/backend/src/channels/whatsapp/whatsapp-intent.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-intent.service.ts index 4832c144..169c3a4d 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp-intent.service.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp-intent.service.ts @@ -77,7 +77,7 @@ export class WhatsAppIntentService { lower.includes("where is my order") || lower.includes("order status") ) { - return { functionName: "track_order", params: {} }; + return { functionName: "get_order_status", params: {} }; } if ( diff --git a/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.spec.ts index 1fb68f14..3c1adfc0 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.spec.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.spec.ts @@ -1,4 +1,4 @@ -import { Prisma } from "@prisma/client"; +import { Prisma, StoreTier } from "@prisma/client"; import { WhatsAppProductDiscoveryService } from "./whatsapp-product-discovery.service"; describe("WhatsAppProductDiscoveryService", () => { @@ -39,6 +39,7 @@ describe("WhatsAppProductDiscoveryService", () => { expect(prisma.product.findMany).toHaveBeenCalledWith({ where: { isActive: true, + storeProfile: { tier: { not: StoreTier.TIER_0 } }, OR: [ { name: { diff --git a/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.ts index 44e2884a..324f2525 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.ts @@ -1,5 +1,5 @@ import { Injectable } from "@nestjs/common"; -import { Prisma } from "@prisma/client"; +import { Prisma, StoreTier } from "@prisma/client"; import { PrismaService } from "../../prisma/prisma.service"; import { WhatsAppInteractiveService } from "./whatsapp-interactive.service"; @@ -53,6 +53,7 @@ export class WhatsAppProductDiscoveryService { return this.prisma.product.findMany({ where: { isActive: true, + storeProfile: { tier: { not: StoreTier.TIER_0 } }, OR: [ { name: { contains: query, mode: Prisma.QueryMode.insensitive } }, { title: { contains: query, mode: Prisma.QueryMode.insensitive } }, diff --git a/apps/backend/src/channels/whatsapp/whatsapp-session.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-session.service.spec.ts index 3bfcdd5f..0c173b17 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp-session.service.spec.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp-session.service.spec.ts @@ -21,6 +21,12 @@ describe("WhatsAppSessionService", () => { expect(service.normalizePhone("+2348012345678")).toBe("+2348012345678"); }); + it("rejects incomplete local phone input", () => { + expect(() => service.normalizePhone("0")).toThrow( + "Invalid WhatsApp phone number.", + ); + }); + it("upserts an inbound WhatsApp session and preserves linked user context", async () => { prisma.whatsAppSession.upsert.mockResolvedValue({ phone: "+2348012345678", diff --git a/apps/backend/src/channels/whatsapp/whatsapp-session.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-session.service.ts index c529a55a..c01ea035 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp-session.service.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp-session.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from "@nestjs/common"; +import { BadRequestException, Injectable } from "@nestjs/common"; import { WhatsAppSession } from "@prisma/client"; import { PrismaService } from "../../prisma/prisma.service"; @@ -22,6 +22,10 @@ export class WhatsAppSessionService { return cleaned; } + if (cleaned === "0") { + throw new BadRequestException("Invalid WhatsApp phone number."); + } + if (cleaned.startsWith("0")) { return `+234${cleaned.slice(1)}`; } diff --git a/apps/backend/src/channels/whatsapp/whatsapp.constants.ts b/apps/backend/src/channels/whatsapp/whatsapp.constants.ts index 2adc44d9..d2e49532 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp.constants.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp.constants.ts @@ -35,7 +35,7 @@ export enum SessionState { export const NUMBER_INTENT_MAP: Record = { "1": "search_products", - "2": "track_order", + "2": "get_order_status", "3": "confirm_delivery", }; @@ -56,7 +56,7 @@ export const GEMINI_FUNCTION_DECLARATIONS = [ }, }, { - name: "track_order", + name: "get_order_status", description: "Help a shopper track an existing order. Triggered by tracking, delivery, shipment, or order status questions.", parameters: { diff --git a/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts index 91215d74..60a6568f 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts @@ -227,7 +227,7 @@ describe("WhatsAppService", () => { consentGiven: true, }); intentService.parseIntent.mockResolvedValue({ - functionName: "track_order", + functionName: "get_order_status", params: {}, }); @@ -252,7 +252,7 @@ describe("WhatsAppService", () => { consentGiven: true, }); intentService.parseIntent.mockResolvedValue({ - functionName: "track_order", + functionName: "get_order_status", params: {}, }); diff --git a/apps/backend/src/channels/whatsapp/whatsapp.service.ts b/apps/backend/src/channels/whatsapp/whatsapp.service.ts index 69c889e0..044778de 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp.service.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp.service.ts @@ -121,6 +121,7 @@ export class WhatsAppService { if ( this.isAccountActionRequest(messageText || "", intent.functionName) && + intent.functionName !== "get_order_status" && intent.functionName !== "track_order" && intent.functionName !== "confirm_delivery" ) { @@ -140,6 +141,7 @@ export class WhatsAppService { ); return; + case "get_order_status": case "track_order": case "confirm_delivery": if (!(await this.requireLinkedAccount(phone, userId))) { @@ -180,7 +182,11 @@ export class WhatsAppService { messageText: string, functionName?: string, ): boolean { - if (functionName === "track_order" || functionName === "confirm_delivery") { + if ( + functionName === "get_order_status" || + functionName === "track_order" || + functionName === "confirm_delivery" + ) { return true; } @@ -231,9 +237,11 @@ export class WhatsAppService { const actionLabel = action === "track_order" ? "track orders" - : action === "confirm_delivery" - ? "confirm delivery" - : "continue with checkout or account actions"; + : action === "get_order_status" + ? "check order status" + : action === "confirm_delivery" + ? "confirm delivery" + : "continue with checkout or account actions"; await this.interactiveService.sendTextMessage( phone, diff --git a/apps/web/src/app/(app)/whatsapp/WhatsAppAssistantClient.tsx b/apps/web/src/app/(app)/whatsapp/WhatsAppAssistantClient.tsx index d7148e4b..1b739157 100644 --- a/apps/web/src/app/(app)/whatsapp/WhatsAppAssistantClient.tsx +++ b/apps/web/src/app/(app)/whatsapp/WhatsAppAssistantClient.tsx @@ -149,10 +149,10 @@ export function WhatsAppAssistantClient() { WhatsApp

- Twizrr shopping assistant + twizrr shopping assistant

- Chat with Twizrr on WhatsApp to discover products, continue shopping, + Chat with twizrr on WhatsApp to discover products, continue shopping, and receive order updates from the assistant.

@@ -215,7 +215,7 @@ export function WhatsAppAssistantClient() { function HowItWorksCard() { const steps = [ - "Verify your WhatsApp number so Twizrr can attach it to your account.", + "Verify your WhatsApp number so twizrr can attach it to your account.", "Open WhatsApp and accept the assistant consent prompt if it appears.", "Shop, search, track orders, and confirm delivery inside WhatsApp.", ]; @@ -291,7 +291,7 @@ function ShopOnWhatsAppCard({ const session = status?.assistantSession; const hasConsent = session?.consentGiven === true; const helperText = hasConsent - ? "Your account is linked and consent is recorded. Open WhatsApp to continue shopping with Twizrr." + ? "Your account is linked and consent is recorded. Open WhatsApp to continue shopping with twizrr." : "Your account is linked. Open WhatsApp and accept the consent prompt before the assistant starts shopping with you."; return ( @@ -378,7 +378,7 @@ function StatusCard({ ? hasConsent ? "Your account is linked and the assistant can continue your WhatsApp shopping session." : "Your account is linked. The assistant will confirm consent in WhatsApp before shopping starts." - : "Connect your account so WhatsApp chats can match your Twizrr profile."; + : "Connect your account so WhatsApp chats can match your twizrr profile."; return (
@@ -436,7 +436,7 @@ function ReadinessCard({ { title: "Account link", description: isLinked - ? "This WhatsApp number is attached to your Twizrr account." + ? "This WhatsApp number is attached to your twizrr account." : "Verify your WhatsApp number so account history can be used safely.", state: isLinked ? "done" : "pending", }, @@ -452,7 +452,7 @@ function ReadinessCard({ { title: "WhatsApp session", description: hasSession - ? "Twizrr has seen activity from the linked WhatsApp number." + ? "twizrr has seen activity from the linked WhatsApp number." : isLinked ? "No WhatsApp assistant activity is recorded yet for this number." : "A previous WhatsApp session can be attached after number verification.", @@ -471,7 +471,7 @@ function ReadinessCard({ Assistant readiness

- Twizrr separates phone verification from assistant consent. Both + twizrr separates phone verification from assistant consent. Both must be ready before the WhatsApp assistant can personalize shopping.

@@ -568,7 +568,7 @@ function ConnectCard({

We will send a 6-digit code to your WhatsApp number. Enter it here - to attach the number to your Twizrr account. If you already chatted + to attach the number to your twizrr account. If you already chatted with the assistant from this number, that session will be attached after verification.

@@ -623,7 +623,7 @@ function ConnectCard({

You can also open WhatsApp now. Without linking, the assistant may not - know your Twizrr account or order history yet, and it will ask for + know your twizrr account or order history yet, and it will ask for consent before shopping starts.

-
+
{navItems.map((item) => { const Icon = item.icon; const isActive = isActiveItem(item, activeHref); diff --git a/apps/web/src/components/layout/MobileHeader.tsx b/apps/web/src/components/layout/MobileHeader.tsx index f653123a..bf6dcd04 100644 --- a/apps/web/src/components/layout/MobileHeader.tsx +++ b/apps/web/src/components/layout/MobileHeader.tsx @@ -1,46 +1,72 @@ -import Image from 'next/image' -import { Bell, Search } from 'lucide-react' -import { cn } from '@/lib/utils' -import type { AppShellMode } from './navigation' +import Image from "next/image"; +import Link from "next/link"; +import { Bell, Bot, Search } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { AppShellMode } from "./navigation"; interface MobileHeaderProps { - mode: AppShellMode - storeName?: string + mode: AppShellMode; + storeName?: string; } -export function MobileHeader({ mode, storeName = 'Store' }: MobileHeaderProps) { - const isStoreMode = mode === 'STORE' +export function MobileHeader({ mode, storeName = "Store" }: MobileHeaderProps) { + const isStoreMode = mode === "STORE"; const placeholderButtonClassName = cn( - 'flex h-11 min-h-[44px] w-11 min-w-[44px] cursor-not-allowed items-center justify-center rounded-full opacity-50', - isStoreMode ? 'text-[var(--color-bianca)]' : 'text-[var(--color-mocha)]', - ) + "flex h-11 min-h-[44px] w-11 min-w-[44px] cursor-not-allowed items-center justify-center rounded-full opacity-50", + isStoreMode ? "text-[var(--color-bianca)]" : "text-[var(--color-mocha)]", + ); return (
{isStoreMode ? ( - + ) : ( - twizrr + twizrr )}
- {isStoreMode && {storeName}} + {isStoreMode && ( + {storeName} + )}
+ + +
- ) + ); } diff --git a/apps/web/src/components/layout/ProfileMenuDrawer.tsx b/apps/web/src/components/layout/ProfileMenuDrawer.tsx index 708599c4..176b9824 100644 --- a/apps/web/src/components/layout/ProfileMenuDrawer.tsx +++ b/apps/web/src/components/layout/ProfileMenuDrawer.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { Bookmark, + Bot, Heart, HelpCircle, LayoutDashboard, @@ -185,6 +186,12 @@ export function ProfileMenuDrawer({ {/* Support */} + Date: Sat, 6 Jun 2026 08:33:14 +0100 Subject: [PATCH 005/389] docs(delivery): define Twizrr-managed delivery policy --- AGENTS.md | 21 +++++++++++++--- TWIZRR_DEVELOPMENT_RULES.md | 2 +- TWIZRR_MVP_SCOPE.md | 49 ++++++++++++++++++++++++------------- apps/backend/AGENTS.md | 42 ++++++++++++++++++++++--------- apps/web/AGENTS.md | 7 +++--- 5 files changed, 84 insertions(+), 37 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3f8e0b42..fe6a57f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ twizrr is a Nigerian social commerce marketplace. Stores post products, lifestyl A NestJS 10 backend API serves two clients — a Next.js 14 web app and a WhatsApp AI assistant. The backend handles all business logic: products, orders, escrow payments via Paystack, payout automation via BullMQ, and AI product search via Vertex AI embeddings stored in pgvector. The web app serves store owners (dashboard) and shoppers (discovery + checkout). WhatsApp serves shoppers only — it is not used for store management. **The core feature:** -Escrow-protected social commerce. Store lists product → shopper discovers via feed or WhatsApp AI → shopper pays → money held in escrow → store dispatches → shopper confirms delivery with 6-digit code → store receives payout automatically. +Escrow-protected social commerce. Store lists product → shopper discovers via feed or WhatsApp AI → shopper pays → money held in escrow → store prepares order → Twizrr-managed delivery runs → shopper confirms delivery with 6-digit code → store receives payout automatically. **External services:** - **Paystack** — card payments, Dedicated Virtual Accounts (DVA), bank transfer payouts to stores @@ -644,6 +644,19 @@ RULE 3 — ESCROW ON EVERY ORDER: All payments: held in twizrr Paystack balance Released only: after 6-digit OTP confirmed OR 48hr auto-confirm +RULE 3A — TWIZRR OWNS DELIVERY: + All marketplace delivery is Twizrr-managed by default + Shoppers do not choose store delivery or personal delivery + Checkout requires a delivery address and uses User.phone as the default primary delivery phone + Checkout may collect an optional secondary delivery phone + Shopper delivery phones are private platform data + Store owners do not see shopper phone numbers by default + Delivery phone/address may be shared only with approved logistics providers when required + Store owners prepare orders and trigger Ready for pickup / Book delivery through Twizrr + Delivery fee is calculated by Twizrr from fulfillment pickup point to shopper dropoff + Physical product pickup = physical store pickup address + Dropship pickup = source physical supplier pickup address, never digital dropshipper address + RULE 4 — STORE TYPE IS NEVER PUBLIC: StoreProfile.storeType (DIGITAL | PHYSICAL) Remove from ALL shopper-facing API responses @@ -915,7 +928,7 @@ BullMQ: auto-confirm job (DISPATCHED + 48hrs) WhatsApp template: order_confirmed sent to shopper Store notified: new order (in-app + email) ↓ -Store dispatches → Order: DISPATCHED +Store marks ready for pickup → Twizrr books delivery → Order: DISPATCHED Shopper: WhatsApp template order_dispatched ↓ Shopper taps [I received it] OR enters 6-digit code @@ -954,8 +967,8 @@ System creates 2 linked Orders: Order A: shopper ↔ @yabafashion (customer-facing, ₦40,000) Order B: @yabafashion ↔ @balogunstore (fulfillment, ₦28,000) ↓ -@balogunstore notified: "New fulfillment order — ship to [address]" -@balogunstore dispatches directly to shopper +@balogunstore notified: "New fulfillment order — prepare for pickup" +Twizrr-managed delivery picks up from @balogunstore and delivers to shopper ↓ Shopper confirms delivery ↓ diff --git a/TWIZRR_DEVELOPMENT_RULES.md b/TWIZRR_DEVELOPMENT_RULES.md index c2b7b735..08e2df92 100644 --- a/TWIZRR_DEVELOPMENT_RULES.md +++ b/TWIZRR_DEVELOPMENT_RULES.md @@ -1048,7 +1048,7 @@ FINAL CHECK: 2. Browse products in the feed 3. Add to cart 4. Checkout and pay (test card or real card on beta) - 5. Merchant dispatches + 5. Merchant marks order ready for pickup / Twizrr books delivery 6. Shopper confirms delivery (OTP or button) 7. Payout processed to merchant diff --git a/TWIZRR_MVP_SCOPE.md b/TWIZRR_MVP_SCOPE.md index 3c370fc6..abf2dd86 100644 --- a/TWIZRR_MVP_SCOPE.md +++ b/TWIZRR_MVP_SCOPE.md @@ -13,7 +13,8 @@ THE FULL LOOP MUST WORK: Store lists product → Shopper discovers via WhatsApp AI → Shopper pays (Paystack) → Escrow holds payment -→ Store dispatches → Shopper confirms delivery (6-digit code) +→ Store prepares order → Twizrr-managed delivery runs +→ Shopper confirms delivery (6-digit code) → Store receives payout automatically SUCCESS METRIC: @@ -31,6 +32,9 @@ LAUNCH STRATEGY: Week 8+: Public Lagos launch GEOGRAPHIC SCOPE: Lagos only +LAUNCH CLUSTERS: +├── Balogun: supplier/source inventory cluster +└── Yaba: youth/student/dropshipper/shoppers cluster CHANNEL SPLIT: ├── WhatsApp: shoppers discover and buy └── Web: store owners manage and list @@ -82,7 +86,7 @@ FLOW (6 screens): ├── Business details: │ Business category (dropdown) │ Product sub-categories (multi-select) -│ Digital store: home/pickup address (Google Maps — internal only) +│ Digital store: home address (Google Maps — internal only; never a delivery pickup point) │ Physical store: business address (Google Maps — public) │ ├── Bank details: @@ -319,9 +323,10 @@ FLOOR CHANGE (physical store raises dropshipper price): │ Bottom sheet: Clothing or Footwear guide │ For bundles: [Clothing] [Footwear] [How to Measure] tabs ├── Delivery options preview (before checkout): -│ Pickup from physical store's location (never digital home) -│ Same-day (if same city + before 12 PM WAT) -│ Nationwide via Shipbubble (always shown) +│ Twizrr-managed delivery estimate from fulfillment pickup point to shopper dropoff +│ Physical store pickup = physical store pickup address +│ Dropship pickup = source physical supplier pickup address (never digital home) +│ MVP may use Lagos selected-zone pricing before full live logistics pricing │ Cache: Redis 30-min TTL per productId + shopperCity ├── Escrow Protection badge ├── Review section (scrollable) @@ -333,18 +338,28 @@ FLOOR CHANGE (physical store raises dropshipper price): 1. CART → CHECKOUT: Cart page (from Profile Menu → Cart) Products + variants + quantities listed - [Proceed to Checkout] → select address → select payment → confirm + [Proceed to Checkout] → confirm address + delivery phone → select payment → confirm 2. PRODUCT DETAIL → BUY NOW: [Buy Now — ₦185,000] button Variant MUST be selected first ("Please select a size") - Skips cart entirely → address + payment → confirm + Skips cart entirely → address + delivery phone + payment → confirm Cart untouched 3. FEED CARD → QUICK BUY: [Quick Buy] on product post in feed Never leaves the feed - Bottom sheet: variant select + address + payment method + [Confirm] + Bottom sheet: variant select + address + delivery phone + payment method + [Confirm] + +CHECKOUT DELIVERY POLICY: +├── Shopper does not choose store delivery or personal delivery +├── Checkout requires delivery address +├── User.phone is the default primary delivery phone +├── Shopper may add an optional secondary delivery phone +├── Shopper delivery phones are private platform data +├── Delivery phone/address may be shared only with approved logistics providers +├── Delivery fee is calculated by twizrr from pickup point to shopper dropoff +└── Shopper pays subtotal + delivery fee + applicable platform/service fee ALL FLOWS END AT ORDER CONFIRMED SCREEN: └── Large checkmark + "Order Placed!" @@ -433,21 +448,21 @@ ACTION SECTION (changes by status): STORE MODE → Orders tab FILTER TABS: All | Needs Action | Active | Completed | Cancelled -"Needs Action" has a badge count (PAID orders needing dispatch) +"Needs Action" has a badge count (PAID orders needing preparation/pickup) EACH ORDER CARD: ├── #TWZ-XXXXXX · [time ago] -├── Product thumbnail + name + shopper @handle + variant +├── Product thumbnail + name + shopper handle + variant ├── ₦[amount] + status badge └── "ESCROW: FUNDS HELD · Release on delivery" -DISPATCH FLOW (tap PAID order → [Dispatch Order]): -├── Select delivery method: -│ Shipbubble (nationwide — shows rate quote) -│ Personal delivery (Lagos — store handles) -├── Tracking number (if Shipbubble) -└── [Confirm Dispatch] → order: DISPATCHED - Shopper notified immediately (WhatsApp template + in-app) +TWIZRR-MANAGED DELIVERY FLOW (tap PAID order → [Ready for pickup]): +├── Store accepts/prepares the order +├── Store confirms the order is ready for pickup +├── Twizrr books/assigns approved logistics from pickup point to shopper dropoff +├── Store owner does not arrange off-platform delivery with the shopper +├── Store owner does not see shopper phone numbers by default +└── Shopper notified when delivery is booked/dispatched (WhatsApp template + in-app) ``` --- diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index 8b83ed7c..5a2709c4 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -620,7 +620,7 @@ DROPSHIP ORDERS — TWO LINKED ORDERS: DROPSHIP_CUSTOMER: shopper ↔ digital store (what shopper sees) DROPSHIP_FULFILLMENT: digital store ↔ physical store (fulfillment) linkedOrderId: connects the two -Physical store dispatches to DROPSHIP_CUSTOMER.deliveryAddress +Twizrr-managed delivery picks up from the physical source store and delivers to the shopper dropoff address MODELS: Order { @@ -1336,14 +1336,14 @@ Post { ### Order Module (src/domains/orders/order/) ``` -PURPOSE: Full order lifecycle — creation, state machine, dispatch, confirmation +PURPOSE: Full order lifecycle — creation, state machine, Twizrr-managed delivery handoff, confirmation ENDPOINTS: POST /orders — create order (checkout) GET /orders/:id — get order detail GET /orders/me — shopper's own orders (with filter tabs) GET /store/orders — store's incoming orders (store owner) -PUT /orders/:id/dispatch — store marks dispatched +PUT /orders/:id/dispatch — legacy dispatch transition; new UX should model Ready for pickup / Book delivery POST /orders/:id/confirm — shopper confirms delivery (6-digit OTP) POST /orders/:id/dispute — shopper files dispute @@ -1356,7 +1356,7 @@ STATE MACHINE (strict — no skipping states): PENDING_PAYMENT ↓ (Paystack webhook: payment.success) PAID - ↓ (store marks dispatched) + ↓ (store prepares order; Twizrr-managed delivery is booked/dispatched) DISPATCHED ↓ (shopper confirms OR 48hr auto-confirm) DELIVERED @@ -1395,7 +1395,8 @@ DROPSHIP ORDER — TWO LINKED ORDERS: └── Order type DROPSHIP_CUSTOMER: shopper → digital store Order type DROPSHIP_FULFILLMENT: digital store → physical store linkedOrderId: links the two orders - Physical store dispatches to shopper address (from DROPSHIP_CUSTOMER) + Twizrr-managed delivery picks up from the source physical store + and delivers to the shopper dropoff address Physical store never sees shopper's order total — only fulfillment order ``` @@ -1891,22 +1892,39 @@ POST /payments/webhook ## 5. Delivery Module (src/domains/orders/delivery/) ``` -PURPOSE: Shipbubble integration + delivery preview API +PURPOSE: Twizrr-managed delivery policy, Shipbubble integration, and delivery preview API ENDPOINTS: GET /delivery/estimates — delivery preview for product page Query: ?productId=xxx&shopperState=Lagos&shopperCity=Lekki Cache: Redis 30-min TTL per (productId + shopperState + shopperCity) -POST /delivery/book — book Shipbubble on dispatch +POST /delivery/book — book approved logistics after store marks Ready for pickup POST /delivery/webhook — Shipbubble delivery status events +DELIVERY POLICY — CRITICAL: +All marketplace delivery is Twizrr-managed by default. +Shoppers do not choose store delivery or personal delivery. +Checkout requires delivery address and uses User.phone as the default +primary delivery phone; checkout may collect an optional secondary phone. +Shopper delivery phones are private platform data and must not be returned +to store owners by default. +Delivery phone/address may be shared only with approved logistics providers +when required for fulfillment. +Store owners accept/prepare orders and trigger Ready for pickup / Book delivery +through Twizrr; they do not arrange off-platform delivery with shoppers. +Delivery fee is calculated by Twizrr from pickup point to shopper dropoff. +MVP may start with Lagos selected-zone pricing before full live logistics pricing. + PICKUP POINT RULE — CRITICAL: -For PHYSICAL store: use store.businessAddress -For DIGITAL store: find physical store fulfilling the order - use THAT physical store's businessAddress -NEVER use digital store's homeAddress for any delivery routing -homeAddress is private — never used for logistics +For PHYSICAL store order: use the physical store pickup/business address +For DROPSHIP order: use the source physical supplier pickup/business address +NEVER use the digital dropshipper's homeAddress for delivery routing +homeAddress is private and internal only + +LAUNCH CLUSTERS: +Balogun = supplier/source inventory cluster +Yaba = youth/student/dropshipper/shoppers cluster INJECTS: ShipbubbleClient (from integrations/shipbubble/) diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 797b34e8..8478b3a5 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -326,7 +326,9 @@ LAYOUT (top to bottom): │ ├── Delivery preview: │ GET /delivery/estimates?productId=X&shopperState=Y&shopperCity=Z -│ [Same-day] card (if available) + [Nationwide] card +│ Twizrr-managed estimate from fulfillment pickup point to shopper dropoff +│ Do not offer store delivery or personal delivery as shopper choices +│ Delivery phone fields are checkout-only private platform data │ ├── Escrow Protection badge: │ [shield icon] Escrow Protected — safe until you confirm delivery @@ -514,11 +516,10 @@ LAYOUT: DISPATCHED: 6-digit code boxes: [4] [8] [3] [9] [2] [0] "Share this code ONLY when your item arrives" - [Track Shipment →] button (if Shipbubble) + [Track Shipment →] button (when Twizrr-managed logistics tracking is available) DELIVERED: [Confirm I Received It] saffron button (full-width) - OR: 6-digit code input (if personal delivery) [Raise a Dispute] smaller text link below "X hours left to raise a dispute" countdown From d011ecb744864934396242922197df44b4bc084b Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Sat, 6 Jun 2026 08:48:20 +0100 Subject: [PATCH 006/389] docs(delivery): align policy wording with brand language --- AGENTS.md | 8 ++++---- TWIZRR_DEVELOPMENT_RULES.md | 6 +++--- TWIZRR_MVP_SCOPE.md | 22 +++++++++++----------- apps/backend/AGENTS.md | 4 ++-- apps/web/AGENTS.md | 12 ++++++------ 5 files changed, 26 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fe6a57f9..e91fdd1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ twizrr is a Nigerian social commerce marketplace. Stores post products, lifestyl A NestJS 10 backend API serves two clients — a Next.js 14 web app and a WhatsApp AI assistant. The backend handles all business logic: products, orders, escrow payments via Paystack, payout automation via BullMQ, and AI product search via Vertex AI embeddings stored in pgvector. The web app serves store owners (dashboard) and shoppers (discovery + checkout). WhatsApp serves shoppers only — it is not used for store management. **The core feature:** -Escrow-protected social commerce. Store lists product → shopper discovers via feed or WhatsApp AI → shopper pays → money held in escrow → store prepares order → Twizrr-managed delivery runs → shopper confirms delivery with 6-digit code → store receives payout automatically. +Protected-payment social commerce. Store lists product → shopper discovers via feed or WhatsApp AI → shopper pays → payment is protected by twizrr Buyer Protection → store prepares order → delivery is handled by twizrr → shopper confirms delivery with a delivery code → store receives payment automatically. **External services:** - **Paystack** — card payments, Dedicated Virtual Accounts (DVA), bank transfer payouts to stores @@ -646,14 +646,14 @@ RULE 3 — ESCROW ON EVERY ORDER: RULE 3A — TWIZRR OWNS DELIVERY: All marketplace delivery is Twizrr-managed by default - Shoppers do not choose store delivery or personal delivery + Checkout does not expose old store-delivery or personal-delivery choices Checkout requires a delivery address and uses User.phone as the default primary delivery phone Checkout may collect an optional secondary delivery phone Shopper delivery phones are private platform data Store owners do not see shopper phone numbers by default Delivery phone/address may be shared only with approved logistics providers when required - Store owners prepare orders and trigger Ready for pickup / Book delivery through Twizrr - Delivery fee is calculated by Twizrr from fulfillment pickup point to shopper dropoff + Store owners prepare orders and trigger Ready for pickup / Book delivery through twizrr + Delivery fee is calculated by twizrr from fulfillment pickup point to shopper dropoff Physical product pickup = physical store pickup address Dropship pickup = source physical supplier pickup address, never digital dropshipper address diff --git a/TWIZRR_DEVELOPMENT_RULES.md b/TWIZRR_DEVELOPMENT_RULES.md index 08e2df92..6cfcf53d 100644 --- a/TWIZRR_DEVELOPMENT_RULES.md +++ b/TWIZRR_DEVELOPMENT_RULES.md @@ -1048,9 +1048,9 @@ FINAL CHECK: 2. Browse products in the feed 3. Add to cart 4. Checkout and pay (test card or real card on beta) - 5. Merchant marks order ready for pickup / Twizrr books delivery - 6. Shopper confirms delivery (OTP or button) - 7. Payout processed to merchant + 5. Store owner marks order ready for pickup / twizrr books delivery + 6. Shopper confirms delivery (delivery code or button) + 7. Payment processed to store If all 7 steps work: deploy is safe. If any step fails: do not deploy to main. diff --git a/TWIZRR_MVP_SCOPE.md b/TWIZRR_MVP_SCOPE.md index abf2dd86..3a40a94a 100644 --- a/TWIZRR_MVP_SCOPE.md +++ b/TWIZRR_MVP_SCOPE.md @@ -12,10 +12,10 @@ ``` THE FULL LOOP MUST WORK: Store lists product → Shopper discovers via WhatsApp AI -→ Shopper pays (Paystack) → Escrow holds payment -→ Store prepares order → Twizrr-managed delivery runs -→ Shopper confirms delivery (6-digit code) -→ Store receives payout automatically +→ Shopper pays (Paystack) → Payment is protected +→ Store prepares order → delivery is handled by twizrr +→ Shopper confirms delivery with a delivery code +→ Store receives payment automatically SUCCESS METRIC: └── 50+ completed orders in the first month @@ -323,12 +323,12 @@ FLOOR CHANGE (physical store raises dropshipper price): │ Bottom sheet: Clothing or Footwear guide │ For bundles: [Clothing] [Footwear] [How to Measure] tabs ├── Delivery options preview (before checkout): -│ Twizrr-managed delivery estimate from fulfillment pickup point to shopper dropoff +│ Delivery estimate handled by twizrr from fulfillment pickup point to shopper dropoff │ Physical store pickup = physical store pickup address │ Dropship pickup = source physical supplier pickup address (never digital home) │ MVP may use Lagos selected-zone pricing before full live logistics pricing │ Cache: Redis 30-min TTL per productId + shopperCity -├── Escrow Protection badge +├── twizrr Buyer Protection badge ├── Review section (scrollable) └── Sticky bottom bar: [Add to Cart] [Buy Now — ₦185,000] ``` @@ -352,23 +352,23 @@ FLOOR CHANGE (physical store raises dropshipper price): Bottom sheet: variant select + address + delivery phone + payment method + [Confirm] CHECKOUT DELIVERY POLICY: -├── Shopper does not choose store delivery or personal delivery +├── Checkout does not expose old store-delivery or personal-delivery choices ├── Checkout requires delivery address ├── User.phone is the default primary delivery phone ├── Shopper may add an optional secondary delivery phone ├── Shopper delivery phones are private platform data ├── Delivery phone/address may be shared only with approved logistics providers ├── Delivery fee is calculated by twizrr from pickup point to shopper dropoff -└── Shopper pays subtotal + delivery fee + applicable platform/service fee +└── Shopper pays subtotal + delivery fee + applicable service fee ALL FLOWS END AT ORDER CONFIRMED SCREEN: └── Large checkmark + "Order Placed!" "We're letting [Store Name] know. Your delivery code is ready — keep it private." - [ESCROW PROTECTED — ₦190,950 HELD] pill + [twizrr Buyer Protection — payment protected] pill Order summary (#TWZ-XXXXXX + product + variant + amount) Estimated delivery + delivery address + payment method - "What Happens Next" (3 numbered escrow education steps) + "What Happens Next" (3 numbered Buyer Protection education steps) [Track Order →] primary CTA (full-width saffron button) [Continue Shopping] secondary text link ``` @@ -454,7 +454,7 @@ EACH ORDER CARD: ├── #TWZ-XXXXXX · [time ago] ├── Product thumbnail + name + shopper handle + variant ├── ₦[amount] + status badge -└── "ESCROW: FUNDS HELD · Release on delivery" +└── "Buyer Protection: payment protected until delivery" TWIZRR-MANAGED DELIVERY FLOW (tap PAID order → [Ready for pickup]): ├── Store accepts/prepares the order diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index 5a2709c4..93f11e02 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -1344,7 +1344,7 @@ GET /orders/:id — get order detail GET /orders/me — shopper's own orders (with filter tabs) GET /store/orders — store's incoming orders (store owner) PUT /orders/:id/dispatch — legacy dispatch transition; new UX should model Ready for pickup / Book delivery -POST /orders/:id/confirm — shopper confirms delivery (6-digit OTP) +POST /orders/:id/confirm — shopper confirms delivery with a delivery code POST /orders/:id/dispute — shopper files dispute ORDER CODE GENERATION: @@ -1904,7 +1904,7 @@ POST /delivery/webhook — Shipbubble delivery status events DELIVERY POLICY — CRITICAL: All marketplace delivery is Twizrr-managed by default. -Shoppers do not choose store delivery or personal delivery. +Checkout does not expose old store-delivery or personal-delivery choices. Checkout requires delivery address and uses User.phone as the default primary delivery phone; checkout may collect an optional secondary phone. Shopper delivery phones are private platform data and must not be returned diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 8478b3a5..815f422f 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -326,12 +326,12 @@ LAYOUT (top to bottom): │ ├── Delivery preview: │ GET /delivery/estimates?productId=X&shopperState=Y&shopperCity=Z -│ Twizrr-managed estimate from fulfillment pickup point to shopper dropoff -│ Do not offer store delivery or personal delivery as shopper choices +│ Delivery estimate handled by twizrr from fulfillment pickup point to shopper dropoff +│ Do not offer old store-delivery or personal-delivery choices │ Delivery phone fields are checkout-only private platform data │ -├── Escrow Protection badge: -│ [shield icon] Escrow Protected — safe until you confirm delivery +├── twizrr Buyer Protection badge: +│ [shield icon] Protected payment — safe until you confirm delivery │ ├── Reviews section (scrollable) │ @@ -420,7 +420,7 @@ LAYOUT: [product thumbnail] Product Name @shopperhandle · Size M / Red ₦185,000 · [PENDING] badge - "ESCROW: FUNDS HELD · Release on delivery" + "Buyer Protection: payment protected until delivery" Tappable → /store/orders/:id [View All Orders →] link ``` @@ -516,7 +516,7 @@ LAYOUT: DISPATCHED: 6-digit code boxes: [4] [8] [3] [9] [2] [0] "Share this code ONLY when your item arrives" - [Track Shipment →] button (when Twizrr-managed logistics tracking is available) + [Track Shipment →] button (when delivery tracking is available) DELIVERED: [Confirm I Received It] saffron button (full-width) From 6b86479d6b06713942e2f721501aba0c1ac17b74 Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Sat, 6 Jun 2026 09:47:22 +0100 Subject: [PATCH 007/389] fix(order): protect shopper contact details from store owners --- .../notification-trigger.service.ts | 34 +++++--- .../src/modules/order/invoice.service.ts | 37 ++++++-- .../src/modules/order/order.service.spec.ts | 87 +++++++++++++++++++ .../src/modules/order/order.service.ts | 32 ++++--- .../dashboard/_components/RecentOrders.tsx | 22 +++-- .../store/orders/StoreOrdersClient.tsx | 8 +- .../orders/[id]/StoreOrderDetailClient.tsx | 31 +++---- .../store-orders/DispatchOrderSheet.tsx | 14 +-- .../store-orders/StoreOrderCard.tsx | 23 ++--- .../store-orders/StoreOrderEscrowBanner.tsx | 10 +-- apps/web/src/lib/store-orders.ts | 16 ++-- 11 files changed, 211 insertions(+), 103 deletions(-) diff --git a/apps/backend/src/modules/notification/notification-trigger.service.ts b/apps/backend/src/modules/notification/notification-trigger.service.ts index ca36914c..fea13a28 100644 --- a/apps/backend/src/modules/notification/notification-trigger.service.ts +++ b/apps/backend/src/modules/notification/notification-trigger.service.ts @@ -77,7 +77,7 @@ export class NotificationTriggerService { buyerId, NotificationType.ORDER_PREPARING, "Order Preparing", - "📦 Your order is being prepared! The merchant is packing your goods.", + "Your order is being prepared. The store is packing your goods.", { ...metadata }, [ NotificationChannel.IN_APP, @@ -109,7 +109,7 @@ export class NotificationTriggerService { buyerId: string, metadata: { orderId: string; reference: string; note?: string }, ) { - const noteText = metadata.note ? ` Merchant says: ${metadata.note}` : ""; + const noteText = metadata.note ? ` Store says: ${metadata.note}` : ""; await this.addJob( buyerId, NotificationType.ORDER_IN_TRANSIT, @@ -343,13 +343,21 @@ export class NotificationTriggerService { deliveryAddress?: string; }, ) { + const amountKobo = metadata.amountKobo; + const storeSafeMetadata = { + orderId: metadata.orderId, + reference: metadata.reference, + productName: metadata.productName, + quantity: metadata.quantity, + }; + // Notify buyer via Email / In-App await this.addJob( buyerId, NotificationType.ORDER_PURCHASE_CONFIRMED, "Payment confirmed!", - "Your order is being prepared. You will receive a delivery code when the merchant dispatches.", - { ...metadata, amountKobo: metadata.amountKobo.toString() }, + "Your order is being prepared. You will receive a delivery code when the store dispatches.", + { ...metadata, amountKobo: amountKobo.toString() }, [ NotificationChannel.IN_APP, NotificationChannel.EMAIL, @@ -357,21 +365,21 @@ export class NotificationTriggerService { ], ); - // Notify merchant via Email / In-App + // Notify store owner via Email / In-App await this.addJob( storeId, NotificationType.ORDER_PURCHASE_RECEIVED, "New order received!", - `${metadata.productName} × ${metadata.quantity} — ₦${Number(metadata.amountKobo) / 100}. Check your dashboard to dispatch.`, + `${metadata.productName} x ${metadata.quantity} - NGN ${Number(amountKobo) / 100}. Check your dashboard to prepare the order.`, { - ...metadata, - amountKobo: metadata.amountKobo.toString(), + ...storeSafeMetadata, + amountKobo: amountKobo.toString(), isMerchantId: true, }, [NotificationChannel.IN_APP, NotificationChannel.EMAIL], ); - // WhatsApp push to merchant + // WhatsApp push to store owner try { await this.whatsappQueue.add( "send-direct-order-notification", @@ -379,11 +387,9 @@ export class NotificationTriggerService { storeId, orderData: { orderId: metadata.orderId, - buyerName: metadata.buyerName, productName: metadata.productName, quantity: metadata.quantity, - amountKobo: metadata.amountKobo.toString(), - deliveryAddress: metadata.deliveryAddress || "Not specified", + amountKobo: amountKobo.toString(), }, }, { @@ -479,7 +485,7 @@ export class NotificationTriggerService { `A buyer has raised a dispute for Order #${orderId.slice(0, 8)}. Reason: ${reason}`, { orderId, reason, isMerchantId: true }, [NotificationChannel.IN_APP, NotificationChannel.EMAIL], - { url: `/merchant/orders/${orderId}` }, + { url: `/store/orders/${orderId}` }, ); // Notify Admins (Static broadcast for now, in a real app we'd fetch admin IDs) @@ -570,7 +576,7 @@ export class NotificationTriggerService { `Order #${metadata.reference.slice(0, 8)} has been automatically confirmed by the system. Payout will be processed shortly.`, { ...serializedMetadata, isMerchantId: true }, [NotificationChannel.IN_APP, NotificationChannel.EMAIL], - { url: `/merchant/orders/${metadata.orderId}` }, + { url: `/store/orders/${metadata.orderId}` }, ); } } diff --git a/apps/backend/src/modules/order/invoice.service.ts b/apps/backend/src/modules/order/invoice.service.ts index 6a5c06f0..a1f742e6 100644 --- a/apps/backend/src/modules/order/invoice.service.ts +++ b/apps/backend/src/modules/order/invoice.service.ts @@ -54,7 +54,9 @@ export class InvoiceService { // --- Header --- this.generateHeader(doc); - this.generateCustomerInformation(doc, order); + this.generateCustomerInformation(doc, order, { + includeBuyerContact: order.buyerId === user.sub, + }); this.generateInvoiceTable(doc, order); this.generateFooter(doc); @@ -67,7 +69,9 @@ export class InvoiceService { .fontSize(20) .text("twizrr", 50, 45, { align: "left" }) .fontSize(10) - .text("Digital Marketplace & Escrow Payments", 50, 70, { align: "left" }) + .text("Social commerce with protected payment", 50, 70, { + align: "left", + }) .fillColor("#000000") .fontSize(10) .text("Invoice", 200, 50, { align: "right" }) @@ -77,22 +81,37 @@ export class InvoiceService { .moveDown(); } - private generateCustomerInformation(doc: PDFKit.PDFDocument, order: any) { + private generateCustomerInformation( + doc: PDFKit.PDFDocument, + order: any, + options: { includeBuyerContact: boolean }, + ) { doc .fillColor("#444444") .fontSize(12) .text("Bill To:", 50, 140) .fontSize(10) .fillColor("#000000") - .text(`${order.user.firstName} ${order.user.lastName}`, 50, 155) - .text(order.user.email, 50, 170) - .text(order.user.phone, 50, 185) - .moveDown(); + .text( + options.includeBuyerContact + ? `${order.user.firstName} ${order.user.lastName}` + : "Customer", + 50, + 155, + ); + + if (options.includeBuyerContact) { + doc.text(order.user.email, 50, 170).text(order.user.phone, 50, 185); + } else { + doc.text("Contact details managed by twizrr", 50, 170); + } + + doc.moveDown(); doc .fontSize(12) .fillColor("#444444") - .text("Merchant:", 300, 140) + .text("Store:", 300, 140) .fontSize(10) .fillColor("#000000") .text(order.storeProfile?.businessName || "N/A", 300, 155) @@ -193,7 +212,7 @@ export class InvoiceService { doc .fontSize(10) .text( - "Thank you for your business. Payment was successfully processed via Paystack Escrow.", + "Thank you for your business. Payment was processed with twizrr Buyer Protection.", 50, 700, { align: "center", width: 500 }, diff --git a/apps/backend/src/modules/order/order.service.spec.ts b/apps/backend/src/modules/order/order.service.spec.ts index 275a3a58..f5952f97 100644 --- a/apps/backend/src/modules/order/order.service.spec.ts +++ b/apps/backend/src/modules/order/order.service.spec.ts @@ -124,3 +124,90 @@ describe("OrderService domain events", () => { expect(domainEvents.append).not.toHaveBeenCalled(); }); }); + +describe("OrderService store-owner privacy", () => { + const makeService = (prisma: Record) => + new OrderService( + prisma as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); + + it("strips shopper contact and delivery details from store order lists", async () => { + const prisma = { + order: { + findMany: jest.fn().mockResolvedValue([ + { + id: "order-1", + orderCode: "TWZ-123456", + buyerId: "buyer-1", + storeId: "store-1", + status: OrderStatus.PAID, + deliveryAddress: "12 Shopper Street", + deliveryDetails: { phone: "+2348012345678" }, + deliveryOtp: "123456", + user: { + email: "shopper@example.com", + phone: "+2348012345678", + }, + }, + ]), + count: jest.fn().mockResolvedValue(1), + }, + }; + const service = makeService(prisma); + + const result = await service.listByMerchant("store-1", 1, 20); + const row = result.data[0] as unknown as Record; + + expect(row.user).toBeUndefined(); + expect(row.buyerId).toBeUndefined(); + expect(row.deliveryDetails).toBeUndefined(); + expect(row.deliveryAddress).toBeNull(); + expect(row.deliveryOtp).toBeNull(); + }); + + it("strips shopper contact and delivery details from store order detail", async () => { + const prisma = { + order: { + findUnique: jest.fn().mockResolvedValue({ + id: "order-1", + orderCode: "TWZ-123456", + buyerId: "buyer-1", + storeId: "store-1", + status: OrderStatus.PAID, + deliveryAddress: "12 Shopper Street", + deliveryDetails: { phone: "+2348012345678" }, + deliveryOtp: "123456", + user: { + email: "shopper@example.com", + phone: "+2348012345678", + }, + }), + }, + }; + const service = makeService(prisma); + + const order = (await service.getById( + "order-1", + "store-owner-user", + "store-1", + )) as Record; + + expect(order.user).toBeUndefined(); + expect(order.buyerId).toBeUndefined(); + expect(order.deliveryDetails).toBeUndefined(); + expect(order.deliveryAddress).toBeNull(); + expect(order.deliveryOtp).toBeNull(); + }); +}); diff --git a/apps/backend/src/modules/order/order.service.ts b/apps/backend/src/modules/order/order.service.ts index 37bad665..c351323e 100644 --- a/apps/backend/src/modules/order/order.service.ts +++ b/apps/backend/src/modules/order/order.service.ts @@ -283,14 +283,9 @@ export class OrderService { this.whatsappService .sendDirectOrderNotification(order.storeId, { orderId: order.id, - buyerName: - orderWithDetails.user.firstName + - " " + - orderWithDetails.user.lastName, productName: orderWithDetails.product?.name || "Product", quantity: order.quantity || 1, amountKobo: order.totalAmountKobo, - deliveryAddress: order.deliveryAddress || "Not specified", }) .catch((err) => this.logger.error(`Error in sendDirectOrderNotification: ${err}`), @@ -1345,10 +1340,6 @@ export class OrderService { throw new ForbiddenException("Access denied"); } - if (!isBuyer && "deliveryOtp" in order) { - order.deliveryOtp = null; - } - // Shoppers must not see dropship internals: physical-store wholesale // cost, digital margin, or the linked fulfillment order id. The // shopper interacts only with the digital store identity. @@ -1356,6 +1347,10 @@ export class OrderService { this.stripDropshipPrivateFields(order); } + if (!isBuyer && isMerchant) { + this.sanitizeStoreOwnerOrder(order); + } + return order; } @@ -1371,6 +1366,19 @@ export class OrderService { target.sourcedProductId = null; } + // Store owners do not receive shopper contact details or shopper delivery + // coordinates by default. twizrr keeps those fields for approved logistics + // handoff and admin/operator paths, not store-owner API responses. + private sanitizeStoreOwnerOrder(order: unknown): void { + if (!order || typeof order !== "object") return; + const target = order as Record; + delete target.user; + delete target.buyerId; + delete target.deliveryDetails; + target.deliveryAddress = null; + target.deliveryOtp = null; + } + // ────────────────────────────────────────────── // LIST ORDERS // ────────────────────────────────────────────── @@ -1423,16 +1431,16 @@ export class OrderService { where: { storeId }, orderBy: { createdAt: "desc" }, include: { - user: { select: { email: true, phone: true } }, product: true, }, }, ); - // Strip OTP from merchant responses + // Store-owner responses are fulfillment/workflow views, not shopper + // contact exports. if (paginated.data) { (paginated.data as any[]).forEach((order) => { - order.deliveryOtp = null; + this.sanitizeStoreOwnerOrder(order); }); } diff --git a/apps/web/src/app/(store)/store/dashboard/_components/RecentOrders.tsx b/apps/web/src/app/(store)/store/dashboard/_components/RecentOrders.tsx index b778c3b3..0ad634d7 100644 --- a/apps/web/src/app/(store)/store/dashboard/_components/RecentOrders.tsx +++ b/apps/web/src/app/(store)/store/dashboard/_components/RecentOrders.tsx @@ -45,12 +45,14 @@ function statusBadgeConfig(status: StoreOrderStatus): StatusBadgeConfig { function escrowLine(status: StoreOrderStatus): string { if (status === "COMPLETED" || status === "DELIVERED") { - return "ESCROW: RELEASED"; + return "PROTECTED PAYMENT: RELEASED"; } - if (status === "CANCELLED") return "ESCROW: NO FUNDS HELD"; - if (status === "REFUND_PENDING") return "ESCROW: REFUND IN PROGRESS"; - if (status === "DISPUTE") return "ESCROW: IN REVIEW"; - return "ESCROW: FUNDS HELD · Release on delivery"; + if (status === "CANCELLED") return "PROTECTED PAYMENT: NO FUNDS HELD"; + if (status === "REFUND_PENDING") { + return "PROTECTED PAYMENT: REFUND IN PROGRESS"; + } + if (status === "DISPUTE") return "PROTECTED PAYMENT: IN REVIEW"; + return "PROTECTED PAYMENT: HELD UNTIL DELIVERY"; } function relativeTime(iso: string): string { @@ -80,10 +82,8 @@ function relativeTime(iso: string): string { * W-14 guardrail we don't do that. A subtle "Order details coming soon" * affordance sits at the right of each row. * - * Buyer column intentionally renders the generic label "Customer". The - * backend's listByMerchant include selects user.{email,phone} only — - * both are PII. The lib drops them at the whitelist boundary so they - * never reach this component. + * Buyer column intentionally renders the generic label "Customer". Backend + * store-owner responses do not expose shopper contact or delivery details. */ export function RecentOrders({ orders, loading, error }: RecentOrdersProps) { return ( @@ -157,9 +157,7 @@ function OrderRow({ order }: { order: StoreOrderRow }) {

{order.product?.name ?? "Order item"}

- {/* Generic label — backend's merchant-orders include selects - user.{email,phone}; lib/store-dashboard.ts drops both at - the whitelist boundary so no PII reaches the DOM. */} + {/* Generic label - shopper contact and delivery details stay private. */}

Customer · Qty {order.quantity}

diff --git a/apps/web/src/app/(store)/store/orders/StoreOrdersClient.tsx b/apps/web/src/app/(store)/store/orders/StoreOrdersClient.tsx index 4eb76526..68e2fb59 100644 --- a/apps/web/src/app/(store)/store/orders/StoreOrdersClient.tsx +++ b/apps/web/src/app/(store)/store/orders/StoreOrdersClient.tsx @@ -39,8 +39,8 @@ const TABS: FilterTab[] = [ key: "ACTIVE", label: "Active", // Active = everything between PAID and DELIVERED. DELIVERED stays - // active because the buyer still has to confirm and escrow has not - // released to your bank yet. + // active because the buyer still has to confirm and protected payment has + // not released to your bank yet. matches: (s) => s === "PAID" || s === "PREPARING" || @@ -109,8 +109,8 @@ export function StoreOrdersClient() { Store orders

- Dispatch paid orders, track active deliveries, and review escrow - status. + Dispatch paid orders, track active deliveries, and review protected + payment status.

diff --git a/apps/web/src/app/(store)/store/orders/[id]/StoreOrderDetailClient.tsx b/apps/web/src/app/(store)/store/orders/[id]/StoreOrderDetailClient.tsx index 77969b97..d63288f3 100644 --- a/apps/web/src/app/(store)/store/orders/[id]/StoreOrderDetailClient.tsx +++ b/apps/web/src/app/(store)/store/orders/[id]/StoreOrderDetailClient.tsx @@ -198,10 +198,7 @@ function DetailLoaded({ - +
@@ -315,10 +311,8 @@ function CustomerCard({ quantity }: { quantity: number }) { } function DeliveryCard({ - address, method, }: { - address: string | null; method: StoreOrderDetail["deliveryMethod"]; }) { return ( @@ -330,10 +324,11 @@ function DeliveryCard({ />

- Deliver to + Delivery details

- {address?.trim() || "Address not provided"} + Delivery address and phone are handled by twizrr and approved + logistics providers when required.

@@ -348,9 +343,9 @@ function DeliveryCard({

{method === "PLATFORM_LOGISTICS" - ? "Platform logistics (Shipbubble)" + ? "Twizrr-managed logistics" : method === "STORE_DELIVERY" - ? "Personal delivery" + ? "Twizrr-managed delivery" : "Not specified"}

@@ -379,7 +374,7 @@ function PaymentCard({ ? "Cancelled" : status === "REFUND_PENDING" ? "Refunding" - : "Paid via twizrr Escrow"; + : "Paid with twizrr Buyer Protection"; return (
@@ -413,7 +408,7 @@ function PaymentCard({ )} {platformFeeKobo !== null && ( <> -
Platform fee
+
Service fee
{formatKobo(platformFeeKobo)}
@@ -484,7 +479,7 @@ function ActionArea({ Ready to dispatch

- The buyer has paid and escrow is holding{" "} + The buyer has paid and twizrr Buyer Protection is holding{" "} {formatKobo(order.totalAmountKobo)}. Confirm dispatch to send the buyer their 6-digit delivery code.

@@ -502,7 +497,7 @@ function ActionArea({ ); } - // Read-only state per status — no destructive merchant actions in W-16. + // Read-only state per status — no destructive store-owner actions in W-16. let copy: string | null = null; if (order.status === "DISPATCHED" || order.status === "IN_TRANSIT") { copy = "Dispatched. Waiting for the buyer to confirm delivery."; diff --git a/apps/web/src/components/store-orders/DispatchOrderSheet.tsx b/apps/web/src/components/store-orders/DispatchOrderSheet.tsx index d8c1dd09..7c17f2c1 100644 --- a/apps/web/src/components/store-orders/DispatchOrderSheet.tsx +++ b/apps/web/src/components/store-orders/DispatchOrderSheet.tsx @@ -13,9 +13,9 @@ interface Props { onOpenChange: (open: boolean) => void; onConfirm: () => Promise; /** - * The merchant's preset delivery method on the order, if any. The + * The store's preset delivery method on the order, if any. The * dispatch endpoint takes NO body so this value is informational — - * surfaces what the buyer chose at checkout so the merchant doesn't + * surfaces what the buyer chose at checkout so the store owner doesn't * have to guess. We default the radio to "PERSONAL" because that's * the only active option in W-16. */ @@ -71,7 +71,7 @@ export function DispatchOrderSheet({ open={open} onOpenChange={onOpenChange} title="Dispatch order" - description="Confirm you're sending this order to the buyer. A 6-digit delivery code goes to them automatically. Funds release to you when they confirm delivery." + description="Confirm this order is ready for twizrr-managed delivery. A 6-digit delivery code goes to the buyer automatically." >
@@ -96,11 +96,11 @@ export function DispatchOrderSheet({ onSelect={() => setOption("PERSONAL")} disabled={submitting} icon={
diff --git a/apps/web/src/app/(store)/store/orders/StoreOrdersClient.tsx b/apps/web/src/app/(store)/store/orders/StoreOrdersClient.tsx index 68e2fb59..85dc38b4 100644 --- a/apps/web/src/app/(store)/store/orders/StoreOrdersClient.tsx +++ b/apps/web/src/app/(store)/store/orders/StoreOrdersClient.tsx @@ -109,8 +109,8 @@ export function StoreOrdersClient() { Store orders

- Dispatch paid orders, track active deliveries, and review protected - payment status. + Mark paid orders ready for pickup, track active deliveries, and review + protected payment status.

diff --git a/apps/web/src/app/(store)/store/orders/[id]/StoreOrderDetailClient.tsx b/apps/web/src/app/(store)/store/orders/[id]/StoreOrderDetailClient.tsx index d63288f3..80a92225 100644 --- a/apps/web/src/app/(store)/store/orders/[id]/StoreOrderDetailClient.tsx +++ b/apps/web/src/app/(store)/store/orders/[id]/StoreOrderDetailClient.tsx @@ -99,9 +99,10 @@ export function StoreOrderDetailClient({ orderId }: Props) { try { await dispatchStoreOrder(order.id); // Generic success copy — never forward backend strings. - toast("Order dispatched. The buyer's delivery code is on its way.", { - variant: "success", - }); + toast( + "Order marked ready for pickup. The buyer's delivery code is on its way.", + { variant: "success" }, + ); setDispatchOpen(false); await reload(); // Refresh tracking too — backend creates a tracking row on dispatch. @@ -112,7 +113,7 @@ export function StoreOrderDetailClient({ orderId }: Props) { /* best-effort */ } } catch { - toast("We couldn't dispatch this order. Please try again.", { + toast("We couldn't mark this order ready. Please try again.", { variant: "error", }); } @@ -198,7 +199,7 @@ function DetailLoaded({ - +
); @@ -310,11 +310,7 @@ function CustomerCard({ quantity }: { quantity: number }) { ); } -function DeliveryCard({ - method, -}: { - method: StoreOrderDetail["deliveryMethod"]; -}) { +function DeliveryCard() { return (
@@ -327,8 +323,7 @@ function DeliveryCard({ Delivery details

- Delivery address and phone are handled by twizrr and approved - logistics providers when required. + Delivery address and phone are managed by twizrr for fulfillment.

@@ -339,14 +334,11 @@ function DeliveryCard({ />

- Delivery method + Pickup and delivery

- {method === "PLATFORM_LOGISTICS" - ? "Twizrr-managed logistics" - : method === "STORE_DELIVERY" - ? "Twizrr-managed delivery" - : "Not specified"} + Delivery handled by twizrr. Mark the order ready for pickup when it + is packed.

@@ -476,12 +468,12 @@ function ActionArea({ return (

- Ready to dispatch + Ready for pickup

The buyer has paid and twizrr Buyer Protection is holding{" "} - {formatKobo(order.totalAmountKobo)}. Confirm dispatch to send the - buyer their 6-digit delivery code. + {formatKobo(order.totalAmountKobo)}. Mark the order ready for pickup + to send the buyer their delivery code.

diff --git a/apps/web/src/components/store-orders/DispatchOrderSheet.tsx b/apps/web/src/components/store-orders/DispatchOrderSheet.tsx index 7c17f2c1..7ab25d3b 100644 --- a/apps/web/src/components/store-orders/DispatchOrderSheet.tsx +++ b/apps/web/src/components/store-orders/DispatchOrderSheet.tsx @@ -1,58 +1,30 @@ "use client"; import { useEffect, useState } from "react"; -import { CheckCircle2, Lock, Shield, Truck } from "lucide-react"; +import { CheckCircle2, Shield, Truck } from "lucide-react"; import { BottomSheet } from "@/components/ui/BottomSheet"; import { Button } from "@/components/ui/Button"; -import { cn } from "@/lib/utils"; - -type DeliveryOption = "PERSONAL" | "SHIPBUBBLE"; interface Props { open: boolean; onOpenChange: (open: boolean) => void; onConfirm: () => Promise; - /** - * The store's preset delivery method on the order, if any. The - * dispatch endpoint takes NO body so this value is informational — - * surfaces what the buyer chose at checkout so the store owner doesn't - * have to guess. We default the radio to "PERSONAL" because that's - * the only active option in W-16. - */ - orderDeliveryMethod?: "STORE_DELIVERY" | "PLATFORM_LOGISTICS" | null; } /** - * Dispatch confirmation sheet. + * Ready-for-pickup confirmation sheet. * * Important contract: - * - `POST /orders/:id/dispatch` accepts NO body. The order's delivery - * method was already set at checkout; the dispatch service then: - * (a) validates status (must be PAID or PREPARING), - * (b) generates and bcrypt-hashes a 6-digit delivery OTP, - * (c) sends the plaintext OTP to the buyer via notifications, - * (d) atomically transitions to DISPATCHED. - * - There is no `trackingNumber` / `carrier` field on either the - * dispatch endpoint or the addTracking DTO. We do NOT include a - * tracking-number input — surfacing one with no destination would - * be dishonest UX. This is documented as a backend follow-up. - * - Shipbubble booking lives on a separate `POST /delivery/book` - * workflow that's out of W-16 scope. We render the Shipbubble - * option as Coming Soon. + * - `POST /orders/:id/dispatch` accepts no body. + * - The backend validates status, creates the delivery code, notifies the + * buyer, and transitions the order. + * - Delivery provider selection belongs to internal twizrr operations. */ -export function DispatchOrderSheet({ - open, - onOpenChange, - onConfirm, - orderDeliveryMethod, -}: Props) { - const [option, setOption] = useState("PERSONAL"); +export function DispatchOrderSheet({ open, onOpenChange, onConfirm }: Props) { const [submitting, setSubmitting] = useState(false); useEffect(() => { if (open) { - // Reset to defaults whenever the sheet reopens. - setOption("PERSONAL"); setSubmitting(false); } }, [open]); @@ -70,8 +42,8 @@ export function DispatchOrderSheet({
@@ -80,42 +52,27 @@ export function DispatchOrderSheet({ aria-hidden="true" />

- The buyer’s 6-digit code stays private. They share it with you - only on handover — never accept a screenshot before the item is in + The buyer’s delivery code stays private. They share it only + after handover; never accept a screenshot before the item is in their hands.

-
- - Delivery method - - - setOption("PERSONAL")} - disabled={submitting} - icon={
+
+

+ Book delivery through twizrr +

+

+ Mark the order ready for pickup. twizrr handles delivery + coordination from here. +

+
+
); } - -interface OptionRowProps { - selected: boolean; - onSelect: () => void; - disabled?: boolean; - comingSoon?: boolean; - icon: React.ReactNode; - title: string; - subtitle: string; -} - -function OptionRow({ - selected, - onSelect, - disabled, - comingSoon, - icon, - title, - subtitle, -}: OptionRowProps) { - return ( - - ); -} diff --git a/apps/web/src/lib/cart.ts b/apps/web/src/lib/cart.ts index bfb9e2c9..b0cf7354 100644 --- a/apps/web/src/lib/cart.ts +++ b/apps/web/src/lib/cart.ts @@ -270,7 +270,6 @@ export async function clearCart(): Promise { export interface CartCheckoutPayload { deliveryAddress: string; deliveryDetails?: Record; - deliveryMethod?: "STORE_DELIVERY" | "PLATFORM_LOGISTICS"; idempotencyKey?: string; } diff --git a/apps/web/src/lib/checkout.ts b/apps/web/src/lib/checkout.ts index bfec4175..5d9caad4 100644 --- a/apps/web/src/lib/checkout.ts +++ b/apps/web/src/lib/checkout.ts @@ -178,7 +178,6 @@ export interface DirectOrderPayload { quantity: number; deliveryAddress: string; deliveryDetails?: Record; - deliveryMethod?: "STORE_DELIVERY" | "PLATFORM_LOGISTICS"; idempotencyKey?: string; } @@ -195,7 +194,6 @@ export interface SourcedOrderPayload { quantity: number; deliveryAddress: string; deliveryDetails?: Record; - deliveryMethod?: "STORE_DELIVERY" | "PLATFORM_LOGISTICS"; idempotencyKey?: string; } From dc41551849566657be23b45c39ab09e0cc782a75 Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Sat, 6 Jun 2026 11:19:10 +0100 Subject: [PATCH 010/389] fix(web): address delivery policy review --- .../buyer/cart/CartCheckoutSheet.tsx | 5 ++- .../checkout/[productId]/CheckoutClient.tsx | 4 +- .../orders/[id]/StoreOrderDetailClient.tsx | 37 +++++++++++++++++-- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/apps/web/src/app/(shopper)/buyer/cart/CartCheckoutSheet.tsx b/apps/web/src/app/(shopper)/buyer/cart/CartCheckoutSheet.tsx index 5e3db970..59dc8740 100644 --- a/apps/web/src/app/(shopper)/buyer/cart/CartCheckoutSheet.tsx +++ b/apps/web/src/app/(shopper)/buyer/cart/CartCheckoutSheet.tsx @@ -257,14 +257,15 @@ export function CartCheckoutSheet({ title="Confirm your order" className="max-w-xl" > - {/* Escrow trust banner */} + {/* Buyer Protection trust banner */}
diff --git a/apps/web/src/app/(shopper)/buyer/checkout/[productId]/CheckoutClient.tsx b/apps/web/src/app/(shopper)/buyer/checkout/[productId]/CheckoutClient.tsx index 8867cd03..f7802477 100644 --- a/apps/web/src/app/(shopper)/buyer/checkout/[productId]/CheckoutClient.tsx +++ b/apps/web/src/app/(shopper)/buyer/checkout/[productId]/CheckoutClient.tsx @@ -423,7 +423,7 @@ export function CheckoutClient({
- {/* Escrow trust banner — visible throughout checkout */} + {/* Buyer Protection trust banner - visible throughout checkout */}

- Your payment is protected by twizrr Escrow + Your payment is protected by twizrr Buyer Protection

Money is held safely until you confirm delivery. diff --git a/apps/web/src/app/(store)/store/orders/[id]/StoreOrderDetailClient.tsx b/apps/web/src/app/(store)/store/orders/[id]/StoreOrderDetailClient.tsx index 80a92225..cb8ecceb 100644 --- a/apps/web/src/app/(store)/store/orders/[id]/StoreOrderDetailClient.tsx +++ b/apps/web/src/app/(store)/store/orders/[id]/StoreOrderDetailClient.tsx @@ -113,6 +113,27 @@ export function StoreOrderDetailClient({ orderId }: Props) { /* best-effort */ } } catch { + try { + const refreshed = await fetchStoreOrderById(order.id); + if (refreshed?.status === "DISPATCHED") { + setOrder(refreshed); + setState("ready"); + setDispatchOpen(false); + try { + const rows = await fetchStoreOrderTracking(order.id); + setTracking(rows); + } catch { + /* best-effort */ + } + toast( + "Order marked ready for pickup. The buyer's delivery code is on its way.", + { variant: "success" }, + ); + return; + } + } catch { + /* fall through */ + } toast("We couldn't mark this order ready. Please try again.", { variant: "error", }); @@ -199,7 +220,7 @@ function DetailLoaded({ - +

@@ -337,8 +367,7 @@ function DeliveryCard() { Pickup and delivery

- Delivery handled by twizrr. Mark the order ready for pickup when it - is packed. + {deliveryInstruction}

From d75e2cb4f4c867f0cf0a301c461e0b49b915391b Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Sat, 6 Jun 2026 11:39:53 +0100 Subject: [PATCH 011/389] fix(order): ignore shopper-selected delivery method --- apps/backend/src/modules/cart/cart.service.ts | 1 - .../src/modules/cart/dto/checkout-cart.dto.ts | 2 + .../modules/order/dto/checkout-cart.dto.ts | 2 + .../order/dto/create-direct-order.dto.ts | 2 + .../order/dto/create-sourced-order.dto.ts | 2 + .../src/modules/order/order.service.spec.ts | 282 ++++++++++++++++++ .../src/modules/order/order.service.ts | 34 +-- 7 files changed, 304 insertions(+), 21 deletions(-) diff --git a/apps/backend/src/modules/cart/cart.service.ts b/apps/backend/src/modules/cart/cart.service.ts index 3807bd44..de8706ee 100644 --- a/apps/backend/src/modules/cart/cart.service.ts +++ b/apps/backend/src/modules/cart/cart.service.ts @@ -165,7 +165,6 @@ export class CartService { cartItemIds: cartItems.map((item) => item.id), deliveryAddress: dto.deliveryAddress, deliveryDetails: dto.deliveryDetails, - deliveryMethod: dto.deliveryMethod, idempotencyKey: dto.idempotencyKey, }; diff --git a/apps/backend/src/modules/cart/dto/checkout-cart.dto.ts b/apps/backend/src/modules/cart/dto/checkout-cart.dto.ts index 3a3f2f7d..2185346c 100644 --- a/apps/backend/src/modules/cart/dto/checkout-cart.dto.ts +++ b/apps/backend/src/modules/cart/dto/checkout-cart.dto.ts @@ -17,6 +17,8 @@ export class CartCheckoutDto { @IsObject() deliveryDetails?: Record; + // Deprecated compatibility field. Shopper input is ignored by checkout; + // delivery is server-selected and handled by twizrr. @IsIn(["STORE_DELIVERY", "PLATFORM_LOGISTICS"]) @IsOptional() deliveryMethod?: "STORE_DELIVERY" | "PLATFORM_LOGISTICS"; diff --git a/apps/backend/src/modules/order/dto/checkout-cart.dto.ts b/apps/backend/src/modules/order/dto/checkout-cart.dto.ts index 572486c0..c0efec15 100644 --- a/apps/backend/src/modules/order/dto/checkout-cart.dto.ts +++ b/apps/backend/src/modules/order/dto/checkout-cart.dto.ts @@ -23,6 +23,8 @@ export class CheckoutCartDto { @IsObject() deliveryDetails?: Record; + // Deprecated compatibility field. Shopper input is ignored; delivery is + // server-selected and handled by twizrr. @IsIn(["STORE_DELIVERY", "PLATFORM_LOGISTICS"]) @IsOptional() deliveryMethod?: "STORE_DELIVERY" | "PLATFORM_LOGISTICS"; diff --git a/apps/backend/src/modules/order/dto/create-direct-order.dto.ts b/apps/backend/src/modules/order/dto/create-direct-order.dto.ts index c97e8add..82d030e2 100644 --- a/apps/backend/src/modules/order/dto/create-direct-order.dto.ts +++ b/apps/backend/src/modules/order/dto/create-direct-order.dto.ts @@ -28,6 +28,8 @@ export class CreateDirectOrderDto { @IsObject() deliveryDetails?: Record; + // Deprecated compatibility field. Shopper input is ignored; delivery is + // server-selected and handled by twizrr. @IsOptional() @IsEnum(DeliveryMethod, { message: "deliveryMethod must be STORE_DELIVERY or PLATFORM_LOGISTICS", diff --git a/apps/backend/src/modules/order/dto/create-sourced-order.dto.ts b/apps/backend/src/modules/order/dto/create-sourced-order.dto.ts index a579598d..a87cb114 100644 --- a/apps/backend/src/modules/order/dto/create-sourced-order.dto.ts +++ b/apps/backend/src/modules/order/dto/create-sourced-order.dto.ts @@ -23,6 +23,8 @@ export class CreateSourcedOrderDto { @IsObject() deliveryDetails?: Record; + // Deprecated compatibility field. Shopper input is ignored; delivery is + // server-selected and handled by twizrr. @IsOptional() @IsEnum(DeliveryMethod, { message: "deliveryMethod must be STORE_DELIVERY or PLATFORM_LOGISTICS", diff --git a/apps/backend/src/modules/order/order.service.spec.ts b/apps/backend/src/modules/order/order.service.spec.ts index 6f4138a9..3ae95e23 100644 --- a/apps/backend/src/modules/order/order.service.spec.ts +++ b/apps/backend/src/modules/order/order.service.spec.ts @@ -1,6 +1,8 @@ import { ConflictException } from "@nestjs/common"; +import { DeliveryMethod } from "@prisma/client"; import { OrderStatus } from "@twizrr/shared"; +import { CartService } from "../cart/cart.service"; import { OrderService } from "./order.service"; type TransitionForTest = ( @@ -223,3 +225,283 @@ describe("OrderService store-owner privacy", () => { expect(orderEvents[0].userId).toBeNull(); }); }); + +describe("OrderService server-selected delivery method", () => { + const makeService = (overrides: Record = {}) => { + const orderCreate = jest.fn( + async ({ data }: { data: Record }) => ({ + id: + data.orderType === "DROPSHIP_FULFILLMENT" + ? "fulfillment-order-1" + : "order-1", + orderCode: "TWZ-123456", + quantity: 1, + linkedOrderId: null, + ...data, + }), + ); + const tx = { + order: { + findUnique: jest.fn().mockResolvedValue(null), + create: orderCreate, + update: jest.fn( + async ({ data }: { data: Record }) => ({ + id: "order-1", + orderCode: "TWZ-123456", + buyerId: "buyer-1", + storeId: "digital-store-1", + totalAmountKobo: 1500n, + platformFeeKobo: 0n, + ...data, + }), + ), + }, + productStockCache: { + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + inventoryEvent: { + create: jest.fn().mockResolvedValue({ id: "inventory-event-1" }), + }, + orderEvent: { + create: jest.fn().mockResolvedValue({ id: "order-event-1" }), + }, + }; + const product = { + id: "product-1", + storeId: "store-1", + isActive: true, + retailPriceKobo: 1000n, + wholesalePriceKobo: null, + pricePerUnitKobo: null, + unit: "piece", + name: "Test product", + productStockCaches: [{ stock: 10 }], + storeProfile: { + userId: "store-owner-1", + verificationTier: "TIER_1", + businessAddress: "10 Market Road", + }, + }; + const sourcedProduct = { + id: "sourced-product-1", + isActive: true, + sellingPriceKobo: 1500n, + floorPriceKobo: 1000n, + digitalStoreId: "digital-store-1", + physicalStoreId: "physical-store-1", + digitalStore: { + userId: "digital-store-owner", + verificationTier: "TIER_1", + }, + physicalStore: { + userId: "physical-store-owner", + }, + physicalProduct: { + ...product, + id: "physical-product-1", + storeId: "physical-store-1", + dropshipperPriceKobo: 1000n, + }, + }; + const cartItem = { + id: "cart-item-1", + buyerId: "buyer-1", + productId: "product-1", + quantity: 1, + priceType: "RETAIL", + product, + }; + const prisma = { + product: { + findUnique: jest.fn().mockResolvedValue(product), + }, + sourcedProduct: { + findUnique: jest.fn().mockResolvedValue(sourcedProduct), + }, + cartItem: { + findMany: jest.fn().mockResolvedValue([cartItem]), + }, + order: { + findUnique: jest.fn().mockResolvedValue({ + id: "order-1", + user: { firstName: "Buyer", lastName: "One" }, + product: { name: "Test product" }, + }), + }, + $transaction: jest.fn((callback: (transaction: typeof tx) => unknown) => + callback(tx), + ), + ...overrides, + }; + const logisticsService = { + getQuote: jest.fn().mockResolvedValue({ costKobo: 25000 }), + }; + const ledgerService = { + calculateCheckoutTotals: jest.fn( + ({ + subtotalKobo, + deliveryFeeKobo, + }: { + subtotalKobo: bigint; + deliveryFeeKobo: bigint; + }) => ({ + subtotalKobo, + deliveryFeeKobo, + totalAmountKobo: subtotalKobo + deliveryFeeKobo, + platformFeeKobo: 0n, + platformFeePercent: 0, + }), + ), + recordCheckoutCreated: jest.fn().mockResolvedValue(undefined), + }; + const paymentService = { + initialize: jest + .fn() + .mockResolvedValue({ authorization_url: "https://pay.test/checkout" }), + }; + const checkoutReminderQueue = { + add: jest.fn().mockResolvedValue(undefined), + }; + const whatsappService = { + sendDirectOrderNotification: jest.fn().mockResolvedValue(undefined), + }; + const domainEvents = { + append: jest.fn().mockResolvedValue({ id: "domain-event-1" }), + }; + const service = new OrderService( + prisma as never, + {} as never, + {} as never, + paymentService as never, + checkoutReminderQueue as never, + {} as never, + {} as never, + {} as never, + logisticsService as never, + whatsappService as never, + {} as never, + ledgerService as never, + domainEvents as never, + ); + + return { + service, + tx, + prisma, + logisticsService, + ledgerService, + paymentService, + checkoutReminderQueue, + }; + }; + + it("ignores shopper-selected platform logistics for direct orders", async () => { + const { service, tx, logisticsService } = makeService(); + + await service.createDirectOrder("buyer-1", { + productId: "product-1", + quantity: 1, + deliveryAddress: "20 Shopper Street", + deliveryMethod: DeliveryMethod.PLATFORM_LOGISTICS, + }); + + expect(logisticsService.getQuote).not.toHaveBeenCalled(); + expect(tx.order.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + deliveryMethod: DeliveryMethod.STORE_DELIVERY, + deliveryFeeKobo: 0n, + }), + }); + }); + + it("ignores shopper-selected platform logistics for sourced orders", async () => { + const { service, tx, logisticsService } = makeService(); + + await service.createSourcedOrder("buyer-1", "sourced-product-1", { + quantity: 1, + deliveryAddress: "20 Shopper Street", + deliveryMethod: DeliveryMethod.PLATFORM_LOGISTICS, + }); + + expect(logisticsService.getQuote).not.toHaveBeenCalled(); + expect(tx.order.create).toHaveBeenNthCalledWith(1, { + data: expect.objectContaining({ + orderType: "DROPSHIP_CUSTOMER", + deliveryMethod: DeliveryMethod.STORE_DELIVERY, + deliveryFeeKobo: 0n, + }), + }); + expect(tx.order.create).toHaveBeenNthCalledWith(2, { + data: expect.objectContaining({ + orderType: "DROPSHIP_FULFILLMENT", + deliveryMethod: DeliveryMethod.STORE_DELIVERY, + deliveryFeeKobo: 0n, + }), + }); + }); + + it("ignores shopper-selected platform logistics for cart checkout", async () => { + const { service, tx, logisticsService } = makeService(); + + await service.checkoutCart("buyer-1", { + cartItemIds: ["cart-item-1"], + deliveryAddress: "20 Shopper Street", + deliveryMethod: "PLATFORM_LOGISTICS", + }); + + expect(logisticsService.getQuote).not.toHaveBeenCalled(); + expect(tx.order.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + deliveryMethod: DeliveryMethod.STORE_DELIVERY, + deliveryFeeKobo: 0n, + }), + }); + }); + + it("still creates orders when legacy store-delivery input is sent", async () => { + const { service, tx, paymentService } = makeService(); + + const result = await service.createDirectOrder("buyer-1", { + productId: "product-1", + quantity: 1, + deliveryAddress: "20 Shopper Street", + deliveryMethod: DeliveryMethod.STORE_DELIVERY, + }); + + expect(tx.order.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + deliveryMethod: DeliveryMethod.STORE_DELIVERY, + }), + }); + expect(paymentService.initialize).toHaveBeenCalled(); + expect(result.authorizationUrl).toBe("https://pay.test/checkout"); + }); +}); + +describe("CartService checkout delivery compatibility", () => { + it("does not forward shopper-selected deliveryMethod to order checkout", async () => { + const prisma = { + cartItem: { + findMany: jest.fn().mockResolvedValue([{ id: "cart-item-1" }]), + }, + }; + const orderService = { + checkoutCart: jest.fn().mockResolvedValue({ orderId: "order-1" }), + }; + const service = new CartService(prisma as never, orderService as never); + + await service.checkout("buyer-1", { + deliveryAddress: "20 Shopper Street", + deliveryMethod: "PLATFORM_LOGISTICS", + }); + + const forwardedDto = orderService.checkoutCart.mock.calls[0][1]; + expect(forwardedDto).toEqual({ + cartItemIds: ["cart-item-1"], + deliveryAddress: "20 Shopper Street", + deliveryDetails: undefined, + idempotencyKey: undefined, + }); + expect("deliveryMethod" in forwardedDto).toBe(false); + }); +}); diff --git a/apps/backend/src/modules/order/order.service.ts b/apps/backend/src/modules/order/order.service.ts index 92719099..0e284136 100644 --- a/apps/backend/src/modules/order/order.service.ts +++ b/apps/backend/src/modules/order/order.service.ts @@ -10,7 +10,7 @@ import { } from "@nestjs/common"; import { InjectQueue } from "@nestjs/bullmq"; import { Queue } from "bullmq"; -import { Prisma } from "@prisma/client"; +import { DeliveryMethod, Prisma } from "@prisma/client"; import { PrismaService } from "../../prisma/prisma.service"; import { NotificationTriggerService } from "../notification/notification-trigger.service"; import { InventoryService } from "../inventory/inventory.service"; @@ -24,6 +24,9 @@ import { const AUTO_CONFIRM_DELAY_MS = 48 * 60 * 60 * 1000; const AUTO_CONFIRM_WARNING_DELAY_MS = 36 * 60 * 60 * 1000; +// Delivery is Twizrr-managed. Keep the legacy enum value that does not +// auto-queue provider booking until the internal logistics flow is wired. +const SERVER_SELECTED_DELIVERY_METHOD = DeliveryMethod.STORE_DELIVERY; import { WhatsAppService } from "../../channels/whatsapp/whatsapp.service"; import { InventoryEventType, @@ -89,13 +92,8 @@ export class OrderService { // ────────────────────────────────────────────── async createDirectOrder(buyerId: string, dto: CreateDirectOrderDto) { - const { - productId, - quantity, - deliveryAddress, - deliveryDetails, - deliveryMethod = "STORE_DELIVERY", - } = dto; + const { productId, quantity, deliveryAddress, deliveryDetails } = dto; + const deliveryMethod = this.getServerSelectedDeliveryMethod(); const product = await this.prisma.product.findUnique({ where: { id: productId }, include: { @@ -345,12 +343,8 @@ export class OrderService { sourcedProductId: string, dto: CreateSourcedOrderDto, ) { - const { - quantity, - deliveryAddress, - deliveryDetails, - deliveryMethod = "STORE_DELIVERY", - } = dto; + const { quantity, deliveryAddress, deliveryDetails } = dto; + const deliveryMethod = this.getServerSelectedDeliveryMethod(); const sourcedProduct = await this.prisma.sourcedProduct.findUnique({ where: { id: sourcedProductId }, @@ -698,12 +692,8 @@ export class OrderService { // ─────────────────────────────────────────────────────────────────────────── async checkoutCart(buyerId: string, dto: CheckoutCartDto) { - const { - cartItemIds, - deliveryAddress, - deliveryDetails, - deliveryMethod = "STORE_DELIVERY", - } = dto; + const { cartItemIds, deliveryAddress, deliveryDetails } = dto; + const deliveryMethod = this.getServerSelectedDeliveryMethod(); if (!cartItemIds || cartItemIds.length === 0) { throw new BadRequestException("No cart items provided for checkout."); @@ -1008,6 +998,10 @@ export class OrderService { }); } + private getServerSelectedDeliveryMethod(): DeliveryMethod { + return SERVER_SELECTED_DELIVERY_METHOD; + } + private getTransitionDomainEventType( toStatus: OrderStatus, metadata?: Record, From a1e35d591fc50d4837c4e015f42f5eedd2540500 Mon Sep 17 00:00:00 2001 From: SAHEED2010 Date: Sun, 7 Jun 2026 03:36:09 +1300 Subject: [PATCH 012/389] fix(whatsapp): address final regression findings --- .../whatsapp/image-search.service.spec.ts | 132 ++++++++++++++++++ .../channels/whatsapp/image-search.service.ts | 121 +++++++++------- .../whatsapp/whatsapp-auth.service.spec.ts | 39 ++++++ .../whatsapp/whatsapp-auth.service.ts | 3 +- ...whatsapp-product-discovery.service.spec.ts | 34 ++++- .../whatsapp-product-discovery.service.ts | 41 +++++- .../channels/whatsapp/whatsapp.controller.ts | 18 ++- 7 files changed, 322 insertions(+), 66 deletions(-) create mode 100644 apps/backend/src/channels/whatsapp/image-search.service.spec.ts diff --git a/apps/backend/src/channels/whatsapp/image-search.service.spec.ts b/apps/backend/src/channels/whatsapp/image-search.service.spec.ts new file mode 100644 index 00000000..f3de44c7 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/image-search.service.spec.ts @@ -0,0 +1,132 @@ +import { + ModerationStatus, + Prisma, + ProductStatus, + StoreTier, +} from "@prisma/client"; +import { ImageSearchService } from "./image-search.service"; + +describe("ImageSearchService", () => { + const prisma = { + product: { + findMany: jest.fn(), + }, + }; + const interactiveService = { + sendTextMessage: jest.fn(), + sendListMessage: jest.fn(), + sendReplyButtons: jest.fn(), + }; + const metaWhatsAppClient = { + downloadImage: jest.fn(), + }; + const visionClient = { + detectProductTerms: jest.fn(), + }; + + let service: ImageSearchService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new ImageSearchService( + prisma as any, + interactiveService as any, + metaWhatsAppClient as any, + visionClient as any, + ); + }); + + it("uses Cloud Vision terms with public-safe product filters and product codes", async () => { + metaWhatsAppClient.downloadImage.mockResolvedValue({ + base64Data: "base64-image", + mimeType: "image/jpeg", + }); + visionClient.detectProductTerms.mockResolvedValue(["sneakers"]); + prisma.product.findMany.mockResolvedValue([ + { + id: "internal-product-id", + productCode: "TWZ-SNK-001", + name: "White Sneakers", + retailPriceKobo: 1500000n, + pricePerUnitKobo: null, + storeProfile: { + businessName: "Sneaker Store", + storeHandle: "sneakerstore", + storeName: "Sneaker Store NG", + }, + }, + ]); + + await service.handleImageSearch("+2348012345678", "image-id"); + + expect(visionClient.detectProductTerms).toHaveBeenCalledWith( + "base64-image", + ); + expect(prisma.product.findMany).toHaveBeenCalledWith({ + where: { + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + productCode: { not: null }, + moderationStatus: { not: ModerationStatus.BLOCKED }, + storeProfile: { tier: { not: StoreTier.TIER_0 }, isOpen: true }, + OR: [ + { + name: { + contains: "sneakers", + mode: Prisma.QueryMode.insensitive, + }, + }, + ], + }, + include: { + storeProfile: { + select: { + businessName: true, + storeHandle: true, + storeName: true, + }, + }, + }, + take: 20, + orderBy: { createdAt: "desc" }, + }); + expect(interactiveService.sendListMessage).toHaveBeenLastCalledWith( + "+2348012345678", + 'Matching products for "sneakers":', + "View Products", + [ + { + title: "Matches", + rows: [ + { + id: "product_TWZ-SNK-001", + title: "White Sneakers", + description: "NGN 15,000 | sneakerstore", + }, + ], + }, + ], + ); + }); + + it("does not add markdown emphasis to fallback image-search copy", async () => { + metaWhatsAppClient.downloadImage.mockResolvedValue({ + base64Data: "base64-image", + mimeType: "image/jpeg", + }); + visionClient.detectProductTerms.mockResolvedValue(["bag"]); + prisma.product.findMany.mockResolvedValue([]); + + await service.handleImageSearch("+2348012345678", "image-id"); + + expect(interactiveService.sendReplyButtons).toHaveBeenCalledWith( + "+2348012345678", + expect.not.stringContaining("*bag*"), + [ + { id: "browse_categories", title: "Browse Categories" }, + { id: "search_products", title: "Try Text Search" }, + ], + ); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/image-search.service.ts b/apps/backend/src/channels/whatsapp/image-search.service.ts index a6d9360d..28f630f3 100644 --- a/apps/backend/src/channels/whatsapp/image-search.service.ts +++ b/apps/backend/src/channels/whatsapp/image-search.service.ts @@ -1,6 +1,10 @@ import { Injectable, Logger } from "@nestjs/common"; -import { Prisma, StoreTier } from "@prisma/client"; -import { GeminiClient } from "../../integrations/ai/gemini.client"; +import { + ModerationStatus, + Prisma, + ProductStatus, + StoreTier, +} from "@prisma/client"; import { VisionClient } from "../../integrations/ai/vision.client"; import { MetaWhatsAppClient } from "../../integrations/meta-whatsapp/meta-whatsapp.client"; import { PrismaService } from "../../prisma/prisma.service"; @@ -14,7 +18,6 @@ export class ImageSearchService { private prisma: PrismaService, private interactiveService: WhatsAppInteractiveService, private metaWhatsAppClient: MetaWhatsAppClient, - private geminiClient: GeminiClient, private visionClient: VisionClient, ) {} @@ -41,22 +44,9 @@ export class ImageSearchService { return; } - const { base64Data, mimeType } = imageResult; + const { base64Data } = imageResult; - let extractedTerms = await this.analyzeImage( - base64Data, - mimeType, - "cloud_vision", - ); - - if (!extractedTerms) { - this.logger.log("Primary API failed, falling back to gemini"); - extractedTerms = await this.analyzeImage( - base64Data, - mimeType, - "gemini", - ); - } + const extractedTerms = await this.analyzeImage(base64Data); if (!extractedTerms) { await this.interactiveService.sendTextMessage( @@ -71,27 +61,38 @@ export class ImageSearchService { const products = await this.searchProducts(extractedTerms); if (products.length === 0) { - const bodyText = `I identified the item as *${extractedTerms[0]}*, but I couldn't find a direct match right now.\n\nHere are some other popular products you might like instead:`; + const bodyText = `I identified the item as ${extractedTerms[0]}, but I couldn't find a direct match right now.\n\nHere are some other popular products you might like instead:`; const fallbackProducts = await this.prisma.product.findMany({ where: { + status: ProductStatus.ACTIVE, isActive: true, - storeProfile: { tier: { not: StoreTier.TIER_0 } }, + deletedAt: null, + productCode: { not: null }, + moderationStatus: { not: ModerationStatus.BLOCKED }, + storeProfile: { tier: { not: StoreTier.TIER_0 }, isOpen: true }, + }, + include: { + storeProfile: { + select: { + businessName: true, + storeHandle: true, + storeName: true, + }, + }, }, - include: { storeProfile: { select: { businessName: true } } }, take: 5, orderBy: { createdAt: "desc" }, }); if (fallbackProducts.length > 0) { const rows = fallbackProducts.map((product) => ({ - id: `product_${product.id}`, + id: `product_${product.productCode || product.id}`, title: product.name.substring(0, 24), - description: - `NGN ${(Number(product.retailPriceKobo || product.pricePerUnitKobo || 0) / 100).toLocaleString("en-NG")} | ${product.storeProfile?.businessName || "Verified Shop"}`.substring( - 0, - 72, - ), + description: this.formatProductDescription(product).substring( + 0, + 72, + ), })); await this.interactiveService.sendListMessage( @@ -120,7 +121,7 @@ export class ImageSearchService { return; } - const bodyTextFallback = `I identified the item as *${extractedTerms[0]}*, but I couldn't find a direct match.\n\nWould you like to browse our top categories or search for a different product?`; + const bodyTextFallback = `I identified the item as ${extractedTerms[0]}, but I couldn't find a direct match.\n\nWould you like to browse our top categories or search for a different product?`; await this.interactiveService.sendReplyButtons( phone, bodyTextFallback, @@ -133,13 +134,9 @@ export class ImageSearchService { } const rows = products.slice(0, 10).map((product) => ({ - id: `product_${product.id}`, + id: `product_${product.productCode || product.id}`, title: product.name.substring(0, 24), - description: - `NGN ${(Number(product.retailPriceKobo || product.pricePerUnitKobo || 0) / 100).toLocaleString("en-NG")} | ${product.storeProfile?.businessName || "Verified Shop"}`.substring( - 0, - 72, - ), + description: this.formatProductDescription(product).substring(0, 72), })); await this.interactiveService.sendListMessage( @@ -166,20 +163,8 @@ export class ImageSearchService { return this.metaWhatsAppClient.downloadImage(imageId); } - private async analyzeImage( - base64Data: string, - mimeType: string, - apiType: string, - ): Promise { - if (apiType === "cloud_vision") { - return this.visionClient.detectProductTerms(base64Data); - } - - if (apiType === "gemini") { - return this.geminiClient.analyzeImageKeywords(base64Data, mimeType); - } - - return null; + private async analyzeImage(base64Data: string): Promise { + return this.visionClient.detectProductTerms(base64Data); } private async searchProducts(terms: string[]) { @@ -191,12 +176,46 @@ export class ImageSearchService { return this.prisma.product.findMany({ where: { - AND: [{ isActive: true }, { OR: searchConditions }], - storeProfile: { tier: { not: StoreTier.TIER_0 } }, + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + productCode: { not: null }, + moderationStatus: { not: ModerationStatus.BLOCKED }, + storeProfile: { tier: { not: StoreTier.TIER_0 }, isOpen: true }, + OR: searchConditions, + }, + include: { + storeProfile: { + select: { + businessName: true, + storeHandle: true, + storeName: true, + }, + }, }, - include: { storeProfile: { select: { businessName: true } } }, take: 20, orderBy: { createdAt: "desc" }, }); } + + private formatProductDescription(product: { + retailPriceKobo?: bigint | number | null; + pricePerUnitKobo?: bigint | number | null; + storeProfile?: { + businessName?: string | null; + storeHandle?: string | null; + storeName?: string | null; + } | null; + }): string { + const amountKobo = product.retailPriceKobo ?? product.pricePerUnitKobo ?? 0; + const amountNaira = Number(amountKobo) / 100; + const price = amountNaira.toLocaleString("en-NG"); + const storeName = + product.storeProfile?.storeHandle || + product.storeProfile?.storeName || + product.storeProfile?.businessName || + "Verified Shop"; + + return `NGN ${price} | ${storeName}`; + } } diff --git a/apps/backend/src/channels/whatsapp/whatsapp-auth.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-auth.service.spec.ts index 2ed46ee1..f18ff6d7 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp-auth.service.spec.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp-auth.service.spec.ts @@ -117,4 +117,43 @@ describe("WhatsAppAuthService account linking", () => { expect(redisService.set).not.toHaveBeenCalled(); expect(emailService.sendVerificationOTP).not.toHaveBeenCalled(); }); + + it("creates a 6-digit OTP for WhatsApp-first email verification", async () => { + const phone = "+2348012345678"; + const sessionKey = `${WA_SESSION_PREFIX}${phone}`; + const otpKey = `${WA_OTP_PREFIX}${phone}`; + redisService.get.mockResolvedValue( + JSON.stringify({ + state: SessionState.AWAITING_EMAIL, + data: {}, + }), + ); + prisma.user.findUnique.mockResolvedValue({ + id: "user-1", + firstName: "Ameen", + whatsappLink: null, + }); + emailService.sendVerificationOTP.mockResolvedValue(undefined); + + const response = await service.handleLinkingFlow( + phone, + "ameen@example.com", + ); + + const otpWrite = redisService.set.mock.calls.find( + ([key]) => key === otpKey, + ); + expect(otpWrite).toBeDefined(); + expect(otpWrite?.[1]).toMatch(/^\d{6}$/); + expect(emailService.sendVerificationOTP).toHaveBeenCalledWith( + "ameen@example.com", + otpWrite?.[1], + ); + expect(redisService.set).toHaveBeenCalledWith( + sessionKey, + expect.stringContaining('"state":"AWAITING_OTP"'), + expect.any(Number), + ); + expect(response).toContain("verification code"); + }); }); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-auth.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-auth.service.ts index d163faa6..24cb39ce 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp-auth.service.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp-auth.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; +import { randomInt } from "crypto"; import { PrismaService } from "../../prisma/prisma.service"; import { RedisService } from "../../redis/redis.service"; import { EmailService } from "../../modules/email/email.service"; @@ -152,7 +153,7 @@ export class WhatsAppAuthService { return ALREADY_LINKED; } - const otp = Math.floor(100000 + Math.random() * 900000).toString(); + const otp = randomInt(100000, 1000000).toString(); const otpKey = `${WA_OTP_PREFIX}${phone}`; await this.redisService.set(otpKey, otp, OTP_TTL); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.spec.ts index 3c1adfc0..a003f0e4 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.spec.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.spec.ts @@ -1,4 +1,9 @@ -import { Prisma, StoreTier } from "@prisma/client"; +import { + ModerationStatus, + Prisma, + ProductStatus, + StoreTier, +} from "@prisma/client"; import { WhatsAppProductDiscoveryService } from "./whatsapp-product-discovery.service"; describe("WhatsAppProductDiscoveryService", () => { @@ -27,10 +32,15 @@ describe("WhatsAppProductDiscoveryService", () => { prisma.product.findMany.mockResolvedValue([ { id: "product-1", + productCode: "TWZ-RED-001", name: "Red Shoes", retailPriceKobo: 250000n, pricePerUnitKobo: null, - storeProfile: { businessName: "Style Hub" }, + storeProfile: { + businessName: "Style Hub", + storeHandle: "stylehub", + storeName: "Style Hub NG", + }, }, ]); @@ -38,8 +48,12 @@ describe("WhatsAppProductDiscoveryService", () => { expect(prisma.product.findMany).toHaveBeenCalledWith({ where: { + status: ProductStatus.ACTIVE, isActive: true, - storeProfile: { tier: { not: StoreTier.TIER_0 } }, + deletedAt: null, + productCode: { not: null }, + moderationStatus: { not: ModerationStatus.BLOCKED }, + storeProfile: { tier: { not: StoreTier.TIER_0 }, isOpen: true }, OR: [ { name: { @@ -73,7 +87,15 @@ describe("WhatsAppProductDiscoveryService", () => { }, ], }, - include: { storeProfile: { select: { businessName: true } } }, + include: { + storeProfile: { + select: { + businessName: true, + storeHandle: true, + storeName: true, + }, + }, + }, take: 10, orderBy: { createdAt: "desc" }, }); @@ -86,9 +108,9 @@ describe("WhatsAppProductDiscoveryService", () => { title: "Products", rows: [ { - id: "product_product-1", + id: "product_TWZ-RED-001", title: "Red Shoes", - description: "NGN 2,500 | Style Hub", + description: "NGN 2,500 | stylehub", }, ], }, diff --git a/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.ts index 324f2525..92cfc51f 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.ts @@ -1,5 +1,10 @@ import { Injectable } from "@nestjs/common"; -import { Prisma, StoreTier } from "@prisma/client"; +import { + ModerationStatus, + Prisma, + ProductStatus, + StoreTier, +} from "@prisma/client"; import { PrismaService } from "../../prisma/prisma.service"; import { WhatsAppInteractiveService } from "./whatsapp-interactive.service"; @@ -36,7 +41,7 @@ export class WhatsAppProductDiscoveryService { } const rows = products.slice(0, 10).map((product) => ({ - id: `product_${product.id}`, + id: `product_${product.productCode || product.id}`, title: product.name.substring(0, 24), description: this.formatProductDescription(product).substring(0, 72), })); @@ -52,8 +57,15 @@ export class WhatsAppProductDiscoveryService { private async searchProducts(query: string) { return this.prisma.product.findMany({ where: { + status: ProductStatus.ACTIVE, isActive: true, - storeProfile: { tier: { not: StoreTier.TIER_0 } }, + deletedAt: null, + productCode: { not: null }, + moderationStatus: { not: ModerationStatus.BLOCKED }, + storeProfile: { + tier: { not: StoreTier.TIER_0 }, + isOpen: true, + }, OR: [ { name: { contains: query, mode: Prisma.QueryMode.insensitive } }, { title: { contains: query, mode: Prisma.QueryMode.insensitive } }, @@ -77,21 +89,38 @@ export class WhatsAppProductDiscoveryService { }, ], }, - include: { storeProfile: { select: { businessName: true } } }, + include: { + storeProfile: { + select: { + businessName: true, + storeHandle: true, + storeName: true, + }, + }, + }, take: 10, orderBy: { createdAt: "desc" }, }); } private formatProductDescription(product: { + productCode?: string | null; retailPriceKobo?: bigint | number | null; pricePerUnitKobo?: bigint | number | null; - storeProfile?: { businessName?: string | null } | null; + storeProfile?: { + businessName?: string | null; + storeHandle?: string | null; + storeName?: string | null; + } | null; }): string { const amountKobo = product.retailPriceKobo ?? product.pricePerUnitKobo ?? 0; const amountNaira = Number(amountKobo) / 100; const price = amountNaira.toLocaleString("en-NG"); - const storeName = product.storeProfile?.businessName || "Verified shop"; + const storeName = + product.storeProfile?.storeHandle || + product.storeProfile?.storeName || + product.storeProfile?.businessName || + "twizrr store"; return `NGN ${price} | ${storeName}`; } diff --git a/apps/backend/src/channels/whatsapp/whatsapp.controller.ts b/apps/backend/src/channels/whatsapp/whatsapp.controller.ts index 5a7a8a72..af249b72 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp.controller.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp.controller.ts @@ -19,6 +19,20 @@ import { RedisService } from "../../redis/redis.service"; import { WHATSAPP_QUEUE } from "../../queue/queue.constants"; import { WA_MSG_DEDUP_PREFIX, MSG_DEDUP_TTL } from "./whatsapp.constants"; +function normalizeWhatsAppPhone(phone: string): string { + const cleaned = phone.trim().replace(/[\s\-()]/g, ""); + if (cleaned.startsWith("+")) return cleaned; + if (cleaned.startsWith("0")) return `+234${cleaned.slice(1)}`; + return `+${cleaned}`; +} + +function maskPhone(phone: string): string { + if (!phone) return "unknown"; + return phone.replace(/^(\+\d{1,3})(\d+)(\d{2})$/, (_, p1, p2, p3) => { + return p1 + "*".repeat(p2.length) + p3; + }); +} + /** * WhatsApp Webhook Controller * @@ -88,7 +102,7 @@ export class WhatsAppController { } const message = value.messages[0]; - const phone = message.from; // Sender's phone number (e.g. "2348147846093") + const phone = normalizeWhatsAppPhone(message.from); const messageId = message.id; // Unique Meta message ID const messageType = message.type; // "text", "interactive", "image", etc. @@ -164,7 +178,7 @@ export class WhatsAppController { } this.logger.log( - `Queued WhatsApp message from ${phone}: type=${messageType}, text="${(messageText || "").substring(0, 50)}", interactive=${interactiveReply?.id || "none"}`, + `Queued WhatsApp message from ${maskPhone(phone)}: type=${messageType}, hasText=${Boolean(messageText)}, interactive=${interactiveReply?.id || "none"}`, ); } catch (error) { this.logger.error( From ac0a57c28a59ab460847ed0c01ad8863af41f96a Mon Sep 17 00:00:00 2001 From: SAHEED2010 Date: Sun, 7 Jun 2026 05:44:54 +1300 Subject: [PATCH 013/389] fix(whatsapp): address regression review cleanup --- .../channels/whatsapp/image-search.service.ts | 2 +- .../whatsapp/whatsapp-auth.service.ts | 14 ++++------- .../whatsapp-product-discovery.service.ts | 1 - .../whatsapp/whatsapp-session.service.ts | 19 +++----------- .../channels/whatsapp/whatsapp.controller.ts | 17 ++----------- .../src/channels/whatsapp/whatsapp.service.ts | 12 +++------ .../src/channels/whatsapp/whatsapp.utils.ts | 25 +++++++++++++++++++ 7 files changed, 39 insertions(+), 51 deletions(-) create mode 100644 apps/backend/src/channels/whatsapp/whatsapp.utils.ts diff --git a/apps/backend/src/channels/whatsapp/image-search.service.ts b/apps/backend/src/channels/whatsapp/image-search.service.ts index 28f630f3..4f50fa3b 100644 --- a/apps/backend/src/channels/whatsapp/image-search.service.ts +++ b/apps/backend/src/channels/whatsapp/image-search.service.ts @@ -214,7 +214,7 @@ export class ImageSearchService { product.storeProfile?.storeHandle || product.storeProfile?.storeName || product.storeProfile?.businessName || - "Verified Shop"; + "twizrr store"; return `NGN ${price} | ${storeName}`; } diff --git a/apps/backend/src/channels/whatsapp/whatsapp-auth.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-auth.service.ts index 24cb39ce..9753c62b 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp-auth.service.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp-auth.service.ts @@ -18,17 +18,13 @@ import { OTP_EXPIRED, } from "./whatsapp.constants"; import { WhatsAppSessionService } from "./whatsapp-session.service"; +import { maskWhatsAppPhone } from "./whatsapp.utils"; interface SessionData { state: SessionState; data: Record; } -function maskPhone(phone: string): string { - if (phone.length <= 4) return "****"; - return `****${phone.slice(-4)}`; -} - function maskEmail(email: string): string { if (!email) return ""; const [local, domain] = email.split("@"); @@ -61,7 +57,7 @@ export class WhatsAppAuthService { return link?.isActive ? link.userId : null; } catch (error) { this.logger.error( - `Error resolving phone ${maskPhone(phone)}: ${ + `Error resolving phone ${maskWhatsAppPhone(phone)}: ${ error instanceof Error ? error.message : error }`, ); @@ -115,7 +111,7 @@ export class WhatsAppAuthService { } } catch (error) { this.logger.error( - `Error in linking flow for ${maskPhone(phone)}: ${ + `Error in linking flow for ${maskWhatsAppPhone(phone)}: ${ error instanceof Error ? error.message : error }`, ); @@ -219,7 +215,7 @@ export class WhatsAppAuthService { ]); } catch (error) { this.logger.error( - `Failed to create WhatsAppLink for ${maskPhone(phone)}: ${ + `Failed to create WhatsAppLink for ${maskWhatsAppPhone(phone)}: ${ error instanceof Error ? error.message : error }`, ); @@ -231,7 +227,7 @@ export class WhatsAppAuthService { await this.redisService.del(otpKey); this.logger.log( - `WhatsApp linked: phone=${maskPhone(phone)}, userId=${session.data.userId}`, + `WhatsApp linked: phone=${maskWhatsAppPhone(phone)}, userId=${session.data.userId}`, ); return `Account linked.\n\nHi ${session.data.userName || "there"}, you can now search products and use account actions from your twizrr dashboard.`; diff --git a/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.ts index 92cfc51f..f1b7a901 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.ts @@ -104,7 +104,6 @@ export class WhatsAppProductDiscoveryService { } private formatProductDescription(product: { - productCode?: string | null; retailPriceKobo?: bigint | number | null; pricePerUnitKobo?: bigint | number | null; storeProfile?: { diff --git a/apps/backend/src/channels/whatsapp/whatsapp-session.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-session.service.ts index c01ea035..3dfbd6be 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp-session.service.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp-session.service.ts @@ -1,6 +1,7 @@ -import { BadRequestException, Injectable } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; import { WhatsAppSession } from "@prisma/client"; import { PrismaService } from "../../prisma/prisma.service"; +import { normalizeWhatsAppPhone } from "./whatsapp.utils"; export const WHATSAPP_CONSENT_ACCEPT_ID = "twizrr_consent_accept"; export const WHATSAPP_CONSENT_DECLINE_ID = "twizrr_consent_decline"; @@ -16,21 +17,7 @@ export class WhatsAppSessionService { constructor(private readonly prisma: PrismaService) {} normalizePhone(phone: string): string { - const cleaned = phone.trim().replace(/[\s\-()]/g, ""); - - if (cleaned.startsWith("+")) { - return cleaned; - } - - if (cleaned === "0") { - throw new BadRequestException("Invalid WhatsApp phone number."); - } - - if (cleaned.startsWith("0")) { - return `+234${cleaned.slice(1)}`; - } - - return `+${cleaned}`; + return normalizeWhatsAppPhone(phone); } async recordInbound( diff --git a/apps/backend/src/channels/whatsapp/whatsapp.controller.ts b/apps/backend/src/channels/whatsapp/whatsapp.controller.ts index af249b72..bf65a4d2 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp.controller.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp.controller.ts @@ -18,20 +18,7 @@ import { SkipThrottle } from "@nestjs/throttler"; import { RedisService } from "../../redis/redis.service"; import { WHATSAPP_QUEUE } from "../../queue/queue.constants"; import { WA_MSG_DEDUP_PREFIX, MSG_DEDUP_TTL } from "./whatsapp.constants"; - -function normalizeWhatsAppPhone(phone: string): string { - const cleaned = phone.trim().replace(/[\s\-()]/g, ""); - if (cleaned.startsWith("+")) return cleaned; - if (cleaned.startsWith("0")) return `+234${cleaned.slice(1)}`; - return `+${cleaned}`; -} - -function maskPhone(phone: string): string { - if (!phone) return "unknown"; - return phone.replace(/^(\+\d{1,3})(\d+)(\d{2})$/, (_, p1, p2, p3) => { - return p1 + "*".repeat(p2.length) + p3; - }); -} +import { maskWhatsAppPhone, normalizeWhatsAppPhone } from "./whatsapp.utils"; /** * WhatsApp Webhook Controller @@ -178,7 +165,7 @@ export class WhatsAppController { } this.logger.log( - `Queued WhatsApp message from ${maskPhone(phone)}: type=${messageType}, hasText=${Boolean(messageText)}, interactive=${interactiveReply?.id || "none"}`, + `Queued WhatsApp message from ${maskWhatsAppPhone(phone)}: type=${messageType}, hasText=${Boolean(messageText)}, interactive=${interactiveReply?.id || "none"}`, ); } catch (error) { this.logger.error( diff --git a/apps/backend/src/channels/whatsapp/whatsapp.service.ts b/apps/backend/src/channels/whatsapp/whatsapp.service.ts index 044778de..9ff0b054 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp.service.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp.service.ts @@ -16,13 +16,7 @@ import { WhatsAppSessionService, } from "./whatsapp-session.service"; import { MetaWhatsAppClient } from "../../integrations/meta-whatsapp/meta-whatsapp.client"; - -function maskPhone(phone: string): string { - if (!phone) return "unknown"; - return phone.replace(/^(\+\d{1,3})(\d+)(\d{2})$/, (_, p1, p2, p3) => { - return p1 + "*".repeat(p2.length) + p3; - }); -} +import { maskWhatsAppPhone } from "./whatsapp.utils"; @Injectable() export class WhatsAppService { @@ -167,7 +161,7 @@ export class WhatsAppService { } } catch (error) { this.logger.error( - `WhatsApp processing failed for ${maskPhone(phone)}: ${ + `WhatsApp processing failed for ${maskWhatsAppPhone(phone)}: ${ error instanceof Error ? error.message : error }`, ); @@ -415,7 +409,7 @@ export class WhatsAppService { async sendOrderDispatchedNotification(phone: string, metadata: any) { if (!metadata?.orderReference || !metadata?.otp) { this.logger.warn( - `Missing orderReference or OTP in sendOrderDispatchedNotification for ${maskPhone(phone)}. Skipping.`, + `Missing orderReference or OTP in sendOrderDispatchedNotification for ${maskWhatsAppPhone(phone)}. Skipping.`, ); return; } diff --git a/apps/backend/src/channels/whatsapp/whatsapp.utils.ts b/apps/backend/src/channels/whatsapp/whatsapp.utils.ts new file mode 100644 index 00000000..29f60cee --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp.utils.ts @@ -0,0 +1,25 @@ +import { BadRequestException } from "@nestjs/common"; + +export function normalizeWhatsAppPhone(phone: string): string { + const cleaned = phone.trim().replace(/[\s\-()]/g, ""); + + if (cleaned.startsWith("+")) { + return cleaned; + } + + if (cleaned === "0") { + throw new BadRequestException("Invalid WhatsApp phone number."); + } + + if (cleaned.startsWith("0")) { + return `+234${cleaned.slice(1)}`; + } + + return `+${cleaned}`; +} + +export function maskWhatsAppPhone(phone: string): string { + const digits = phone.replace(/\D/g, ""); + if (digits.length <= 4) return "****"; + return `****${digits.slice(-4)}`; +} From d87f75fe361727156b9d87a143a45c7d04da6ebf Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Sat, 6 Jun 2026 18:06:01 +0100 Subject: [PATCH 014/389] feat(delivery): add delivery contact and address snapshots --- .../migration.sql | 14 + apps/backend/prisma/schema.prisma | 10 + apps/backend/src/modules/cart/cart.service.ts | 1 + .../src/modules/cart/dto/checkout-cart.dto.ts | 4 + .../backend/src/modules/order/delivery-fee.ts | 76 ++++ .../modules/order/dto/checkout-cart.dto.ts | 4 + .../order/dto/create-direct-order.dto.ts | 4 + .../order/dto/create-sourced-order.dto.ts | 4 + .../src/modules/order/order.service.spec.ts | 174 ++++++++- .../src/modules/order/order.service.ts | 361 ++++++++++++++++-- 10 files changed, 605 insertions(+), 47 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260606115000_add_delivery_contact_address_snapshots/migration.sql create mode 100644 apps/backend/src/modules/order/delivery-fee.ts diff --git a/apps/backend/prisma/migrations/20260606115000_add_delivery_contact_address_snapshots/migration.sql b/apps/backend/prisma/migrations/20260606115000_add_delivery_contact_address_snapshots/migration.sql new file mode 100644 index 00000000..b241a1bc --- /dev/null +++ b/apps/backend/prisma/migrations/20260606115000_add_delivery_contact_address_snapshots/migration.sql @@ -0,0 +1,14 @@ +-- Add Twizrr-managed delivery contact, address snapshot, and MVP fee metadata. +-- Existing delivery fields remain for backward compatibility. + +ALTER TABLE "orders" + ADD COLUMN "delivery_primary_phone" TEXT, + ADD COLUMN "delivery_secondary_phone" TEXT, + ADD COLUMN "delivery_address_snapshot" JSONB, + ADD COLUMN "pickup_address_snapshot" JSONB, + ADD COLUMN "pickup_store_id" TEXT, + ADD COLUMN "source_store_id" TEXT, + ADD COLUMN "delivery_fee_source" TEXT, + ADD COLUMN "delivery_fee_rule" TEXT, + ADD COLUMN "pickup_zone" TEXT, + ADD COLUMN "delivery_zone" TEXT; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 3a04ae05..baa54420 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -175,6 +175,16 @@ model Order { dispatchedAt DateTime? @map("dispatched_at") deliveryAddress String? @map("delivery_address") deliveryDetails Json? @map("delivery_details") + deliveryPrimaryPhone String? @map("delivery_primary_phone") + deliverySecondaryPhone String? @map("delivery_secondary_phone") + deliveryAddressSnapshot Json? @map("delivery_address_snapshot") + pickupAddressSnapshot Json? @map("pickup_address_snapshot") + pickupStoreId String? @map("pickup_store_id") + sourceStoreId String? @map("source_store_id") + deliveryFeeSource String? @map("delivery_fee_source") + deliveryFeeRule String? @map("delivery_fee_rule") + pickupZone String? @map("pickup_zone") + deliveryZone String? @map("delivery_zone") disputeReason String? @map("dispute_reason") disputeStatus OrderDisputeStatus @default(NONE) @map("dispute_status") payoutStatus PayoutStatus @default(PENDING) @map("payout_status") diff --git a/apps/backend/src/modules/cart/cart.service.ts b/apps/backend/src/modules/cart/cart.service.ts index de8706ee..fd1aea16 100644 --- a/apps/backend/src/modules/cart/cart.service.ts +++ b/apps/backend/src/modules/cart/cart.service.ts @@ -165,6 +165,7 @@ export class CartService { cartItemIds: cartItems.map((item) => item.id), deliveryAddress: dto.deliveryAddress, deliveryDetails: dto.deliveryDetails, + secondaryDeliveryPhone: dto.secondaryDeliveryPhone, idempotencyKey: dto.idempotencyKey, }; diff --git a/apps/backend/src/modules/cart/dto/checkout-cart.dto.ts b/apps/backend/src/modules/cart/dto/checkout-cart.dto.ts index 2185346c..f961eee8 100644 --- a/apps/backend/src/modules/cart/dto/checkout-cart.dto.ts +++ b/apps/backend/src/modules/cart/dto/checkout-cart.dto.ts @@ -17,6 +17,10 @@ export class CartCheckoutDto { @IsObject() deliveryDetails?: Record; + @IsOptional() + @IsString() + secondaryDeliveryPhone?: string; + // Deprecated compatibility field. Shopper input is ignored by checkout; // delivery is server-selected and handled by twizrr. @IsIn(["STORE_DELIVERY", "PLATFORM_LOGISTICS"]) diff --git a/apps/backend/src/modules/order/delivery-fee.ts b/apps/backend/src/modules/order/delivery-fee.ts new file mode 100644 index 00000000..fa817c2b --- /dev/null +++ b/apps/backend/src/modules/order/delivery-fee.ts @@ -0,0 +1,76 @@ +export const DELIVERY_FEE_SOURCE = "MVP_SELECTED_ZONE"; + +export interface DeliveryZoneAddress { + formattedAddress?: string | null; + street?: string | null; + city?: string | null; + state?: string | null; +} + +export interface DeliveryFeeQuote { + feeKobo: bigint; + source: typeof DELIVERY_FEE_SOURCE; + rule: string; + pickupZone: string; + deliveryZone: string; +} + +const ZONE_FEES_KOBO: Record> = { + BALOGUN: { + BALOGUN: 150_000n, + YABA: 250_000n, + LAGOS_SELECTED: 300_000n, + }, + YABA: { + BALOGUN: 250_000n, + YABA: 150_000n, + LAGOS_SELECTED: 300_000n, + }, + LAGOS_SELECTED: { + BALOGUN: 300_000n, + YABA: 300_000n, + LAGOS_SELECTED: 300_000n, + }, +}; + +export function resolveMvpDeliveryZone( + address: DeliveryZoneAddress, +): string | null { + const haystack = [ + address.formattedAddress, + address.street, + address.city, + address.state, + ] + .filter((value): value is string => typeof value === "string") + .join(" ") + .toLowerCase(); + + if (!haystack.trim()) return null; + if (/\b(balogun|idumota|marina|lagos island)\b/.test(haystack)) { + return "BALOGUN"; + } + if (/\b(yaba|akoka|sabo)\b/.test(haystack)) { + return "YABA"; + } + if (/\blagos\b/.test(haystack)) { + return "LAGOS_SELECTED"; + } + return null; +} + +export function calculateMvpDeliveryFee(input: { + pickupZone: string; + deliveryZone: string; +}): DeliveryFeeQuote | null { + const feeKobo = ZONE_FEES_KOBO[input.pickupZone]?.[input.deliveryZone]; + if (feeKobo === undefined) return null; + + return { + feeKobo, + source: DELIVERY_FEE_SOURCE, + rule: `${input.pickupZone}_TO_${input.deliveryZone}`, + pickupZone: input.pickupZone, + deliveryZone: input.deliveryZone, + }; +} diff --git a/apps/backend/src/modules/order/dto/checkout-cart.dto.ts b/apps/backend/src/modules/order/dto/checkout-cart.dto.ts index c0efec15..9fbc1cf5 100644 --- a/apps/backend/src/modules/order/dto/checkout-cart.dto.ts +++ b/apps/backend/src/modules/order/dto/checkout-cart.dto.ts @@ -23,6 +23,10 @@ export class CheckoutCartDto { @IsObject() deliveryDetails?: Record; + @IsOptional() + @IsString() + secondaryDeliveryPhone?: string; + // Deprecated compatibility field. Shopper input is ignored; delivery is // server-selected and handled by twizrr. @IsIn(["STORE_DELIVERY", "PLATFORM_LOGISTICS"]) diff --git a/apps/backend/src/modules/order/dto/create-direct-order.dto.ts b/apps/backend/src/modules/order/dto/create-direct-order.dto.ts index 82d030e2..9ac6e5de 100644 --- a/apps/backend/src/modules/order/dto/create-direct-order.dto.ts +++ b/apps/backend/src/modules/order/dto/create-direct-order.dto.ts @@ -28,6 +28,10 @@ export class CreateDirectOrderDto { @IsObject() deliveryDetails?: Record; + @IsOptional() + @IsString() + secondaryDeliveryPhone?: string; + // Deprecated compatibility field. Shopper input is ignored; delivery is // server-selected and handled by twizrr. @IsOptional() diff --git a/apps/backend/src/modules/order/dto/create-sourced-order.dto.ts b/apps/backend/src/modules/order/dto/create-sourced-order.dto.ts index a87cb114..06b8b76f 100644 --- a/apps/backend/src/modules/order/dto/create-sourced-order.dto.ts +++ b/apps/backend/src/modules/order/dto/create-sourced-order.dto.ts @@ -23,6 +23,10 @@ export class CreateSourcedOrderDto { @IsObject() deliveryDetails?: Record; + @IsOptional() + @IsString() + secondaryDeliveryPhone?: string; + // Deprecated compatibility field. Shopper input is ignored; delivery is // server-selected and handled by twizrr. @IsOptional() diff --git a/apps/backend/src/modules/order/order.service.spec.ts b/apps/backend/src/modules/order/order.service.spec.ts index 3ae95e23..672bed69 100644 --- a/apps/backend/src/modules/order/order.service.spec.ts +++ b/apps/backend/src/modules/order/order.service.spec.ts @@ -1,4 +1,4 @@ -import { ConflictException } from "@nestjs/common"; +import { BadRequestException, ConflictException } from "@nestjs/common"; import { DeliveryMethod } from "@prisma/client"; import { OrderStatus } from "@twizrr/shared"; @@ -157,6 +157,12 @@ describe("OrderService store-owner privacy", () => { status: OrderStatus.PAID, deliveryAddress: "12 Shopper Street", deliveryDetails: { phone: "+2348012345678" }, + deliveryPrimaryPhone: "+2348012345678", + deliverySecondaryPhone: "+2349012345678", + deliveryAddressSnapshot: { primaryPhone: "+2348012345678" }, + pickupAddressSnapshot: { formattedAddress: "Balogun, Lagos" }, + pickupStoreId: "store-1", + sourceStoreId: null, deliveryOtp: "123456", user: { email: "shopper@example.com", @@ -175,6 +181,12 @@ describe("OrderService store-owner privacy", () => { expect(row.user).toBeUndefined(); expect(row.buyerId).toBeUndefined(); expect(row.deliveryDetails).toBeUndefined(); + expect(row.deliveryPrimaryPhone).toBeUndefined(); + expect(row.deliverySecondaryPhone).toBeUndefined(); + expect(row.deliveryAddressSnapshot).toBeUndefined(); + expect(row.pickupAddressSnapshot).toBeUndefined(); + expect(row.pickupStoreId).toBeUndefined(); + expect(row.sourceStoreId).toBeUndefined(); expect(row.deliveryAddress).toBeNull(); expect(row.deliveryOtp).toBeNull(); }); @@ -190,6 +202,12 @@ describe("OrderService store-owner privacy", () => { status: OrderStatus.PAID, deliveryAddress: "12 Shopper Street", deliveryDetails: { phone: "+2348012345678" }, + deliveryPrimaryPhone: "+2348012345678", + deliverySecondaryPhone: "+2349012345678", + deliveryAddressSnapshot: { primaryPhone: "+2348012345678" }, + pickupAddressSnapshot: { formattedAddress: "Balogun, Lagos" }, + pickupStoreId: "store-1", + sourceStoreId: null, deliveryOtp: "123456", user: { email: "shopper@example.com", @@ -217,6 +235,12 @@ describe("OrderService store-owner privacy", () => { expect(order.user).toBeUndefined(); expect(order.buyerId).toBeUndefined(); expect(order.deliveryDetails).toBeUndefined(); + expect(order.deliveryPrimaryPhone).toBeUndefined(); + expect(order.deliverySecondaryPhone).toBeUndefined(); + expect(order.deliveryAddressSnapshot).toBeUndefined(); + expect(order.pickupAddressSnapshot).toBeUndefined(); + expect(order.pickupStoreId).toBeUndefined(); + expect(order.sourceStoreId).toBeUndefined(); expect(order.deliveryAddress).toBeNull(); expect(order.deliveryOtp).toBeNull(); const orderEvents = order.orderEvents as Array>; @@ -266,6 +290,19 @@ describe("OrderService server-selected delivery method", () => { create: jest.fn().mockResolvedValue({ id: "order-event-1" }), }, }; + const buyer = { + id: "buyer-1", + phone: "+2348012345678", + phoneVerified: true, + displayName: "Buyer One", + firstName: "Buyer", + lastName: "One", + }; + const pickupDetails = { + formattedAddress: "Balogun Market, Lagos", + city: "Lagos Island", + state: "Lagos", + }; const product = { id: "product-1", storeId: "store-1", @@ -277,9 +314,13 @@ describe("OrderService server-selected delivery method", () => { name: "Test product", productStockCaches: [{ stock: 10 }], storeProfile: { + id: "store-1", userId: "store-owner-1", verificationTier: "TIER_1", - businessAddress: "10 Market Road", + businessName: "Balogun Store", + storeName: "Balogun Store", + businessAddress: "Balogun Market, Lagos", + businessAddressDetails: pickupDetails, }, }; const sourcedProduct = { @@ -294,7 +335,12 @@ describe("OrderService server-selected delivery method", () => { verificationTier: "TIER_1", }, physicalStore: { + id: "physical-store-1", userId: "physical-store-owner", + businessName: "Physical Supplier", + storeName: "Physical Supplier", + businessAddress: "Balogun Market, Lagos", + businessAddressDetails: pickupDetails, }, physicalProduct: { ...product, @@ -312,6 +358,9 @@ describe("OrderService server-selected delivery method", () => { product, }; const prisma = { + user: { + findUnique: jest.fn().mockResolvedValue(buyer), + }, product: { findUnique: jest.fn().mockResolvedValue(product), }, @@ -401,7 +450,8 @@ describe("OrderService server-selected delivery method", () => { await service.createDirectOrder("buyer-1", { productId: "product-1", quantity: 1, - deliveryAddress: "20 Shopper Street", + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, deliveryMethod: DeliveryMethod.PLATFORM_LOGISTICS, }); @@ -409,7 +459,20 @@ describe("OrderService server-selected delivery method", () => { expect(tx.order.create).toHaveBeenCalledWith({ data: expect.objectContaining({ deliveryMethod: DeliveryMethod.STORE_DELIVERY, - deliveryFeeKobo: 0n, + deliveryFeeKobo: 250_000n, + deliveryPrimaryPhone: "+2348012345678", + deliveryAddressSnapshot: expect.objectContaining({ + formattedAddress: "20 Yaba Road, Lagos", + primaryPhone: "+2348012345678", + }), + pickupAddressSnapshot: expect.objectContaining({ + formattedAddress: "Balogun Market, Lagos", + }), + pickupStoreId: "store-1", + deliveryFeeSource: "MVP_SELECTED_ZONE", + deliveryFeeRule: "BALOGUN_TO_YABA", + pickupZone: "BALOGUN", + deliveryZone: "YABA", }), }); }); @@ -419,7 +482,8 @@ describe("OrderService server-selected delivery method", () => { await service.createSourcedOrder("buyer-1", "sourced-product-1", { quantity: 1, - deliveryAddress: "20 Shopper Street", + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, deliveryMethod: DeliveryMethod.PLATFORM_LOGISTICS, }); @@ -428,7 +492,12 @@ describe("OrderService server-selected delivery method", () => { data: expect.objectContaining({ orderType: "DROPSHIP_CUSTOMER", deliveryMethod: DeliveryMethod.STORE_DELIVERY, - deliveryFeeKobo: 0n, + deliveryFeeKobo: 250_000n, + pickupStoreId: "physical-store-1", + sourceStoreId: "physical-store-1", + pickupAddressSnapshot: expect.objectContaining({ + formattedAddress: "Balogun Market, Lagos", + }), }), }); expect(tx.order.create).toHaveBeenNthCalledWith(2, { @@ -436,6 +505,8 @@ describe("OrderService server-selected delivery method", () => { orderType: "DROPSHIP_FULFILLMENT", deliveryMethod: DeliveryMethod.STORE_DELIVERY, deliveryFeeKobo: 0n, + pickupStoreId: "physical-store-1", + sourceStoreId: "physical-store-1", }), }); }); @@ -445,7 +516,8 @@ describe("OrderService server-selected delivery method", () => { await service.checkoutCart("buyer-1", { cartItemIds: ["cart-item-1"], - deliveryAddress: "20 Shopper Street", + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, deliveryMethod: "PLATFORM_LOGISTICS", }); @@ -453,7 +525,8 @@ describe("OrderService server-selected delivery method", () => { expect(tx.order.create).toHaveBeenCalledWith({ data: expect.objectContaining({ deliveryMethod: DeliveryMethod.STORE_DELIVERY, - deliveryFeeKobo: 0n, + deliveryFeeKobo: 250_000n, + pickupStoreId: "store-1", }), }); }); @@ -464,7 +537,8 @@ describe("OrderService server-selected delivery method", () => { const result = await service.createDirectOrder("buyer-1", { productId: "product-1", quantity: 1, - deliveryAddress: "20 Shopper Street", + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, deliveryMethod: DeliveryMethod.STORE_DELIVERY, }); @@ -476,6 +550,86 @@ describe("OrderService server-selected delivery method", () => { expect(paymentService.initialize).toHaveBeenCalled(); expect(result.authorizationUrl).toBe("https://pay.test/checkout"); }); + + it("rejects checkout when the buyer primary phone is unverified", async () => { + const { service } = makeService({ + user: { + findUnique: jest.fn().mockResolvedValue({ + id: "buyer-1", + phone: "+2348012345678", + phoneVerified: false, + displayName: "Buyer One", + firstName: "Buyer", + lastName: "One", + }), + }, + }); + + await expect( + service.createDirectOrder("buyer-1", { + productId: "product-1", + quantity: 1, + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, + }), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + code: "DELIVERY_PRIMARY_PHONE_UNVERIFIED", + }), + }); + }); + + it("persists optional secondary delivery phone in the snapshot", async () => { + const { service, tx } = makeService(); + + await service.createDirectOrder("buyer-1", { + productId: "product-1", + quantity: 1, + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, + secondaryDeliveryPhone: "09012345678", + }); + + expect(tx.order.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + deliverySecondaryPhone: "+2349012345678", + deliveryAddressSnapshot: expect.objectContaining({ + secondaryPhone: "+2349012345678", + }), + }), + }); + }); + + it("includes server-calculated delivery fee before payment initialization", async () => { + const { service, ledgerService, paymentService } = makeService(); + + await service.createDirectOrder("buyer-1", { + productId: "product-1", + quantity: 1, + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, + }); + + expect(ledgerService.calculateCheckoutTotals).toHaveBeenCalledWith( + expect.objectContaining({ + deliveryFeeKobo: 250_000n, + }), + ); + expect(paymentService.initialize).toHaveBeenCalled(); + }); + + it("fails clearly when the dropoff zone is unsupported", async () => { + const { service } = makeService(); + + await expect( + service.createDirectOrder("buyer-1", { + productId: "product-1", + quantity: 1, + deliveryAddress: "1 Unknown Road, Ibadan", + deliveryDetails: { city: "Ibadan", state: "Oyo" }, + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); }); describe("CartService checkout delivery compatibility", () => { @@ -492,6 +646,7 @@ describe("CartService checkout delivery compatibility", () => { await service.checkout("buyer-1", { deliveryAddress: "20 Shopper Street", + secondaryDeliveryPhone: "09012345678", deliveryMethod: "PLATFORM_LOGISTICS", }); @@ -500,6 +655,7 @@ describe("CartService checkout delivery compatibility", () => { cartItemIds: ["cart-item-1"], deliveryAddress: "20 Shopper Street", deliveryDetails: undefined, + secondaryDeliveryPhone: "09012345678", idempotencyKey: undefined, }); expect("deliveryMethod" in forwardedDto).toBe(false); diff --git a/apps/backend/src/modules/order/order.service.ts b/apps/backend/src/modules/order/order.service.ts index 0e284136..07a2e76c 100644 --- a/apps/backend/src/modules/order/order.service.ts +++ b/apps/backend/src/modules/order/order.service.ts @@ -48,6 +48,11 @@ import { CreateDirectOrderDto } from "./dto/create-direct-order.dto"; import { CreateSourcedOrderDto } from "./dto/create-sourced-order.dto"; import { CreateTrackingDto } from "./dto/create-tracking.dto"; import { CheckoutCartDto } from "./dto/checkout-cart.dto"; +import { + calculateMvpDeliveryFee, + DeliveryFeeQuote, + resolveMvpDeliveryZone, +} from "./delivery-fee"; import { PayoutService } from "../payout/payout.service"; import { LedgerService } from "../ledger/ledger.service"; @@ -63,6 +68,33 @@ type OrderDomainEventType = | "ORDER_COMPLETED" | "DISPUTE_OPENED"; +interface DeliveryBuyerSnapshot { + id: string; + phone: string; + phoneVerified: boolean; + displayName: string | null; + firstName: string | null; + lastName: string | null; +} + +interface StorePickupSnapshotSource { + id?: string; + storeName?: string | null; + businessName?: string | null; + businessAddress?: string | null; + businessAddressDetails?: Prisma.JsonValue | null; +} + +interface DeliverySnapshotContext { + primaryPhone: string; + secondaryPhone: string | null; + deliveryAddressSnapshot: Prisma.JsonObject; + pickupAddressSnapshot: Prisma.JsonObject; + pickupStoreId: string | null; + sourceStoreId: string | null; + feeQuote: DeliveryFeeQuote; +} + @Injectable() export class OrderService { private readonly logger = new Logger(OrderService.name); @@ -94,6 +126,7 @@ export class OrderService { async createDirectOrder(buyerId: string, dto: CreateDirectOrderDto) { const { productId, quantity, deliveryAddress, deliveryDetails } = dto; const deliveryMethod = this.getServerSelectedDeliveryMethod(); + const buyer = await this.getVerifiedDeliveryBuyer(buyerId); const product = await this.prisma.product.findUnique({ where: { id: productId }, include: { @@ -107,7 +140,7 @@ export class OrderService { if (product.storeProfile?.userId === buyerId) { throw new ForbiddenException( - "Merchants cannot purchase their own products", + "Store owners cannot purchase their own products", ); } @@ -129,25 +162,19 @@ export class OrderService { const merchantTier = product.storeProfile?.verificationTier; const subtotalKobo = BigInt(resolvedPriceKobo) * BigInt(quantity); - let calculatedDeliveryFeeKobo = 0n; - if ( - deliveryMethod === "PLATFORM_LOGISTICS" && - product.storeProfile?.businessAddress - ) { - // Estimate weight roughly, e.g., 50kg per unit if bulky, or fallback to 1kg - const estimatedWeightKg = - product.unit.toLowerCase() === "bag" ? 50 * quantity : 1 * quantity; - const quote = await this.logisticsService.getQuote( - product.storeProfile.businessAddress, - deliveryAddress, - estimatedWeightKg, - ); - calculatedDeliveryFeeKobo = BigInt(quote.costKobo); - } + const deliveryContext = this.buildDeliverySnapshotContext({ + buyer, + deliveryAddress, + deliveryDetails, + secondaryDeliveryPhone: dto.secondaryDeliveryPhone, + pickupStore: product.storeProfile, + pickupStoreId: product.storeId, + sourceStoreId: null, + }); const totals = this.ledgerService.calculateCheckoutTotals({ subtotalKobo, - deliveryFeeKobo: calculatedDeliveryFeeKobo, + deliveryFeeKobo: deliveryContext.feeQuote.feeKobo, merchantTier, }); @@ -213,6 +240,18 @@ export class OrderService { unitPriceKobo: resolvedPriceKobo, deliveryAddress, deliveryDetails: deliveryDetails ? (deliveryDetails as any) : null, + deliveryPrimaryPhone: deliveryContext.primaryPhone, + deliverySecondaryPhone: deliveryContext.secondaryPhone, + deliveryAddressSnapshot: + deliveryContext.deliveryAddressSnapshot as Prisma.InputJsonValue, + pickupAddressSnapshot: + deliveryContext.pickupAddressSnapshot as Prisma.InputJsonValue, + pickupStoreId: deliveryContext.pickupStoreId, + sourceStoreId: deliveryContext.sourceStoreId, + deliveryFeeSource: deliveryContext.feeQuote.source, + deliveryFeeRule: deliveryContext.feeQuote.rule, + pickupZone: deliveryContext.feeQuote.pickupZone, + deliveryZone: deliveryContext.feeQuote.deliveryZone, totalAmountKobo: totals.totalAmountKobo, deliveryFeeKobo: totals.deliveryFeeKobo, currency: "NGN", @@ -345,6 +384,7 @@ export class OrderService { ) { const { quantity, deliveryAddress, deliveryDetails } = dto; const deliveryMethod = this.getServerSelectedDeliveryMethod(); + const buyer = await this.getVerifiedDeliveryBuyer(buyerId); const sourcedProduct = await this.prisma.sourcedProduct.findUnique({ where: { id: sourcedProductId }, @@ -418,12 +458,21 @@ export class OrderService { const wholesalePriceKobo = BigInt(currentFloorPriceKobo); const subtotalKobo = sellingPriceKobo * BigInt(quantity); const dropshipperCostKobo = wholesalePriceKobo * BigInt(quantity); + const deliveryContext = this.buildDeliverySnapshotContext({ + buyer, + deliveryAddress, + deliveryDetails, + secondaryDeliveryPhone: dto.secondaryDeliveryPhone, + pickupStore: sourcedProduct.physicalStore, + pickupStoreId: sourcedProduct.physicalStoreId, + sourceStoreId: sourcedProduct.physicalStoreId, + }); // Compute totals server-side using the digital store's tier. const merchantTier = sourcedProduct.digitalStore.verificationTier; const totals = this.ledgerService.calculateCheckoutTotals({ subtotalKobo, - deliveryFeeKobo: 0n, + deliveryFeeKobo: deliveryContext.feeQuote.feeKobo, merchantTier, }); const digitalMarginKobo = subtotalKobo - dropshipperCostKobo; @@ -522,6 +571,18 @@ export class OrderService { deliveryDetails: deliveryDetails ? (deliveryDetails as Prisma.InputJsonValue) : Prisma.DbNull, + deliveryPrimaryPhone: deliveryContext.primaryPhone, + deliverySecondaryPhone: deliveryContext.secondaryPhone, + deliveryAddressSnapshot: + deliveryContext.deliveryAddressSnapshot as Prisma.InputJsonValue, + pickupAddressSnapshot: + deliveryContext.pickupAddressSnapshot as Prisma.InputJsonValue, + pickupStoreId: deliveryContext.pickupStoreId, + sourceStoreId: deliveryContext.sourceStoreId, + deliveryFeeSource: deliveryContext.feeQuote.source, + deliveryFeeRule: deliveryContext.feeQuote.rule, + pickupZone: deliveryContext.feeQuote.pickupZone, + deliveryZone: deliveryContext.feeQuote.deliveryZone, totalAmountKobo: totals.totalAmountKobo, deliveryFeeKobo: totals.deliveryFeeKobo, currency: "NGN", @@ -561,6 +622,18 @@ export class OrderService { deliveryDetails: deliveryDetails ? (deliveryDetails as Prisma.InputJsonValue) : Prisma.DbNull, + deliveryPrimaryPhone: deliveryContext.primaryPhone, + deliverySecondaryPhone: deliveryContext.secondaryPhone, + deliveryAddressSnapshot: + deliveryContext.deliveryAddressSnapshot as Prisma.InputJsonValue, + pickupAddressSnapshot: + deliveryContext.pickupAddressSnapshot as Prisma.InputJsonValue, + pickupStoreId: deliveryContext.pickupStoreId, + sourceStoreId: deliveryContext.sourceStoreId, + deliveryFeeSource: deliveryContext.feeQuote.source, + deliveryFeeRule: deliveryContext.feeQuote.rule, + pickupZone: deliveryContext.feeQuote.pickupZone, + deliveryZone: deliveryContext.feeQuote.deliveryZone, totalAmountKobo: dropshipperCostKobo, deliveryFeeKobo: 0n, currency: "NGN", @@ -694,6 +767,7 @@ export class OrderService { async checkoutCart(buyerId: string, dto: CheckoutCartDto) { const { cartItemIds, deliveryAddress, deliveryDetails } = dto; const deliveryMethod = this.getServerSelectedDeliveryMethod(); + const buyer = await this.getVerifiedDeliveryBuyer(buyerId); if (!cartItemIds || cartItemIds.length === 0) { throw new BadRequestException("No cart items provided for checkout."); @@ -732,14 +806,12 @@ export class OrderService { const storeId = items[0].product.storeId; const storeProfile = items[0].product.storeProfile; if (!storeProfile) { - throw new BadRequestException( - "Merchant profile not found for these items.", - ); + throw new BadRequestException("Store profile not found for these items."); } if (storeProfile.userId === buyerId) { throw new ForbiddenException( - "Merchants cannot purchase their own products", + "Store owners cannot purchase their own products", ); } @@ -792,25 +864,19 @@ export class OrderService { // Determine payment method and platform fee const merchantTier = storeProfile.verificationTier; - let calculatedDeliveryFeeKobo = 0n; - if ( - deliveryMethod === "PLATFORM_LOGISTICS" && - storeProfile.businessAddress - ) { - // Very rough estimate of 10kg per cart item total for now - const estimatedWeightKg = 10 * items.length; - // No try-catch here: let logistics errors propagate to the user - const quote = await this.logisticsService.getQuote( - storeProfile.businessAddress, - deliveryAddress, - estimatedWeightKg, - ); - calculatedDeliveryFeeKobo = BigInt(quote.costKobo); - } + const deliveryContext = this.buildDeliverySnapshotContext({ + buyer, + deliveryAddress, + deliveryDetails, + secondaryDeliveryPhone: dto.secondaryDeliveryPhone, + pickupStore: storeProfile, + pickupStoreId: storeId, + sourceStoreId: null, + }); const totals = this.ledgerService.calculateCheckoutTotals({ subtotalKobo, - deliveryFeeKobo: calculatedDeliveryFeeKobo, + deliveryFeeKobo: deliveryContext.feeQuote.feeKobo, merchantTier, }); @@ -876,6 +942,18 @@ export class OrderService { storeId, deliveryAddress, deliveryDetails: deliveryDetails ? (deliveryDetails as any) : null, + deliveryPrimaryPhone: deliveryContext.primaryPhone, + deliverySecondaryPhone: deliveryContext.secondaryPhone, + deliveryAddressSnapshot: + deliveryContext.deliveryAddressSnapshot as Prisma.InputJsonValue, + pickupAddressSnapshot: + deliveryContext.pickupAddressSnapshot as Prisma.InputJsonValue, + pickupStoreId: deliveryContext.pickupStoreId, + sourceStoreId: deliveryContext.sourceStoreId, + deliveryFeeSource: deliveryContext.feeQuote.source, + deliveryFeeRule: deliveryContext.feeQuote.rule, + pickupZone: deliveryContext.feeQuote.pickupZone, + deliveryZone: deliveryContext.feeQuote.deliveryZone, totalAmountKobo: totals.totalAmountKobo, deliveryFeeKobo: totals.deliveryFeeKobo, currency: "NGN", @@ -1002,6 +1080,207 @@ export class OrderService { return SERVER_SELECTED_DELIVERY_METHOD; } + private async getVerifiedDeliveryBuyer( + buyerId: string, + ): Promise { + const buyer = await this.prisma.user.findUnique({ + where: { id: buyerId }, + select: { + id: true, + phone: true, + phoneVerified: true, + displayName: true, + firstName: true, + lastName: true, + }, + }); + + if (!buyer) { + throw new NotFoundException({ + message: "Buyer not found", + code: "BUYER_NOT_FOUND", + }); + } + + if (!buyer.phone || !buyer.phoneVerified) { + throw new BadRequestException({ + message: "Verify your account phone before checkout.", + code: "DELIVERY_PRIMARY_PHONE_UNVERIFIED", + }); + } + + return buyer; + } + + private buildDeliverySnapshotContext(input: { + buyer: DeliveryBuyerSnapshot; + deliveryAddress: string; + deliveryDetails?: Record; + secondaryDeliveryPhone?: string; + pickupStore: StorePickupSnapshotSource | null; + pickupStoreId: string | null; + sourceStoreId: string | null; + }): DeliverySnapshotContext { + const deliveryAddressSnapshot = this.toDeliveryAddressSnapshot( + input.deliveryAddress, + input.deliveryDetails, + input.buyer, + input.secondaryDeliveryPhone, + ); + const pickupAddressSnapshot = this.toPickupAddressSnapshot( + input.pickupStore, + input.pickupStoreId, + ); + const pickupZone = resolveMvpDeliveryZone(pickupAddressSnapshot); + const deliveryZone = resolveMvpDeliveryZone(deliveryAddressSnapshot); + + if (!pickupZone || !deliveryZone) { + throw new BadRequestException({ + message: + "Delivery is not available for this pickup or dropoff zone yet.", + code: "DELIVERY_ZONE_UNSUPPORTED", + }); + } + + const feeQuote = calculateMvpDeliveryFee({ pickupZone, deliveryZone }); + if (!feeQuote) { + throw new BadRequestException({ + message: "Delivery is not available for this route yet.", + code: "DELIVERY_ROUTE_UNSUPPORTED", + }); + } + + return { + primaryPhone: input.buyer.phone, + secondaryPhone: + this.readString(deliveryAddressSnapshot, "secondaryPhone") ?? null, + deliveryAddressSnapshot, + pickupAddressSnapshot, + pickupStoreId: input.pickupStoreId, + sourceStoreId: input.sourceStoreId, + feeQuote, + }; + } + + private toDeliveryAddressSnapshot( + deliveryAddress: string, + deliveryDetails: Record | undefined, + buyer: DeliveryBuyerSnapshot, + secondaryDeliveryPhone?: string, + ): Prisma.JsonObject { + const details = this.toRecord(deliveryDetails); + const secondaryPhone = this.normalizeOptionalDeliveryPhone( + secondaryDeliveryPhone || + this.readString(details, "secondaryPhone") || + this.readString(details, "altPhone"), + ); + const recipientName = + this.readString(details, "recipientName") || + this.readString(details, "name") || + buyer.displayName || + `${buyer.firstName ?? ""} ${buyer.lastName ?? ""}`.trim() || + "Shopper"; + + return this.compactJson({ + label: this.readString(details, "label"), + recipientName, + formattedAddress: + this.readString(details, "formattedAddress") || deliveryAddress, + street: this.readString(details, "street") || deliveryAddress, + line2: this.readString(details, "line2"), + city: this.readString(details, "city"), + state: this.readString(details, "state"), + postalCode: this.readString(details, "postalCode"), + landmark: + this.readString(details, "landmark") || + this.readString(details, "note"), + primaryPhone: buyer.phone, + secondaryPhone, + }); + } + + private toPickupAddressSnapshot( + store: StorePickupSnapshotSource | null, + storeId: string | null, + ): Prisma.JsonObject { + const details = this.toRecord(store?.businessAddressDetails); + const formattedAddress = + this.readString(details, "formattedAddress") || store?.businessAddress; + + if (!formattedAddress) { + throw new BadRequestException({ + message: "Store pickup address is required before checkout.", + code: "DELIVERY_PICKUP_ADDRESS_REQUIRED", + }); + } + + return this.compactJson({ + storeId, + storeName: store?.storeName || store?.businessName, + formattedAddress, + addressLine2: this.readString(details, "addressLine2"), + city: this.readString(details, "city"), + state: this.readString(details, "state"), + postalCode: this.readString(details, "postalCode"), + latitude: this.readNumber(details, "latitude"), + longitude: this.readNumber(details, "longitude"), + }); + } + + private normalizeOptionalDeliveryPhone(value?: string): string | null { + if (!value) return null; + const compact = value.replace(/[\s\-()]/g, ""); + let normalized = compact; + + if (/^0[789]\d{9}$/.test(compact)) { + normalized = `+234${compact.slice(1)}`; + } else if (/^[789]\d{9}$/.test(compact)) { + normalized = `+234${compact}`; + } else if (/^234[789]\d{9}$/.test(compact)) { + normalized = `+${compact}`; + } + + if (!/^\+234[789]\d{9}$/.test(normalized)) { + throw new BadRequestException({ + message: + "Secondary delivery phone must be a valid Nigerian mobile number.", + code: "INVALID_SECONDARY_DELIVERY_PHONE", + }); + } + + return normalized; + } + + private toRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + } + + private readString( + record: Record, + key: string, + ): string | undefined { + const value = record[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; + } + + private readNumber( + record: Record, + key: string, + ): number | undefined { + const value = record[key]; + return typeof value === "number" && Number.isFinite(value) + ? value + : undefined; + } + + private compactJson(record: Record): Prisma.JsonObject { + return Object.fromEntries( + Object.entries(record).filter(([, value]) => value !== undefined), + ) as Prisma.JsonObject; + } + private getTransitionDomainEventType( toStatus: OrderStatus, metadata?: Record, @@ -1380,6 +1659,12 @@ export class OrderService { delete target.user; delete target.buyerId; delete target.deliveryDetails; + delete target.deliveryPrimaryPhone; + delete target.deliverySecondaryPhone; + delete target.deliveryAddressSnapshot; + delete target.pickupAddressSnapshot; + delete target.pickupStoreId; + delete target.sourceStoreId; target.deliveryAddress = null; target.deliveryOtp = null; } From ec4ae2ce9815310e9b8b59443042c2f56afb6482 Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Sat, 6 Jun 2026 18:59:31 +0100 Subject: [PATCH 015/389] fix(delivery): harden delivery phone snapshots --- .../migration.sql | 12 ++++++++++++ .../src/modules/cart/dto/checkout-cart.dto.ts | 15 +++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/apps/backend/prisma/migrations/20260606115000_add_delivery_contact_address_snapshots/migration.sql b/apps/backend/prisma/migrations/20260606115000_add_delivery_contact_address_snapshots/migration.sql index b241a1bc..624ddb15 100644 --- a/apps/backend/prisma/migrations/20260606115000_add_delivery_contact_address_snapshots/migration.sql +++ b/apps/backend/prisma/migrations/20260606115000_add_delivery_contact_address_snapshots/migration.sql @@ -12,3 +12,15 @@ ALTER TABLE "orders" ADD COLUMN "delivery_fee_rule" TEXT, ADD COLUMN "pickup_zone" TEXT, ADD COLUMN "delivery_zone" TEXT; + +ALTER TABLE "orders" + ADD CONSTRAINT "orders_delivery_primary_phone_e164_chk" + CHECK ( + "delivery_primary_phone" IS NULL + OR "delivery_primary_phone" ~ '^\+[1-9][0-9]{1,14}$' + ), + ADD CONSTRAINT "orders_delivery_secondary_phone_e164_chk" + CHECK ( + "delivery_secondary_phone" IS NULL + OR "delivery_secondary_phone" ~ '^\+[1-9][0-9]{1,14}$' + ); diff --git a/apps/backend/src/modules/cart/dto/checkout-cart.dto.ts b/apps/backend/src/modules/cart/dto/checkout-cart.dto.ts index f961eee8..31150064 100644 --- a/apps/backend/src/modules/cart/dto/checkout-cart.dto.ts +++ b/apps/backend/src/modules/cart/dto/checkout-cart.dto.ts @@ -2,11 +2,24 @@ import { Transform } from "class-transformer"; import { IsIn, IsNotEmpty, + Matches, IsObject, IsOptional, IsString, } from "class-validator"; +function normalizeOptionalNigerianPhone(value: unknown): unknown { + if (value == null) return undefined; + if (typeof value !== "string") return value; + + const compact = value.trim().replace(/[\s\-()]/g, ""); + if (!compact) return undefined; + if (/^0[789]\d{9}$/.test(compact)) return `+234${compact.slice(1)}`; + if (/^[789]\d{9}$/.test(compact)) return `+234${compact}`; + if (/^234[789]\d{9}$/.test(compact)) return `+${compact}`; + return compact; +} + export class CartCheckoutDto { @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) @IsString() @@ -17,8 +30,10 @@ export class CartCheckoutDto { @IsObject() deliveryDetails?: Record; + @Transform(({ value }) => normalizeOptionalNigerianPhone(value)) @IsOptional() @IsString() + @Matches(/^\+234[789]\d{9}$/) secondaryDeliveryPhone?: string; // Deprecated compatibility field. Shopper input is ignored by checkout; From 1ba30f404307b9a7b0de2ac8bedf2ad946be15c9 Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Sat, 6 Jun 2026 21:56:31 +0100 Subject: [PATCH 016/389] feat(web): show Twizrr delivery fee at checkout --- .../buyer/cart/CartCheckoutSheet.tsx | 241 +++++++++++++++--- .../app/(shopper)/buyer/cart/CartClient.tsx | 11 +- .../checkout/[productId]/CheckoutClient.tsx | 218 +++++++++++++--- apps/web/src/lib/cart.ts | 7 + apps/web/src/lib/checkout.ts | 72 +++++- 5 files changed, 456 insertions(+), 93 deletions(-) diff --git a/apps/web/src/app/(shopper)/buyer/cart/CartCheckoutSheet.tsx b/apps/web/src/app/(shopper)/buyer/cart/CartCheckoutSheet.tsx index 59dc8740..f41b1a39 100644 --- a/apps/web/src/app/(shopper)/buyer/cart/CartCheckoutSheet.tsx +++ b/apps/web/src/app/(shopper)/buyer/cart/CartCheckoutSheet.tsx @@ -2,7 +2,14 @@ import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { CheckCircle2, MapPin, Plus, Shield } from "lucide-react"; +import { + AlertCircle, + CheckCircle2, + MapPin, + Phone, + Plus, + Shield, +} from "lucide-react"; import { Badge } from "@/components/ui/Badge"; import { Button } from "@/components/ui/Button"; import { Input } from "@/components/ui/Input"; @@ -12,10 +19,12 @@ import { useToast } from "@/components/ui/Toast"; import { cn, formatKobo } from "@/lib/utils"; import { checkoutCart } from "@/lib/cart"; import { + deriveDeliveryFeeKobo, fetchMe, fetchSavedAddresses, formatAddressLine, initializePayment, + normalizeOptionalDeliveryPhone, saveAddresses, type CheckoutUser, type DeliveryAddress, @@ -67,6 +76,14 @@ export function CartCheckoutSheet({ const [draft, setDraft] = useState(EMPTY_DRAFT); const [savingAddress, setSavingAddress] = useState(false); const [draftError, setDraftError] = useState(null); + const [secondaryDeliveryPhone, setSecondaryDeliveryPhone] = useState(""); + const [reviewedOrder, setReviewedOrder] = useState<{ + orderId: string; + authorizationUrl: string | null; + totalAmountKobo: string; + deliveryFeeKobo: string | null; + platformFeeKobo: string | null; + } | null>(null); const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); @@ -117,6 +134,35 @@ export function CartCheckoutSheet({ return addresses.find((a) => a.id === selectedAddressId) ?? null; }, [addresses, selectedAddressId]); + const normalizedSecondaryPhone = useMemo( + () => normalizeOptionalDeliveryPhone(secondaryDeliveryPhone), + [secondaryDeliveryPhone], + ); + const secondaryPhoneError = + secondaryDeliveryPhone.trim().length > 0 && !normalizedSecondaryPhone + ? "Enter a valid Nigerian mobile number." + : null; + const primaryPhoneVerified = me?.phone ? me.isPhoneVerified : false; + const phoneBlockMessage = + me && !primaryPhoneVerified + ? "Verify your phone number to place an order." + : null; + const reviewedDeliveryFeeKobo = reviewedOrder + ? deriveDeliveryFeeKobo({ + subtotalKobo, + totalAmountKobo: reviewedOrder.totalAmountKobo, + serviceFeeKobo: reviewedOrder.platformFeeKobo, + deliveryFeeKobo: reviewedOrder.deliveryFeeKobo, + }) + : null; + const reviewedServiceFeeKobo = reviewedOrder?.platformFeeKobo ?? null; + const displayTotalKobo = reviewedOrder?.totalAmountKobo ?? subtotalKobo; + + useEffect(() => { + setReviewedOrder(null); + setSubmitError(null); + }, [selectedAddressId, secondaryDeliveryPhone]); + function updateDraft( key: K, value: NewAddressDraft[K], @@ -166,14 +212,17 @@ export function CartCheckoutSheet({ } } - const handleConfirm = useCallback(async () => { + const handleReviewOrder = useCallback(async () => { if (!selectedAddress || !me) return; - if (!PAYSTACK_PUBLIC_KEY) { - setSubmitError( - "Payment is not configured. Please contact support and try again later.", - ); + if (!primaryPhoneVerified) { + setSubmitError("Verify your phone number to place an order."); return; } + if (secondaryPhoneError) { + setSubmitError(secondaryPhoneError); + return; + } + setSubmitError(null); setSubmitting(true); try { @@ -192,16 +241,52 @@ export function CartCheckoutSheet({ const order = await checkoutCart({ deliveryAddress: deliveryAddressLine, deliveryDetails, + ...(normalizedSecondaryPhone + ? { secondaryDeliveryPhone: normalizedSecondaryPhone } + : {}), }); - // Re-initialize the payment to retrieve access_code + reference for - // the inline popup (cart checkout only returns authorization_url). - // POST /payments/initialize is idempotent by orderId — it re-uses - // the same Paystack reference instead of starting a fresh charge. - const paystack = await initializePayment(order.orderId); + setReviewedOrder(order); + toast("Delivery fee calculated by twizrr.", { variant: "success" }); + } catch (err) { + const apiError = err as { message?: string; code?: string }; + const message = + apiError.code === "DELIVERY_PRIMARY_PHONE_UNVERIFIED" + ? "Verify your phone number to place an order." + : apiError.code === "DELIVERY_ZONE_UNSUPPORTED" || + apiError.code === "DELIVERY_ROUTE_UNSUPPORTED" + ? "Delivery isn't available for this address yet. Try another address." + : "We couldn't start checkout. Please review your cart and try again."; + setSubmitError(message); + } finally { + setSubmitting(false); + } + }, [ + me, + normalizedSecondaryPhone, + primaryPhoneVerified, + secondaryPhoneError, + selectedAddress, + toast, + ]); + + const handlePayNow = useCallback(async () => { + if (!reviewedOrder || !me) return; + if (!PAYSTACK_PUBLIC_KEY) { + setSubmitError( + "Payment is not configured. Please contact support and try again later.", + ); + return; + } + + setSubmitError(null); + setSubmitting(true); + try { + const paystack = await initializePayment(reviewedOrder.orderId); if (!paystack.accessCode && !paystack.reference) { - const redirectUrl = paystack.authorizationUrl || order.authorizationUrl; + const redirectUrl = + paystack.authorizationUrl || reviewedOrder.authorizationUrl; if (redirectUrl) { window.location.href = redirectUrl; return; @@ -214,16 +299,12 @@ export function CartCheckoutSheet({ accessCode: paystack.accessCode || undefined, reference: paystack.reference || undefined, email: me.email, - amountKobo: order.totalAmountKobo, + amountKobo: reviewedOrder.totalAmountKobo, onSuccess: () => { - // Backend deletes the relevant cart items inside - // processSuccessfulPayment after Paystack confirms — we never - // clear locally before that. Send the buyer to the W-09b - // confirmation screen with the new order id. - toast("Payment received. Confirming your order…", { + toast("Payment received. Confirming your order...", { variant: "success", }); - router.push(`/orders/confirmed/${order.orderId}`); + router.push(`/orders/confirmed/${reviewedOrder.orderId}`); }, onCancel: () => { setSubmitting(false); @@ -237,18 +318,19 @@ export function CartCheckoutSheet({ }, }); } catch { - // Surface a generic message — never forward backend strings. - // Multi-store carts trigger this with a 400; the generic copy - // is acceptable for now (the cart page itself doesn't currently - // group items by store). - setSubmitError( - "We couldn't start checkout. Please review your cart and try again.", - ); + setSubmitError("We couldn't start payment. Please try again."); setSubmitting(false); } - }, [me, router, selectedAddress, toast]); + }, [me, reviewedOrder, router, toast]); - const canConfirm = !!selectedAddress && !!me && !submitting && !savingAddress; + const canReviewOrder = + !!selectedAddress && + !!me && + primaryPhoneVerified && + !secondaryPhoneError && + !submitting && + !savingAddress; + const canPay = !!reviewedOrder && !submitting; return ( - {/* Total + Buyer Protection pill */} +
+
+
+
+

+ Primary phone +

+

+ {me?.phone ?? "No phone on account"} +

+

+ Checkout uses your verified account phone. Update it from profile + settings if this number is wrong. +

+
+ {phoneBlockMessage && ( +
+
+ )} +
+ setSecondaryDeliveryPhone(e.target.value)} + inputMode="tel" + maxLength={20} + error={secondaryPhoneError ?? undefined} + /> +

+ Add another number only if twizrr delivery should use it as a backup + contact. +

+
+
+
-
+
+ + + {reviewedServiceFeeKobo !== null && + reviewedServiceFeeKobo !== "0" && ( + + )} +
+
Total - {formatKobo(subtotalKobo)} + {formatKobo(displayTotalKobo)}
-

- Delivery handled by twizrr. Delivery fee calculation will be added in - a checkout update. -

{submitError && ( @@ -437,12 +585,14 @@ export function CartCheckoutSheet({ variant="primary" size="lg" fullWidth - onClick={handleConfirm} + onClick={reviewedOrder ? handlePayNow : handleReviewOrder} loading={submitting} - disabled={!canConfirm} + disabled={reviewedOrder ? !canPay : !canReviewOrder} leftIcon={

- Delivery is calculated and confirmed at checkout. + Delivery handled by twizrr. The fee is shown before payment.

@@ -420,7 +420,8 @@ function OrderSummaryCard({ aria-hidden="true" />

- Your payment is protected by twizrr Escrow until you confirm delivery. + Your payment is protected by twizrr Buyer Protection until you confirm + delivery.

@@ -481,8 +482,8 @@ function CartEmptyShell() { Nothing in your cart yet

- Discover something you love — your payment is protected by twizrr Escrow - on every order. + Discover something you love. Your payment is protected by twizrr Buyer + Protection on every order.

("PAYSTACK_INLINE"); + const [secondaryDeliveryPhone, setSecondaryDeliveryPhone] = useState(""); + const [reviewedOrder, setReviewedOrder] = useState( + null, + ); const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); @@ -208,6 +217,7 @@ export function CheckoutClient({ () => multiplyKobo(unitPriceKobo, QUANTITY), [unitPriceKobo], ); + const subtotalKoboString = subtotalKobo.toString(); const thumbnailUrl = useMemo(() => { if (!product) return null; @@ -223,14 +233,46 @@ export function CheckoutClient({ return addresses.find((a) => a.id === selectedAddressId) ?? null; }, [addresses, selectedAddressId]); - const canConfirm = + const normalizedSecondaryPhone = useMemo( + () => normalizeOptionalDeliveryPhone(secondaryDeliveryPhone), + [secondaryDeliveryPhone], + ); + const secondaryPhoneError = + secondaryDeliveryPhone.trim().length > 0 && !normalizedSecondaryPhone + ? "Enter a valid Nigerian mobile number." + : null; + const primaryPhoneVerified = me?.phone ? me.isPhoneVerified : false; + const phoneBlockMessage = + me && !primaryPhoneVerified + ? "Verify your phone number to place an order." + : null; + const reviewedDeliveryFeeKobo = reviewedOrder + ? deriveDeliveryFeeKobo({ + subtotalKobo: subtotalKoboString, + totalAmountKobo: reviewedOrder.totalAmountKobo, + serviceFeeKobo: reviewedOrder.platformFeeKobo, + deliveryFeeKobo: reviewedOrder.deliveryFeeKobo, + }) + : null; + const reviewedServiceFeeKobo = reviewedOrder?.platformFeeKobo ?? null; + const displayTotalKobo = reviewedOrder?.totalAmountKobo ?? subtotalKoboString; + + const canReviewOrder = !!product && !!me && !!selectedAddress && + primaryPhoneVerified && + !secondaryPhoneError && paymentMethod === "PAYSTACK_INLINE" && !submitting && !isStoreMode && unitPriceKobo !== null; + const canPay = !!reviewedOrder && !submitting && !isStoreMode; + + useEffect(() => { + setReviewedOrder(null); + setSubmitError(null); + }, [selectedAddressId, secondaryDeliveryPhone, product?.productId]); // ── Address form ───────────────────────────────────────────────────────────── function updateDraft( @@ -287,12 +329,14 @@ export function CheckoutClient({ } // ── Confirm + Paystack popup ───────────────────────────────────────────────── - const handleConfirm = useCallback(async () => { + const handleReviewOrder = useCallback(async () => { if (!product || !me || !selectedAddress || !unitPriceKobo) return; - if (!PAYSTACK_PUBLIC_KEY) { - setSubmitError( - "Payment is not configured. Please contact support and try again later.", - ); + if (!primaryPhoneVerified) { + setSubmitError("Verify your phone number to place an order."); + return; + } + if (secondaryPhoneError) { + setSubmitError(secondaryPhoneError); return; } setSubmitError(null); @@ -316,25 +360,67 @@ export function CheckoutClient({ quantity: QUANTITY, deliveryAddress: deliveryAddressLine, deliveryDetails, + ...(normalizedSecondaryPhone + ? { secondaryDeliveryPhone: normalizedSecondaryPhone } + : {}), }) : await createDirectOrder({ productId: product.productId, quantity: QUANTITY, deliveryAddress: deliveryAddressLine, deliveryDetails, + ...(normalizedSecondaryPhone + ? { secondaryDeliveryPhone: normalizedSecondaryPhone } + : {}), }); - // Re-initialize the payment to retrieve access_code + reference for the - // inline popup (the order creation only returns authorizationUrl). - // POST /payments/initialize is idempotent by orderId — it re-uses the - // same Paystack reference instead of starting a fresh charge. - const paystack = await initializePayment(order.orderId); + setReviewedOrder(order); + toast("Delivery fee calculated by twizrr.", { variant: "success" }); + } catch (err) { + const apiError = err as { message?: string; code?: string }; + const message = + apiError.code === "DELIVERY_PRIMARY_PHONE_UNVERIFIED" + ? "Verify your phone number to place an order." + : apiError.code === "DELIVERY_ZONE_UNSUPPORTED" || + apiError.code === "DELIVERY_ROUTE_UNSUPPORTED" + ? "Delivery isn't available for this address yet. Try another address." + : typeof apiError.message === "string" + ? apiError.message + : "Something went wrong. Please try again."; + setSubmitError(message); + } finally { + setSubmitting(false); + } + }, [ + isSourced, + me, + normalizedSecondaryPhone, + primaryPhoneVerified, + product, + secondaryPhoneError, + selectedAddress, + sourcedProductId, + toast, + unitPriceKobo, + ]); + + const handlePayNow = useCallback(async () => { + if (!reviewedOrder || !me || !product) return; + if (!PAYSTACK_PUBLIC_KEY) { + setSubmitError( + "Payment is not configured. Please contact support and try again later.", + ); + return; + } + + setSubmitError(null); + setSubmitting(true); + try { + const paystack = await initializePayment(reviewedOrder.orderId); if (!paystack.accessCode && !paystack.reference) { - // Last-resort fallback: redirect to a hosted Paystack page. - // Prefer the URL returned by /payments/initialize (more current); - // fall back to the order-creation response. - const redirectUrl = paystack.authorizationUrl || order.authorizationUrl; + const redirectUrl = + paystack.authorizationUrl || reviewedOrder.authorizationUrl; if (redirectUrl) { window.location.href = redirectUrl; return; @@ -347,14 +433,9 @@ export function CheckoutClient({ accessCode: paystack.accessCode || undefined, reference: paystack.reference || undefined, email: me.email, - amountKobo: order.totalAmountKobo, + amountKobo: reviewedOrder.totalAmountKobo, onSuccess: () => { - // After Paystack succeeds, the webhook transitions PAID server-side. - // Hand off to the W-09b confirmation route, passing the product - // code (and variant id when applicable) so the confirmation page - // can render the thumbnail/name/store/variant without an extra - // backend join. Falls back gracefully if either is missing. - toast("Payment received. Confirming your order…", { + toast("Payment received. Confirming your order...", { variant: "success", }); const confirmParams = new URLSearchParams(); @@ -363,7 +444,7 @@ export function CheckoutClient({ confirmParams.set("variantId", preselectedVariantId); const qs = confirmParams.toString(); router.push( - `/orders/confirmed/${order.orderId}${qs ? `?${qs}` : ""}`, + `/orders/confirmed/${reviewedOrder.orderId}${qs ? `?${qs}` : ""}`, ); }, onCancel: () => { @@ -386,16 +467,13 @@ export function CheckoutClient({ setSubmitting(false); } }, [ - isSourced, me, preselectedVariantId, product, productCode, + reviewedOrder, router, - selectedAddress, - sourcedProductId, toast, - unitPriceKobo, ]); // ── Render ─────────────────────────────────────────────────────────────────── @@ -599,6 +677,54 @@ export function CheckoutClient({
{/* Step 2 — Payment method */} + + @@ -737,12 +869,14 @@ export function CheckoutClient({ variant="primary" size="lg" fullWidth - onClick={handleConfirm} - disabled={!canConfirm} + onClick={reviewedOrder ? handlePayNow : handleReviewOrder} + disabled={reviewedOrder ? !canPay : !canReviewOrder} loading={submitting} leftIcon={
)}
diff --git a/apps/web/src/app/(shopper)/buyer/checkout/[productId]/CheckoutClient.tsx b/apps/web/src/app/(shopper)/buyer/checkout/[productId]/CheckoutClient.tsx index 9c60a20e..cb2c492c 100644 --- a/apps/web/src/app/(shopper)/buyer/checkout/[productId]/CheckoutClient.tsx +++ b/apps/web/src/app/(shopper)/buyer/checkout/[productId]/CheckoutClient.tsx @@ -94,6 +94,7 @@ export function CheckoutClient({ const [productLoading, setProductLoading] = useState(true); const [me, setMe] = useState(null); + const [meError, setMeError] = useState(false); const [addresses, setAddresses] = useState([]); const [addressesLoading, setAddressesLoading] = useState(true); @@ -115,6 +116,22 @@ export function CheckoutClient({ const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); + const loadMe = useCallback( + async (isCancelled: () => boolean = () => false) => { + setMeError(false); + try { + const user = await fetchMe(); + if (isCancelled()) return; + setMe(user); + } catch { + if (isCancelled()) return; + setMe(null); + setMeError(true); + } + }, + [], + ); + useEffect(() => { let cancelled = false; async function load() { @@ -152,19 +169,11 @@ export function CheckoutClient({ useEffect(() => { let cancelled = false; - async function load() { - try { - const user = await fetchMe(); - if (!cancelled) setMe(user); - } catch { - if (!cancelled) setMe(null); - } - } - load(); + void loadMe(() => cancelled); return () => { cancelled = true; }; - }, []); + }, [loadMe]); useEffect(() => { let cancelled = false; @@ -242,8 +251,9 @@ export function CheckoutClient({ ? "Enter a valid Nigerian mobile number." : null; const primaryPhoneVerified = me?.phone ? me.isPhoneVerified : false; - const phoneBlockMessage = - me && !primaryPhoneVerified + const phoneBlockMessage = meError + ? "We couldn't load your account phone. Retry before checkout." + : me && !primaryPhoneVerified ? "Verify your phone number to place an order." : null; const reviewedDeliveryFeeKobo = reviewedOrder @@ -260,6 +270,7 @@ export function CheckoutClient({ const canReviewOrder = !!product && !!me && + !meError && !!selectedAddress && primaryPhoneVerified && !secondaryPhoneError && @@ -272,7 +283,12 @@ export function CheckoutClient({ useEffect(() => { setReviewedOrder(null); setSubmitError(null); - }, [selectedAddressId, secondaryDeliveryPhone, product?.productId]); + }, [ + selectedAddressId, + secondaryDeliveryPhone, + product?.productId, + subtotalKoboString, + ]); // ── Address form ───────────────────────────────────────────────────────────── function updateDraft( @@ -700,12 +716,25 @@ export function CheckoutClient({ className="mt-0.5 h-4 w-4 shrink-0 text-[var(--color-error)]" aria-hidden="true" /> -

- {phoneBlockMessage} -

+
+

+ {phoneBlockMessage} +

+ {meError && ( + + )} +
)}
diff --git a/apps/web/src/lib/cart.ts b/apps/web/src/lib/cart.ts index 6804961c..d73e7da7 100644 --- a/apps/web/src/lib/cart.ts +++ b/apps/web/src/lib/cart.ts @@ -118,6 +118,20 @@ function toKoboString(value: unknown): string { return "0"; } +function toCheckoutKoboString(value: unknown): string | null { + if (typeof value === "string" && /^\d+$/.test(value)) return value; + if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) { + return String(value); + } + if (typeof value === "bigint" && value >= 0n) return value.toString(); + return null; +} + +function toOptionalCheckoutKoboString(value: unknown): string | null { + if (value === null || value === undefined) return null; + return toCheckoutKoboString(value); +} + function toFiniteInt(value: unknown, fallback: number): number { if (typeof value === "number" && Number.isFinite(value)) { return Math.trunc(value); @@ -294,19 +308,23 @@ function normalizeCheckoutResult( raw: RawCheckoutResponse, ): CartCheckoutResult | null { if (typeof raw?.orderId !== "string") return null; + const totalAmountKobo = toCheckoutKoboString(raw.totalAmountKobo); + const deliveryFeeKobo = toOptionalCheckoutKoboString(raw.deliveryFeeKobo); + const platformFeeKobo = toOptionalCheckoutKoboString(raw.platformFeeKobo); + if ( + totalAmountKobo === null || + (raw.deliveryFeeKobo != null && deliveryFeeKobo === null) || + (raw.platformFeeKobo != null && platformFeeKobo === null) + ) { + return null; + } return { orderId: raw.orderId, authorizationUrl: typeof raw.authorizationUrl === "string" ? raw.authorizationUrl : null, - totalAmountKobo: toKoboString(raw.totalAmountKobo), - deliveryFeeKobo: - raw.deliveryFeeKobo === null || raw.deliveryFeeKobo === undefined - ? null - : toKoboString(raw.deliveryFeeKobo), - platformFeeKobo: - raw.platformFeeKobo === null || raw.platformFeeKobo === undefined - ? null - : toKoboString(raw.platformFeeKobo), + totalAmountKobo, + deliveryFeeKobo, + platformFeeKobo, }; } diff --git a/apps/web/src/lib/checkout.ts b/apps/web/src/lib/checkout.ts index 5f07361d..ad729557 100644 --- a/apps/web/src/lib/checkout.ts +++ b/apps/web/src/lib/checkout.ts @@ -131,22 +131,17 @@ interface RawMeResponse { } export async function fetchMe(): Promise { - try { - const res = await api.get("/users/me"); - if (typeof res?.id !== "string" || typeof res?.email !== "string") { - return null; - } - return { - id: res.id, - email: res.email, - displayName: typeof res.displayName === "string" ? res.displayName : null, - phone: typeof res.phone === "string" ? res.phone : null, - isPhoneVerified: - res.isPhoneVerified === true || res.phoneVerified === true, - }; - } catch { + const res = await api.get("/users/me"); + if (typeof res?.id !== "string" || typeof res?.email !== "string") { return null; } + return { + id: res.id, + email: res.email, + displayName: typeof res.displayName === "string" ? res.displayName : null, + phone: typeof res.phone === "string" ? res.phone : null, + isPhoneVerified: res.isPhoneVerified === true || res.phoneVerified === true, + }; } // ─── Order creation ──────────────────────────────────────────────────────────── @@ -167,11 +162,13 @@ interface RawCreateOrderResponse { platformFeeKobo?: unknown; } -function toKoboString(value: unknown, fallback = "0"): string { - if (typeof value === "string") return value; - if (typeof value === "number" && Number.isFinite(value)) return String(value); - if (typeof value === "bigint") return value.toString(); - return fallback; +function toKoboString(value: unknown): string | null { + if (typeof value === "string" && /^\d+$/.test(value)) return value; + if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) { + return String(value); + } + if (typeof value === "bigint" && value >= 0n) return value.toString(); + return null; } function toOptionalKoboString(value: unknown): string | null { @@ -183,13 +180,23 @@ function normalizeCreateOrder( res: RawCreateOrderResponse, ): CreateOrderResult | null { if (typeof res?.orderId !== "string") return null; + const totalAmountKobo = toKoboString(res.totalAmountKobo); + const deliveryFeeKobo = toOptionalKoboString(res.deliveryFeeKobo); + const platformFeeKobo = toOptionalKoboString(res.platformFeeKobo); + if ( + totalAmountKobo === null || + (res.deliveryFeeKobo != null && deliveryFeeKobo === null) || + (res.platformFeeKobo != null && platformFeeKobo === null) + ) { + return null; + } return { orderId: res.orderId, authorizationUrl: typeof res.authorizationUrl === "string" ? res.authorizationUrl : null, - totalAmountKobo: toKoboString(res.totalAmountKobo), - deliveryFeeKobo: toOptionalKoboString(res.deliveryFeeKobo), - platformFeeKobo: toOptionalKoboString(res.platformFeeKobo), + totalAmountKobo, + deliveryFeeKobo, + platformFeeKobo, }; } From 4052e15e924556fe69ba5b6295d073fc9f35a8d5 Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Sun, 7 Jun 2026 03:26:53 +0100 Subject: [PATCH 018/389] feat(order): add ready-for-pickup fulfillment step --- .../migration.sql | 1 + apps/backend/prisma/schema.prisma | 1 + .../src/modules/order/order.controller.ts | 13 ++ .../src/modules/order/order.service.spec.ts | 189 ++++++++++++++++++ .../src/modules/order/order.service.ts | 99 ++++++++- .../buyer/orders/OrdersListClient.tsx | 5 +- .../dashboard/_components/RecentOrders.tsx | 2 + .../store/orders/StoreOrdersClient.tsx | 9 +- .../orders/[id]/StoreOrderDetailClient.tsx | 18 +- .../web/src/components/order/EscrowBanner.tsx | 6 +- apps/web/src/components/order/OrderCard.tsx | 15 +- .../src/components/order/OrderConfirmed.tsx | 10 +- .../src/components/order/OrderTimeline.tsx | 14 +- .../store-orders/DispatchOrderSheet.tsx | 15 +- .../store-orders/StoreOrderEscrowBanner.tsx | 11 +- .../store-orders/StoreOrderStatusBadge.tsx | 9 +- apps/web/src/lib/orders.ts | 2 + apps/web/src/lib/store-dashboard.ts | 6 +- apps/web/src/lib/store-orders.ts | 20 +- .../shared/src/constants/order-transitions.ts | 14 +- .../shared/src/enums/order-status.enum.ts | 1 + 21 files changed, 407 insertions(+), 53 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260606170000_add_ready_for_pickup_order_status/migration.sql diff --git a/apps/backend/prisma/migrations/20260606170000_add_ready_for_pickup_order_status/migration.sql b/apps/backend/prisma/migrations/20260606170000_add_ready_for_pickup_order_status/migration.sql new file mode 100644 index 00000000..d57bc74a --- /dev/null +++ b/apps/backend/prisma/migrations/20260606170000_add_ready_for_pickup_order_status/migration.sql @@ -0,0 +1 @@ +ALTER TYPE "OrderStatus" ADD VALUE IF NOT EXISTS 'READY_FOR_PICKUP' BEFORE 'DISPATCHED'; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index baa54420..c9a242e0 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -1384,6 +1384,7 @@ enum OrderType { enum OrderStatus { PENDING_PAYMENT PAID + READY_FOR_PICKUP DISPATCHED DELIVERED COMPLETED diff --git a/apps/backend/src/modules/order/order.controller.ts b/apps/backend/src/modules/order/order.controller.ts index f30d3d63..fe952f67 100644 --- a/apps/backend/src/modules/order/order.controller.ts +++ b/apps/backend/src/modules/order/order.controller.ts @@ -138,6 +138,19 @@ export class OrderController { return this.orderService.getTracking(id, user.sub, user.storeId); } + @Post(":id/ready-for-pickup") + @Roles(UserRole.USER) + @Throttle({ default: { limit: 3, ttl: 60000 } }) + readyForPickup( + @CurrentMerchant() storeId: string | undefined, + @Param("id") id: string, + ) { + if (!storeId) { + throw new ForbiddenException("Store identity required"); + } + return this.orderService.readyForPickup(storeId, id); + } + @Post(":id/dispatch") @Roles(UserRole.USER) @Throttle({ default: { limit: 3, ttl: 60000 } }) diff --git a/apps/backend/src/modules/order/order.service.spec.ts b/apps/backend/src/modules/order/order.service.spec.ts index 672bed69..bca1401e 100644 --- a/apps/backend/src/modules/order/order.service.spec.ts +++ b/apps/backend/src/modules/order/order.service.spec.ts @@ -250,6 +250,195 @@ describe("OrderService store-owner privacy", () => { }); }); +describe("OrderService ready-for-pickup fulfillment", () => { + const makeService = ({ + initialStatus = OrderStatus.PAID, + freshStatus = OrderStatus.READY_FOR_PICKUP, + updateCount = 1, + }: { + initialStatus?: OrderStatus; + freshStatus?: OrderStatus; + updateCount?: number; + } = {}) => { + const initialOrder = { + id: "order-1", + orderCode: "TWZ-123456", + buyerId: "buyer-1", + storeId: "store-1", + status: initialStatus, + totalAmountKobo: 250000n, + platformFeeKobo: 5000n, + deliveryOtp: null, + deliveryAddress: "12 Shopper Street", + deliveryDetails: { phone: "+2348012345678" }, + deliveryPrimaryPhone: "+2348012345678", + deliverySecondaryPhone: "+2349012345678", + deliveryAddressSnapshot: { primaryPhone: "+2348012345678" }, + pickupAddressSnapshot: { formattedAddress: "Balogun, Lagos" }, + pickupStoreId: "store-1", + sourceStoreId: null, + user: { email: "shopper@example.com", phone: "+2348012345678" }, + }; + const freshOrder = { ...initialOrder, status: freshStatus }; + const tx = { + order: { + updateMany: jest.fn().mockResolvedValue({ count: updateCount }), + findUniqueOrThrow: jest.fn().mockResolvedValue(freshOrder), + }, + orderTracking: { + create: jest.fn().mockResolvedValue({ id: "tracking-1" }), + }, + orderEvent: { + create: jest.fn().mockResolvedValue({ id: "event-1" }), + }, + }; + const prisma = { + order: { + findUnique: jest.fn().mockResolvedValue(initialOrder), + }, + storeProfile: { + findUnique: jest.fn().mockResolvedValue({ userId: "owner-1" }), + }, + $transaction: jest.fn((callback: (transaction: typeof tx) => unknown) => + callback(tx), + ), + }; + const notifications = { + triggerOrderDispatched: jest.fn(), + }; + const autoConfirmQueue = { add: jest.fn() }; + const autoConfirmWarningQueue = { add: jest.fn() }; + const domainEvents = { append: jest.fn() }; + const service = new OrderService( + prisma as never, + notifications as never, + {} as never, + {} as never, + {} as never, + autoConfirmQueue as never, + autoConfirmWarningQueue as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + domainEvents as never, + ); + + return { + service, + prisma, + tx, + notifications, + autoConfirmQueue, + autoConfirmWarningQueue, + domainEvents, + }; + }; + + it("moves paid orders to ready for pickup without generating a delivery code", async () => { + const { + service, + tx, + notifications, + autoConfirmQueue, + autoConfirmWarningQueue, + domainEvents, + } = makeService(); + + const result = (await service.readyForPickup( + "store-1", + "order-1", + )) as Record; + + expect(tx.order.updateMany).toHaveBeenCalledWith({ + where: { id: "order-1", status: OrderStatus.PAID }, + data: { status: OrderStatus.READY_FOR_PICKUP }, + }); + expect(tx.orderTracking.create).toHaveBeenCalledWith({ + data: { orderId: "order-1", status: OrderStatus.READY_FOR_PICKUP }, + }); + expect(tx.orderEvent.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + fromStatus: OrderStatus.PAID, + toStatus: OrderStatus.READY_FOR_PICKUP, + metadata: { action: "ready_for_pickup" }, + }), + }); + expect(domainEvents.append).toHaveBeenCalledWith( + tx, + expect.objectContaining({ + eventType: "ORDER_READY_FOR_PICKUP", + }), + ); + expect(notifications.triggerOrderDispatched).not.toHaveBeenCalled(); + expect(autoConfirmQueue.add).not.toHaveBeenCalled(); + expect(autoConfirmWarningQueue.add).not.toHaveBeenCalled(); + expect(result.deliveryOtp).toBeNull(); + expect(result.deliveryAddress).toBeNull(); + expect(result.deliveryDetails).toBeUndefined(); + expect(result.buyerId).toBeUndefined(); + expect(result.user).toBeUndefined(); + }); + + it("moves preparing orders to ready for pickup", async () => { + const { service, tx } = makeService({ + initialStatus: OrderStatus.PREPARING, + }); + + await service.readyForPickup("store-1", "order-1"); + + expect(tx.order.updateMany).toHaveBeenCalledWith({ + where: { id: "order-1", status: OrderStatus.PREPARING }, + data: { status: OrderStatus.READY_FOR_PICKUP }, + }); + }); + + it("rejects invalid source statuses for ready for pickup", async () => { + const { service, prisma } = makeService({ + initialStatus: OrderStatus.DISPATCHED, + }); + + await expect( + service.readyForPickup("store-1", "order-1"), + ).rejects.toBeInstanceOf(BadRequestException); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it("dispatches from ready for pickup and keeps delivery-code timing in dispatch", async () => { + const { + service, + tx, + notifications, + autoConfirmQueue, + autoConfirmWarningQueue, + } = makeService({ + initialStatus: OrderStatus.READY_FOR_PICKUP, + freshStatus: OrderStatus.DISPATCHED, + }); + + await service.dispatch("store-1", "order-1"); + + expect(tx.order.updateMany).toHaveBeenCalledWith({ + where: { id: "order-1", status: OrderStatus.READY_FOR_PICKUP }, + data: expect.objectContaining({ + status: OrderStatus.DISPATCHED, + deliveryOtp: expect.any(String), + dispatchedAt: expect.any(Date), + }), + }); + expect(notifications.triggerOrderDispatched).toHaveBeenCalledWith( + "buyer-1", + expect.objectContaining({ + orderId: "order-1", + otp: expect.stringMatching(/^\d{6}$/), + }), + ); + expect(autoConfirmQueue.add).toHaveBeenCalled(); + expect(autoConfirmWarningQueue.add).toHaveBeenCalled(); + }); +}); + describe("OrderService server-selected delivery method", () => { const makeService = (overrides: Record = {}) => { const orderCreate = jest.fn( diff --git a/apps/backend/src/modules/order/order.service.ts b/apps/backend/src/modules/order/order.service.ts index 07a2e76c..1071ec42 100644 --- a/apps/backend/src/modules/order/order.service.ts +++ b/apps/backend/src/modules/order/order.service.ts @@ -61,6 +61,7 @@ import { DomainEventService } from "../../domains/platform/domain-event/domain-e type OrderDomainEventType = | "ORDER_CREATED" | "PAYMENT_CONFIRMED" + | "ORDER_READY_FOR_PICKUP" | "ORDER_DISPATCHED" | "DELIVERY_CONFIRMED" | "AUTO_CONFIRMED" @@ -1289,6 +1290,9 @@ export class OrderService { typeof metadata?.action === "string" ? metadata.action : undefined; if (toStatus === OrderStatus.PAID) return "PAYMENT_CONFIRMED"; + if (toStatus === OrderStatus.READY_FOR_PICKUP) { + return "ORDER_READY_FOR_PICKUP"; + } if (toStatus === OrderStatus.DISPATCHED) return "ORDER_DISPATCHED"; if (toStatus === OrderStatus.DELIVERED) { return action === "system_auto_confirm" @@ -1741,7 +1745,7 @@ export class OrderService { // DISPATCH (merchant only, generates OTP) // ────────────────────────────────────────────── - async dispatch(storeId: string, orderId: string) { + async readyForPickup(storeId: string, orderId: string) { const order = await this.prisma.order.findUnique({ where: { id: orderId }, }); @@ -1754,7 +1758,94 @@ export class OrderService { order.status !== OrderStatus.PREPARING ) { throw new BadRequestException( - "Order must be in PAID or PREPARING status to dispatch", + "Order must be in PAID or PREPARING status to mark ready for pickup", + ); + } + + if ( + !validateTransition( + order.status as OrderStatus, + OrderStatus.READY_FOR_PICKUP, + ) + ) { + throw new BadRequestException( + `Cannot transition from ${order.status} to ${OrderStatus.READY_FOR_PICKUP}`, + ); + } + + const expectedFromStatus = order.status as OrderStatus; + const triggeredBy = await this.getUserIdFromMerchant(storeId); + + const updatedOrder = await this.prisma.$transaction(async (tx) => { + const { count } = await tx.order.updateMany({ + where: { id: orderId, status: expectedFromStatus }, + data: { status: OrderStatus.READY_FOR_PICKUP }, + }); + + if (count !== 1) { + throw new ConflictException({ + message: `Order ${orderId} is no longer in ${expectedFromStatus} state`, + code: "ORDER_STATE_CONFLICT", + }); + } + + const fresh = await tx.order.findUniqueOrThrow({ + where: { id: orderId }, + }); + + await tx.orderTracking.create({ + data: { orderId, status: OrderStatus.READY_FOR_PICKUP }, + }); + + await tx.orderEvent.create({ + data: { + orderId, + fromStatus: expectedFromStatus, + toStatus: OrderStatus.READY_FOR_PICKUP, + triggeredBy, + metadata: { action: "ready_for_pickup" }, + }, + }); + + await this.appendOrderDomainEvent( + tx, + fresh, + "ORDER_READY_FOR_PICKUP", + "USER", + triggeredBy, + { + fromStatus: expectedFromStatus, + toStatus: OrderStatus.READY_FOR_PICKUP, + action: "ready_for_pickup", + }, + ); + + return fresh; + }); + + this.logger.log( + `Order ${orderId}: ${expectedFromStatus} -> ${OrderStatus.READY_FOR_PICKUP}`, + ); + + this.sanitizeStoreOwnerOrder(updatedOrder); + return updatedOrder; + } + + async dispatch(storeId: string, orderId: string) { + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + }); + if (!order) throw new NotFoundException("Order not found"); + if (order.storeId !== storeId) + throw new ForbiddenException("Access denied"); + + if ( + order.status !== OrderStatus.PAID && + order.status !== OrderStatus.PREPARING && + order.status !== OrderStatus.READY_FOR_PICKUP + ) { + throw new BadRequestException( + "Order must be in PAID, PREPARING, or READY_FOR_PICKUP status to dispatch", ); } @@ -1850,7 +1941,8 @@ export class OrderService { otp: plainOtp, }); - this.logger.log(`Order ${orderId} dispatched, OTP generated`); + this.logger.log(`Order ${orderId} dispatched, delivery code generated`); + this.sanitizeStoreOwnerOrder(updatedOrder); return updatedOrder; } @@ -2572,6 +2664,7 @@ export class OrderService { ); switch (o.status) { case OrderStatus.PAID: + case OrderStatus.READY_FOR_PICKUP: case OrderStatus.DISPATCHED: summary.escrow += amount; break; diff --git a/apps/web/src/app/(shopper)/buyer/orders/OrdersListClient.tsx b/apps/web/src/app/(shopper)/buyer/orders/OrdersListClient.tsx index 8d76e120..4abcd0b8 100644 --- a/apps/web/src/app/(shopper)/buyer/orders/OrdersListClient.tsx +++ b/apps/web/src/app/(shopper)/buyer/orders/OrdersListClient.tsx @@ -32,7 +32,10 @@ const TABS: FilterTab[] = [ key: "PROCESSING", label: "Processing", matches: (s) => - s === "PENDING_PAYMENT" || s === "PAID" || s === "PREPARING", + s === "PENDING_PAYMENT" || + s === "PAID" || + s === "PREPARING" || + s === "READY_FOR_PICKUP", emptyHint: "Nothing being processed right now.", }, { diff --git a/apps/web/src/app/(store)/store/dashboard/_components/RecentOrders.tsx b/apps/web/src/app/(store)/store/dashboard/_components/RecentOrders.tsx index 0ad634d7..650e6ed6 100644 --- a/apps/web/src/app/(store)/store/dashboard/_components/RecentOrders.tsx +++ b/apps/web/src/app/(store)/store/dashboard/_components/RecentOrders.tsx @@ -24,6 +24,8 @@ function statusBadgeConfig(status: StoreOrderStatus): StatusBadgeConfig { return { label: "Paid", variant: "saffron" }; case "PREPARING": return { label: "Preparing", variant: "saffron" }; + case "READY_FOR_PICKUP": + return { label: "Ready for pickup", variant: "saffron" }; case "DISPATCHED": return { label: "Dispatched", variant: "saffron" }; case "IN_TRANSIT": diff --git a/apps/web/src/app/(store)/store/orders/StoreOrdersClient.tsx b/apps/web/src/app/(store)/store/orders/StoreOrdersClient.tsx index 85dc38b4..10a055a0 100644 --- a/apps/web/src/app/(store)/store/orders/StoreOrdersClient.tsx +++ b/apps/web/src/app/(store)/store/orders/StoreOrdersClient.tsx @@ -32,7 +32,7 @@ const TABS: FilterTab[] = [ { key: "NEEDS_ACTION", label: "Needs action", - matches: (s) => s === "PAID", + matches: (s) => s === "PAID" || s === "PREPARING", emptyHint: "Nothing needs action right now.", }, { @@ -44,6 +44,7 @@ const TABS: FilterTab[] = [ matches: (s) => s === "PAID" || s === "PREPARING" || + s === "READY_FOR_PICKUP" || s === "DISPATCHED" || s === "IN_TRANSIT" || s === "DELIVERED", @@ -91,9 +92,11 @@ export function StoreOrdersClient() { }; }, [refreshNonce]); - // Needs-action badge — count of PAID orders awaiting dispatch. + // Needs-action badge: paid/preparing orders that can be marked ready. const needsActionCount = useMemo( - () => orders.filter((o) => o.status === "PAID").length, + () => + orders.filter((o) => o.status === "PAID" || o.status === "PREPARING") + .length, [orders], ); diff --git a/apps/web/src/app/(store)/store/orders/[id]/StoreOrderDetailClient.tsx b/apps/web/src/app/(store)/store/orders/[id]/StoreOrderDetailClient.tsx index cb8ecceb..8fa1d16f 100644 --- a/apps/web/src/app/(store)/store/orders/[id]/StoreOrderDetailClient.tsx +++ b/apps/web/src/app/(store)/store/orders/[id]/StoreOrderDetailClient.tsx @@ -21,9 +21,9 @@ import { StoreOrderEscrowBanner } from "@/components/store-orders/StoreOrderEscr import { StoreOrderStatusBadge } from "@/components/store-orders/StoreOrderStatusBadge"; import { cn, formatKobo } from "@/lib/utils"; import { - dispatchStoreOrder, fetchStoreOrderById, fetchStoreOrderTracking, + markStoreOrderReadyForPickup, type StoreOrderDetail, type StoreOrderTrackingNote, } from "@/lib/store-orders"; @@ -97,10 +97,10 @@ export function StoreOrderDetailClient({ orderId }: Props) { async function handleDispatchConfirm() { if (!order) return; try { - await dispatchStoreOrder(order.id); + await markStoreOrderReadyForPickup(order.id); // Generic success copy — never forward backend strings. toast( - "Order marked ready for pickup. The buyer's delivery code is on its way.", + "Order marked ready for pickup. twizrr will coordinate delivery.", { variant: "success" }, ); setDispatchOpen(false); @@ -115,7 +115,7 @@ export function StoreOrderDetailClient({ orderId }: Props) { } catch { try { const refreshed = await fetchStoreOrderById(order.id); - if (refreshed?.status === "DISPATCHED") { + if (refreshed?.status === "READY_FOR_PICKUP") { setOrder(refreshed); setState("ready"); setDispatchOpen(false); @@ -126,7 +126,7 @@ export function StoreOrderDetailClient({ orderId }: Props) { /* best-effort */ } toast( - "Order marked ready for pickup. The buyer's delivery code is on its way.", + "Order marked ready for pickup. twizrr will coordinate delivery.", { variant: "success" }, ); return; @@ -335,6 +335,8 @@ function DeliveryCard({ status }: { status: StoreOrderDetail["status"] }) { const deliveryInstruction = status === "PAID" || status === "PREPARING" ? "Delivery handled by twizrr. Mark the order ready for pickup when it is packed." + : status === "READY_FOR_PICKUP" + ? "Delivery handled by twizrr. This order is ready for courier pickup." : status === "DISPATCHED" || status === "IN_TRANSIT" ? "Delivery handled by twizrr. This order is already moving through fulfillment." : status === "DELIVERED" || status === "COMPLETED" @@ -502,7 +504,7 @@ function ActionArea({

The buyer has paid and twizrr Buyer Protection is holding{" "} {formatKobo(order.totalAmountKobo)}. Mark the order ready for pickup - to send the buyer their delivery code. + so twizrr can coordinate courier pickup.

+ } + /> + + {notice && ( +
+ {notice.text} +
+ )} + +
+ void loadReady()} + onPrev={() => setReadyPage((p) => Math.max(1, p - 1))} + onNext={() => setReadyPage((p) => p + 1)} + onCreateBooking={(order) => void handleCreateBooking(order)} + /> + + void loadBookings()} + onPrev={() => setBookingsPage((p) => Math.max(1, p - 1))} + onNext={() => setBookingsPage((p) => p + 1)} + onStartDelivery={(booking) => void handleStartDelivery(booking)} + /> +
+ + ); +} + +function ReadyForBookingPanel({ + state, + page, + busyKey, + onRetry, + onPrev, + onNext, + onCreateBooking, +}: { + state: LoadState; + page: number; + busyKey: string | null; + onRetry: () => void; + onPrev: () => void; + onNext: () => void; + onCreateBooking: (order: AdminReadyDeliveryRow) => void; +}) { + return ( + + + + {state.status === "loading" && } + + {state.status === "error" && ( + + )} + + {state.status === "ready" && state.data.items.length === 0 && ( + + )} + + {state.status === "ready" && state.data.items.length > 0 && ( + <> +
    + {state.data.items.map((order) => ( + onCreateBooking(order)} + /> + ))} +
+ + + )} +
+ ); +} + +function BookedDeliveriesPanel({ + state, + page, + busyKey, + onRetry, + onPrev, + onNext, + onStartDelivery, +}: { + state: LoadState; + page: number; + busyKey: string | null; + onRetry: () => void; + onPrev: () => void; + onNext: () => void; + onStartDelivery: (booking: AdminDeliveryBooking) => void; +}) { + return ( + + + + {state.status === "loading" && } + + {state.status === "error" && ( + + )} + + {state.status === "ready" && state.data.items.length === 0 && ( + + )} + + {state.status === "ready" && state.data.items.length > 0 && ( + <> +
    + {state.data.items.map((booking) => ( + onStartDelivery(booking)} + /> + ))} +
+ + + )} +
+ ); +} + +function ReadyOrderRow({ + order, + busy, + onCreateBooking, +}: { + order: AdminReadyDeliveryRow; + busy: boolean; + onCreateBooking: () => void; +}) { + const title = orderLabel(order); + const store = storeLabel(order); + const pickupAddress = formatSnapshot(order.pickupAddressSnapshot); + const dropoffAddress = formatSnapshot(order.deliveryAddressSnapshot); + + return ( +
  • +
    +
    +
    + Ready for booking + + {order.orderCode ?? shortId(order.id)} + + + Updated {formatDate(order.updatedAt)} + +
    + +
    +

    + {title} +

    +

    + {store} +

    +
    + +
    + + +
    + +
    + + + +
    +
    + + +
    +
  • + ); +} + +function BookedDeliveryRow({ + booking, + busy, + onStartDelivery, +}: { + booking: AdminDeliveryBooking; + busy: boolean; + onStartDelivery: () => void; +}) { + const canStart = + booking.order?.status === "READY_FOR_PICKUP" && + (booking.status === "PENDING" || booking.status === "PICKUP_SCHEDULED"); + + return ( +
  • +
    +
    +
    + + {booking.status.replace(/_/g, " ").toLowerCase()} + + + {booking.order?.orderCode ?? shortId(booking.orderId)} + + {booking.order?.status && ( + + Order {booking.order.status.replace(/_/g, " ").toLowerCase()} + + )} +
    + +
    + + + + + +
    +
    + + +
    +
  • + ); +} + +function SnapshotBlock({ + title, + zone, + body, +}: { + title: string; + zone: string | null; + body: string; +}) { + return ( +
    +
    +

    + {title} +

    + {zone && {formatZone(zone)}} +
    +

    + {body || "Address snapshot missing"} +

    +
    + ); +} + +function InfoItem({ label, value }: { label: string; value: string }) { + return ( +
    +

    + {label} +

    +

    {value}

    +
    + ); +} + +function SkeletonRows({ rows, columns }: { rows: number; columns: number }) { + return ( +
    + {Array.from({ length: rows }).map((_, i) => ( + + ))} +
    + ); +} + +function orderLabel(order: AdminReadyDeliveryRow): string { + return ( + order.product?.name || + order.product?.title || + order.sourcedProduct?.customTitle || + order.sourcedProduct?.physicalProduct?.name || + order.sourcedProduct?.physicalProduct?.title || + "Order package" + ); +} + +function storeLabel(order: AdminReadyDeliveryRow): string { + const sourceStore = order.sourcedProduct?.physicalStore; + const directStore = order.storeProfile; + const sourceName = + sourceStore?.storeName || + sourceStore?.businessName || + sourceStore?.storeHandle; + const storeName = + directStore?.storeName || + directStore?.businessName || + directStore?.storeHandle; + + if (sourceName) return `Pickup source: ${sourceName}`; + if (storeName) return `Store: ${storeName}`; + return "Store details unavailable"; +} + +function deliveryStatusTone( + status: AdminDeliveryBooking["status"], +): "neutral" | "saffron" | "success" | "warning" | "danger" { + switch (status) { + case "PENDING": + case "PICKUP_SCHEDULED": + return "saffron"; + case "PICKED_UP": + case "IN_TRANSIT": + case "ARRIVING": + return "warning"; + case "DELIVERED": + return "success"; + case "FAILED": + return "danger"; + default: + return "neutral"; + } +} + +function deliveryActionError(err: unknown): string { + const apiErr = err as ApiError & { statusCode?: number }; + const message = apiErr?.message ?? ""; + const code = apiErr?.code ?? ""; + const status = apiErr?.status ?? apiErr?.statusCode; + + if ( + status === 403 || + code === "FORBIDDEN" || + message.toLowerCase().includes("forbidden") + ) { + return "You don't have permission to perform this action."; + } + + if ( + status === 409 || + code.includes("CONFLICT") || + code.includes("ALREADY") || + message.toLowerCase().includes("already") + ) { + return "This delivery has already been updated. Refresh and try again."; + } + + if ( + code.includes("ORDER_NOT_READY") || + code.includes("DELIVERY_BOOKING") || + message.toLowerCase().includes("ready for pickup") + ) { + return "This delivery is not in the right state for that action. Refresh and try again."; + } + + return message || "Unable to update this delivery. Please try again."; +} + +function formatSnapshot(snapshot: unknown): string { + if (typeof snapshot === "string") return snapshot; + if (!snapshot || typeof snapshot !== "object") return ""; + + const record = snapshot as Record; + const lines = [ + readString(record, "recipientName"), + readString(record, "formattedAddress") || readString(record, "address"), + [readString(record, "line1"), readString(record, "line2")] + .filter(Boolean) + .join(", "), + [readString(record, "city"), readString(record, "state")] + .filter(Boolean) + .join(", "), + readString(record, "landmark") || readString(record, "note"), + ].filter(Boolean); + + if (lines.length > 0) return lines.join("\n"); + return JSON.stringify(snapshot, null, 2); +} + +function readString( + record: Record, + key: string, +): string | null { + const value = record[key]; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function formatZone(zone: string): string { + return zone.replace(/_/g, " ").toLowerCase(); +} + +function formatDate(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "Unknown"; + return date.toLocaleDateString("en-NG", { + day: "numeric", + month: "short", + year: "numeric", + }); +} + +function shortId(id: string): string { + return id.length > 10 ? `${id.slice(0, 6)}...${id.slice(-4)}` : id; +} diff --git a/apps/web/src/lib/admin.ts b/apps/web/src/lib/admin.ts index 0e910e0c..2595f020 100644 --- a/apps/web/src/lib/admin.ts +++ b/apps/web/src/lib/admin.ts @@ -8,8 +8,8 @@ import { api } from "./api"; * JwtAuthGuard + RolesGuard — a non-admin caller gets 401/403 here, which * the admin layout probes on mount to render an Unauthorized state. * - * MVP scope: dashboard + users + stores + disputes + moderation. - * Orders, payouts, ledger, audit logs are deferred to a follow-up PR. + * MVP scope: dashboard + users + stores + disputes + moderation + internal + * delivery operations. Payouts, ledger, and audit logs are deferred. */ // ── Shared shapes ──────────────────────────────────────────────────────────── @@ -300,6 +300,115 @@ export function reviewAdminModeration( }); } +// ── Delivery operations ────────────────────────────────────────────────────── + +export type AdminDeliveryStatus = + | "PENDING" + | "PICKUP_SCHEDULED" + | "PICKED_UP" + | "IN_TRANSIT" + | "ARRIVING" + | "DELIVERED" + | "FAILED"; + +export interface AdminDeliveryStoreSummary { + id: string; + storeName: string | null; + businessName: string | null; + storeHandle: string | null; + logoUrl?: string | null; +} + +export interface AdminDeliveryProductSummary { + id: string; + productCode: string | null; + name: string | null; + title: string | null; + imageUrl: string | null; +} + +export interface AdminReadyDeliveryRow { + id: string; + orderCode: string | null; + orderType: string | null; + storeId: string | null; + productId: string | null; + sourcedProductId: string | null; + deliveryFeeKobo: string | null; + status: string; + createdAt: string; + updatedAt: string; + deliveryPrimaryPhone: string | null; + deliverySecondaryPhone: string | null; + deliveryAddressSnapshot: unknown; + pickupAddressSnapshot: unknown; + pickupStoreId: string | null; + sourceStoreId: string | null; + pickupZone: string | null; + deliveryZone: string | null; + storeProfile: AdminDeliveryStoreSummary | null; + product: AdminDeliveryProductSummary | null; + sourcedProduct: { + id: string; + customTitle: string | null; + sellingPriceKobo: string | null; + physicalStore: Omit | null; + physicalProduct: AdminDeliveryProductSummary | null; + } | null; +} + +export interface AdminDeliveryBooking { + id: string; + orderId: string; + method: string; + partnerName: string | null; + partnerRef: string | null; + trackingUrl: string | null; + estimatedCostKobo: string | null; + actualCostKobo: string | null; + status: AdminDeliveryStatus; + estimatedArrival: string | null; + pickedUpAt: string | null; + deliveredAt: string | null; + createdAt: string; + order?: { + id: string; + orderCode: string | null; + orderType: string | null; + status: string; + storeId: string | null; + } | null; +} + +export function fetchReadyForPickupDeliveries( + query?: AdminListQuery, +): Promise> { + return api.get>( + `/admin/deliveries/ready-for-pickup${buildQuery(query)}`, + ); +} + +export function fetchAdminDeliveries( + query?: AdminListQuery, +): Promise> { + return api.get>( + `/admin/deliveries${buildQuery(query)}`, + ); +} + +export function createManualDeliveryBooking( + orderId: string, +): Promise { + return api.post( + `/admin/deliveries/orders/${orderId}/book-manual`, + {}, + ); +} + +export function startAdminDelivery(orderId: string): Promise { + return api.post(`/admin/deliveries/orders/${orderId}/start`, {}); +} + // ── Internal: PATCH/PUT helper ─────────────────────────────────────────────── // `api` from lib/api.ts only exposes POST/PUT/GET/DELETE. The admin backend // uses PATCH on a handful of routes, so we replicate the same envelope-aware From cbf5df06df0a70d6472b59e003adda037d65d3ba Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Sun, 7 Jun 2026 08:00:41 +0100 Subject: [PATCH 025/389] fix(web): address admin delivery ops review --- .../src/app/(admin)/admin/deliveries/page.tsx | 200 +++++++++--------- apps/web/src/lib/admin.ts | 99 +++++++-- apps/web/src/lib/utils.ts | 37 +++- 3 files changed, 214 insertions(+), 122 deletions(-) diff --git a/apps/web/src/app/(admin)/admin/deliveries/page.tsx b/apps/web/src/app/(admin)/admin/deliveries/page.tsx index f736e42f..70987b96 100644 --- a/apps/web/src/app/(admin)/admin/deliveries/page.tsx +++ b/apps/web/src/app/(admin)/admin/deliveries/page.tsx @@ -1,6 +1,13 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useState } from "react"; +import { + useMutation, + useQuery, + useQueryClient, + type QueryClient, + type UseQueryResult, +} from "@tanstack/react-query"; import { RefreshCw, Truck } from "lucide-react"; import type { ApiError } from "@/lib/api"; import { @@ -26,106 +33,84 @@ import { const PAGE_SIZE = 10; -type LoadState = - | { status: "loading" } - | { status: "ready"; data: AdminListResult } - | { status: "error"; message: string }; +type DeliveryQuery = UseQueryResult, ApiError>; export default function AdminDeliveriesPage() { + const queryClient = useQueryClient(); const [readyPage, setReadyPage] = useState(1); const [bookingsPage, setBookingsPage] = useState(1); - const [readyState, setReadyState] = useState< - LoadState - >({ status: "loading" }); - const [bookingsState, setBookingsState] = useState< - LoadState - >({ status: "loading" }); const [busyKey, setBusyKey] = useState(null); const [notice, setNotice] = useState<{ tone: "success" | "error"; text: string; } | null>(null); - const loadReady = useCallback(async () => { - setReadyState({ status: "loading" }); - try { - const data = await fetchReadyForPickupDeliveries({ - page: readyPage, - limit: PAGE_SIZE, - }); - setReadyState({ status: "ready", data }); - } catch (err: unknown) { - const apiErr = err as ApiError; - setReadyState({ - status: "error", - message: apiErr?.message ?? "Unable to load orders ready for booking.", - }); - } - }, [readyPage]); + const readyQuery = useQuery, ApiError>( + { + queryKey: ["admin", "readyDeliveries", readyPage], + queryFn: () => + fetchReadyForPickupDeliveries({ + page: readyPage, + limit: PAGE_SIZE, + }), + }, + ); - const loadBookings = useCallback(async () => { - setBookingsState({ status: "loading" }); - try { - const data = await fetchAdminDeliveries({ + const bookingsQuery = useQuery< + AdminListResult, + ApiError + >({ + queryKey: ["admin", "deliveries", bookingsPage], + queryFn: () => + fetchAdminDeliveries({ page: bookingsPage, limit: PAGE_SIZE, - }); - setBookingsState({ status: "ready", data }); - } catch (err: unknown) { - const apiErr = err as ApiError; - setBookingsState({ - status: "error", - message: apiErr?.message ?? "Unable to load delivery bookings.", - }); - } - }, [bookingsPage]); - - const refreshAll = useCallback(async () => { - await Promise.all([loadReady(), loadBookings()]); - }, [loadReady, loadBookings]); - - useEffect(() => { - void loadReady(); - }, [loadReady]); - - useEffect(() => { - void loadBookings(); - }, [loadBookings]); - - async function handleCreateBooking(order: AdminReadyDeliveryRow) { - setBusyKey(`book:${order.id}`); - setNotice(null); - try { - await createManualDeliveryBooking(order.id); + }), + }); + + const createBookingMutation = useMutation({ + mutationFn: (order: AdminReadyDeliveryRow) => + createManualDeliveryBooking(order.id), + onMutate: (order) => { + setBusyKey(`book:${order.id}`); + setNotice(null); + }, + onSuccess: (_booking, order) => { setNotice({ tone: "success", text: `Manual booking created for ${order.orderCode ?? "this order"}.`, }); - await refreshAll(); - } catch (err: unknown) { + void invalidateDeliveryQueries(queryClient); + }, + onError: (err: unknown) => { setNotice({ tone: "error", text: deliveryActionError(err) }); - } finally { + }, + onSettled: () => { setBusyKey(null); - } - } + }, + }); - async function handleStartDelivery(booking: AdminDeliveryBooking) { - const orderId = booking.order?.id ?? booking.orderId; - setBusyKey(`start:${booking.id}`); - setNotice(null); - try { - await startAdminDelivery(orderId); + const startDeliveryMutation = useMutation({ + mutationFn: (booking: AdminDeliveryBooking) => + startAdminDelivery(booking.order?.id ?? booking.orderId), + onMutate: (booking) => { + setBusyKey(`start:${booking.id}`); + setNotice(null); + }, + onSuccess: (_result, booking) => { setNotice({ tone: "success", text: `Delivery started for ${booking.order?.orderCode ?? "this order"}.`, }); - await refreshAll(); - } catch (err: unknown) { + void invalidateDeliveryQueries(queryClient); + }, + onError: (err: unknown) => { setNotice({ tone: "error", text: deliveryActionError(err) }); - } finally { + }, + onSettled: () => { setBusyKey(null); - } - } + }, + }); return ( <> @@ -135,7 +120,7 @@ export default function AdminDeliveriesPage() { actions={

    - We couldn’t load your escrow summary right now. Try refreshing in - a moment. + We couldn’t load your protected payment summary right now. Try + refreshing in a moment.

    ); diff --git a/apps/web/src/app/(store)/store/dashboard/_components/RecentOrders.tsx b/apps/web/src/app/(store)/store/dashboard/_components/RecentOrders.tsx index 650e6ed6..d2215a9c 100644 --- a/apps/web/src/app/(store)/store/dashboard/_components/RecentOrders.tsx +++ b/apps/web/src/app/(store)/store/dashboard/_components/RecentOrders.tsx @@ -27,7 +27,7 @@ function statusBadgeConfig(status: StoreOrderStatus): StatusBadgeConfig { case "READY_FOR_PICKUP": return { label: "Ready for pickup", variant: "saffron" }; case "DISPATCHED": - return { label: "Dispatched", variant: "saffron" }; + return { label: "Delivery started", variant: "saffron" }; case "IN_TRANSIT": return { label: "In transit", variant: "saffron" }; case "DELIVERED": diff --git a/apps/web/src/app/(store)/store/orders/[id]/StoreOrderDetailClient.tsx b/apps/web/src/app/(store)/store/orders/[id]/StoreOrderDetailClient.tsx index 8fa1d16f..aaf61f95 100644 --- a/apps/web/src/app/(store)/store/orders/[id]/StoreOrderDetailClient.tsx +++ b/apps/web/src/app/(store)/store/orders/[id]/StoreOrderDetailClient.tsx @@ -99,10 +99,9 @@ export function StoreOrderDetailClient({ orderId }: Props) { try { await markStoreOrderReadyForPickup(order.id); // Generic success copy — never forward backend strings. - toast( - "Order marked ready for pickup. twizrr will coordinate delivery.", - { variant: "success" }, - ); + toast("Order marked ready for pickup. twizrr will coordinate delivery.", { + variant: "success", + }); setDispatchOpen(false); await reload(); // Refresh tracking too — backend creates a tracking row on dispatch. @@ -337,11 +336,11 @@ function DeliveryCard({ status }: { status: StoreOrderDetail["status"] }) { ? "Delivery handled by twizrr. Mark the order ready for pickup when it is packed." : status === "READY_FOR_PICKUP" ? "Delivery handled by twizrr. This order is ready for courier pickup." - : status === "DISPATCHED" || status === "IN_TRANSIT" - ? "Delivery handled by twizrr. This order is already moving through fulfillment." - : status === "DELIVERED" || status === "COMPLETED" - ? "Delivery handled by twizrr. This order has completed its delivery flow." - : "Delivery handled by twizrr. No pickup action is needed right now."; + : status === "DISPATCHED" || status === "IN_TRANSIT" + ? "Delivery handled by twizrr. This order is already moving through fulfillment." + : status === "DELIVERED" || status === "COMPLETED" + ? "Delivery handled by twizrr. This order has completed its delivery flow." + : "Delivery handled by twizrr. No pickup action is needed right now."; return (
    @@ -525,7 +524,7 @@ function ActionArea({ if (order.status === "READY_FOR_PICKUP") { copy = "Ready for pickup. twizrr will coordinate courier pickup next."; } else if (order.status === "DISPATCHED" || order.status === "IN_TRANSIT") { - copy = "Dispatched. Waiting for the buyer to confirm delivery."; + copy = "Delivery started. Waiting for the buyer to confirm delivery."; } else if (order.status === "DELIVERED") { copy = "Delivered. Payout queues automatically once the buyer confirms."; } else if (order.status === "COMPLETED") { diff --git a/apps/web/src/app/(store)/store/settings/StoreSettingsClient.tsx b/apps/web/src/app/(store)/store/settings/StoreSettingsClient.tsx index 3701f989..a0394dfe 100644 --- a/apps/web/src/app/(store)/store/settings/StoreSettingsClient.tsx +++ b/apps/web/src/app/(store)/store/settings/StoreSettingsClient.tsx @@ -388,7 +388,7 @@ export function StoreSettingsClient() {

    For security reasons, these actions require support assistance - to ensure no pending escrow payments are lost. + to ensure no pending protected payments are lost.

    @@ -246,6 +278,7 @@ function BookedDeliveriesPanel({ onRetry, onPrev, onNext, + onBookWithShipbubble, onStartDelivery, }: { query: DeliveryQuery; @@ -254,6 +287,10 @@ function BookedDeliveriesPanel({ onRetry: () => void; onPrev: () => void; onNext: () => void; + onBookWithShipbubble: ( + booking: AdminDeliveryBooking, + body: AdminShipbubbleBookingInput, + ) => void; onStartDelivery: (booking: AdminDeliveryBooking) => void; }) { return ( @@ -286,7 +323,10 @@ function BookedDeliveriesPanel({ + onBookWithShipbubble(booking, body) + } onStartDelivery={() => onStartDelivery(booking)} /> ))} @@ -386,20 +426,51 @@ function ReadyOrderRow({ function BookedDeliveryRow({ booking, - busy, + actionKey, + onBookWithShipbubble, onStartDelivery, }: { booking: AdminDeliveryBooking; - busy: boolean; + actionKey: string | null; + onBookWithShipbubble: (body: AdminShipbubbleBookingInput) => void; onStartDelivery: () => void; }) { + const [shipbubbleBody, setShipbubbleBody] = + useState({ + rateId: "", + requestToken: "", + serviceCode: "", + }); + const shipbubbleBusy = actionKey === `shipbubble:${booking.id}`; + const startBusy = actionKey === `start:${booking.id}`; + const canBookWithShipbubble = booking.status === "PENDING"; const canStart = booking.order?.status === "READY_FOR_PICKUP" && (booking.status === "PENDING" || booking.status === "PICKUP_SCHEDULED"); + const hasProviderSelection = + shipbubbleBody.rateId.trim() && shipbubbleBody.requestToken.trim(); + + function updateShipbubbleField( + field: keyof AdminShipbubbleBookingInput, + value: string, + ) { + setShipbubbleBody((current) => ({ + ...current, + [field]: value, + })); + } + + function submitShipbubbleBooking() { + onBookWithShipbubble({ + rateId: shipbubbleBody.rateId.trim(), + requestToken: shipbubbleBody.requestToken.trim(), + serviceCode: shipbubbleBody.serviceCode?.trim() || undefined, + }); + } return (
  • -
    +
    @@ -444,27 +515,99 @@ function BookedDeliveryRow({
    - + {canBookWithShipbubble && ( +
    +
    +
    + updateShipbubbleField("rateId", value)} + /> + + updateShipbubbleField("requestToken", value) + } + /> + + updateShipbubbleField("serviceCode", value) + } + /> +
    + +
    +

    + Delivery handled by twizrr. Booking with Shipbubble schedules + provider pickup; it does not start delivery or generate a delivery + code. +

    +
    + )} + +
    + +
  • ); } +function ProviderInput({ + label, + value, + optional, + onChange, +}: { + label: string; + value: string; + optional?: boolean; + onChange: (value: string) => void; +}) { + return ( + + ); +} + function SnapshotBlock({ title, zone, @@ -564,6 +707,13 @@ function deliveryActionError(err: unknown): string { const code = apiErr?.code ?? ""; const status = apiErr?.status ?? apiErr?.statusCode; + if ( + code.includes("SHIPBUBBLE") || + message.toLowerCase().includes("shipbubble") + ) { + return "Shipbubble booking failed. Manual booking remains available."; + } + if ( status === 403 || code === "FORBIDDEN" || diff --git a/apps/web/src/lib/admin.ts b/apps/web/src/lib/admin.ts index 4bf7647c..f8d2023a 100644 --- a/apps/web/src/lib/admin.ts +++ b/apps/web/src/lib/admin.ts @@ -440,6 +440,24 @@ export function createManualDeliveryBooking( .then(normalizeDeliveryBooking); } +export interface AdminShipbubbleBookingInput { + rateId: string; + requestToken: string; + serviceCode?: string; +} + +export function bookDeliveryWithShipbubble( + bookingId: string, + body: AdminShipbubbleBookingInput, +): Promise { + return api + .post( + `/admin/deliveries/${bookingId}/book-shipbubble`, + body, + ) + .then(normalizeDeliveryBooking); +} + export function startAdminDelivery(orderId: string): Promise { return api.post(`/admin/deliveries/orders/${orderId}/start`, {}); } From 587fb9c4f37ef3db1f865dc6ca7a542600160992 Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Mon, 8 Jun 2026 00:32:08 +0100 Subject: [PATCH 035/389] feat(delivery): add admin Shipbubble rate quote action --- .../modules/admin/admin.controller.spec.ts | 18 ++ .../src/modules/admin/admin.controller.ts | 12 ++ .../src/modules/admin/admin.service.spec.ts | 134 ++++++++++++++ .../src/modules/admin/admin.service.ts | 168 +++++++++++++++++- 4 files changed, 331 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/modules/admin/admin.controller.spec.ts b/apps/backend/src/modules/admin/admin.controller.spec.ts index f9dc20ed..d6b2e3cd 100644 --- a/apps/backend/src/modules/admin/admin.controller.spec.ts +++ b/apps/backend/src/modules/admin/admin.controller.spec.ts @@ -15,6 +15,7 @@ describe("AdminController timeline routes", () => { getStoreTimeline: jest.fn(), getReadyForPickupDeliveryQueue: jest.fn(), createManualDeliveryBooking: jest.fn(), + getShipbubbleDeliveryRates: jest.fn(), bookDeliveryWithShipbubble: jest.fn(), startDelivery: jest.fn(), }; @@ -156,4 +157,21 @@ describe("AdminController timeline routes", () => { dto, ); }); + + it("keeps Shipbubble rate lookup limited to admin operators", () => { + const roles = Reflect.getMetadata( + ROLES_KEY, + AdminController.prototype.getShipbubbleDeliveryRates, + ) as UserRole[] | undefined; + const controller = new AdminController(adminService as never); + const req = { user: { sub: "admin-1" } }; + + controller.getShipbubbleDeliveryRates("booking-1", req as never); + + expect(roles).toEqual([UserRole.SUPER_ADMIN, UserRole.OPERATOR]); + expect(adminService.getShipbubbleDeliveryRates).toHaveBeenCalledWith( + "booking-1", + "admin-1", + ); + }); }); diff --git a/apps/backend/src/modules/admin/admin.controller.ts b/apps/backend/src/modules/admin/admin.controller.ts index cc00c296..7729fe3b 100644 --- a/apps/backend/src/modules/admin/admin.controller.ts +++ b/apps/backend/src/modules/admin/admin.controller.ts @@ -667,6 +667,18 @@ export class AdminController { return this.adminService.startDelivery(orderId, req.user.sub); } + @Post("deliveries/:bookingId/shipbubble-rates") + @Roles(UserRole.SUPER_ADMIN, UserRole.OPERATOR) + getShipbubbleDeliveryRates( + @Param("bookingId") bookingId: string, + @Req() req: AuthenticatedRequest, + ) { + return this.adminService.getShipbubbleDeliveryRates( + bookingId, + req.user.sub, + ); + } + @Post("deliveries/:bookingId/book-shipbubble") @Roles(UserRole.SUPER_ADMIN, UserRole.OPERATOR) bookDeliveryWithShipbubble( diff --git a/apps/backend/src/modules/admin/admin.service.spec.ts b/apps/backend/src/modules/admin/admin.service.spec.ts index 4678bb0b..dbb397cd 100644 --- a/apps/backend/src/modules/admin/admin.service.spec.ts +++ b/apps/backend/src/modules/admin/admin.service.spec.ts @@ -127,6 +127,7 @@ describe("AdminService", () => { const mockShipbubble = { bookDelivery: jest.fn(), + getDeliveryRates: jest.fn(), }; const mockDomainEvents = { @@ -719,6 +720,139 @@ describe("AdminService", () => { }); }); + it("fetches Shipbubble rates for a pending delivery from order snapshots", async () => { + mockPrisma.deliveryBooking.findUnique.mockResolvedValue( + pendingShipbubbleBooking, + ); + mockShipbubble.getDeliveryRates.mockResolvedValue([ + { + rateId: "rate-1", + requestToken: "request-1", + courierName: "Shipbubble Express", + serviceCode: "express", + amountKobo: 260000n, + currency: "NGN", + estimatedDeliveryWindow: "1-2 days", + }, + ]); + + const result = await service.getShipbubbleDeliveryRates( + "booking-1", + "admin-1", + ); + + expect(mockShipbubble.getDeliveryRates).toHaveBeenCalledWith( + expect.objectContaining({ + name: "Balogun Source", + email: "store@example.com", + phone: "+2348000000000", + address: "Balogun Market, Lagos", + city: "Lagos Island", + state: "Lagos", + country: "NG", + }), + expect.objectContaining({ + name: "Ada Buyer", + phone: "+2348012345678", + address: "12 Shopper Street, Yaba", + city: "Yaba", + state: "Lagos", + country: "NG", + }), + expect.objectContaining({ + weightKg: 2, + categoryId: 1, + itemName: "Test product", + itemValueKobo: 2500000n, + quantity: 1, + }), + ); + expect(mockPrisma.deliveryBooking.update).not.toHaveBeenCalled(); + expect(mockPrisma.order.update).not.toHaveBeenCalled(); + expect(mockOrderService.startDeliveryByAdmin).not.toHaveBeenCalled(); + expect(result).toEqual({ + provider: "Shipbubble", + bookingId: "booking-1", + orderId: "order-1", + orderCode: "TWZ-123456", + requestToken: "request-1", + rates: [ + { + rateId: "rate-1", + courierName: "Shipbubble Express", + serviceCode: "express", + amountKobo: "260000", + currency: "NGN", + estimatedDeliveryDate: null, + estimatedDeliveryWindow: "1-2 days", + }, + ], + }); + }); + + it("rejects Shipbubble rate lookup for missing delivery bookings", async () => { + mockPrisma.deliveryBooking.findUnique.mockResolvedValue(null); + + await expect( + service.getShipbubbleDeliveryRates("missing", "admin-1"), + ).rejects.toBeInstanceOf(NotFoundException); + expect(mockShipbubble.getDeliveryRates).not.toHaveBeenCalled(); + }); + + it("rejects Shipbubble rate lookup unless the booking is pending", async () => { + mockPrisma.deliveryBooking.findUnique.mockResolvedValue({ + ...pendingShipbubbleBooking, + status: DeliveryStatus.PICKUP_SCHEDULED, + }); + + await expect( + service.getShipbubbleDeliveryRates("booking-1", "admin-1"), + ).rejects.toBeInstanceOf(ConflictException); + expect(mockShipbubble.getDeliveryRates).not.toHaveBeenCalled(); + }); + + it("rejects Shipbubble rate lookup unless the order is ready for pickup", async () => { + mockPrisma.deliveryBooking.findUnique.mockResolvedValue({ + ...pendingShipbubbleBooking, + order: { ...shipbubbleOrder, status: OrderStatus.PAID }, + }); + + await expect( + service.getShipbubbleDeliveryRates("booking-1", "admin-1"), + ).rejects.toBeInstanceOf(BadRequestException); + expect(mockShipbubble.getDeliveryRates).not.toHaveBeenCalled(); + }); + + it("rejects Shipbubble rate lookup when snapshots or contacts are incomplete", async () => { + mockPrisma.deliveryBooking.findUnique.mockResolvedValue({ + ...pendingShipbubbleBooking, + order: { + ...shipbubbleOrder, + deliveryPrimaryPhone: null, + }, + }); + + await expect( + service.getShipbubbleDeliveryRates("booking-1", "admin-1"), + ).rejects.toBeInstanceOf(BadRequestException); + expect(mockShipbubble.getDeliveryRates).not.toHaveBeenCalled(); + }); + + it("leaves delivery booking unchanged when Shipbubble rate lookup fails", async () => { + mockPrisma.deliveryBooking.findUnique.mockResolvedValue( + pendingShipbubbleBooking, + ); + mockShipbubble.getDeliveryRates.mockRejectedValue( + new Error("provider unavailable"), + ); + + await expect( + service.getShipbubbleDeliveryRates("booking-1", "admin-1"), + ).rejects.toBeInstanceOf(BadGatewayException); + expect(mockPrisma.deliveryBooking.update).not.toHaveBeenCalled(); + expect(mockPrisma.order.update).not.toHaveBeenCalled(); + }); + it("rejects Shipbubble booking for missing delivery bookings", async () => { mockPrisma.deliveryBooking.findUnique.mockResolvedValue(null); diff --git a/apps/backend/src/modules/admin/admin.service.ts b/apps/backend/src/modules/admin/admin.service.ts index 7b8d375c..9130f3df 100644 --- a/apps/backend/src/modules/admin/admin.service.ts +++ b/apps/backend/src/modules/admin/admin.service.ts @@ -29,6 +29,7 @@ import { ShipbubbleClient } from "../../integrations/shipbubble/shipbubble.clien import { ShipbubbleAddress, ShipbubbleParcel, + ShipbubbleRate, } from "../../integrations/shipbubble/shipbubble.types"; const DEFAULT_ADMIN_LIMIT = 20; @@ -135,6 +136,23 @@ export interface AdminShipbubbleBookingInput { quantity?: number; } +export interface AdminShipbubbleRateQuote { + provider: "Shipbubble"; + bookingId: string; + orderId: string; + orderCode: string; + requestToken: string | null; + rates: Array<{ + rateId: string; + courierName: string; + serviceCode: string | null; + amountKobo: string; + currency: string; + estimatedDeliveryDate: string | null; + estimatedDeliveryWindow: string | null; + }>; +} + type AdminDeliveryBookingOrder = { id: string; orderCode: string; @@ -442,7 +460,7 @@ export class AdminService { private buildShipbubbleParcel( order: AdminDeliveryBookingOrder, - input: AdminShipbubbleBookingInput, + input: Partial, ): ShipbubbleParcel { const product = order.sourcedProduct?.physicalProduct ?? order.product; const itemValueKobo = @@ -2726,6 +2744,154 @@ export class AdminService { }; } + async getShipbubbleDeliveryRates( + bookingId: string, + adminId: string, + ): Promise { + const booking = await this.prisma.deliveryBooking.findUnique({ + where: { id: bookingId }, + select: { + id: true, + orderId: true, + status: true, + order: { + select: { + id: true, + orderCode: true, + status: true, + totalAmountKobo: true, + deliveryFeeKobo: true, + quantity: true, + deliveryAddress: true, + deliveryPrimaryPhone: true, + deliverySecondaryPhone: true, + deliveryAddressSnapshot: true, + pickupAddressSnapshot: true, + product: { + select: { + name: true, + title: true, + retailPriceKobo: true, + weightKg: true, + }, + }, + sourcedProduct: { + select: { + customTitle: true, + sellingPriceKobo: true, + physicalProduct: { + select: { + name: true, + title: true, + retailPriceKobo: true, + weightKg: true, + }, + }, + physicalStore: { + select: { + storeName: true, + businessName: true, + user: { + select: { + email: true, + phone: true, + displayName: true, + firstName: true, + lastName: true, + }, + }, + }, + }, + }, + }, + storeProfile: { + select: { + storeName: true, + businessName: true, + user: { + select: { + email: true, + phone: true, + displayName: true, + firstName: true, + lastName: true, + }, + }, + }, + }, + }, + }, + }, + }); + + if (!booking) { + throw new NotFoundException("Delivery booking not found"); + } + + if (booking.status !== DeliveryStatus.PENDING) { + throw new ConflictException({ + message: + "Delivery booking must be pending before fetching Shipbubble rates.", + code: "DELIVERY_BOOKING_NOT_PENDING", + }); + } + + if (!booking.order) { + throw new NotFoundException("Order not found for delivery booking"); + } + + if (booking.order.status !== OrderStatus.READY_FOR_PICKUP) { + throw new BadRequestException({ + message: + "Order must be ready for pickup before fetching Shipbubble rates.", + code: "ORDER_NOT_READY_FOR_PICKUP", + }); + } + + const pickupAddress = this.buildShipbubblePickupAddress(booking.order); + const deliveryAddress = this.buildShipbubbleDeliveryAddress(booking.order); + const parcel = this.buildShipbubbleParcel(booking.order, {}); + + let rates: ShipbubbleRate[]; + try { + rates = await this.shipbubble.getDeliveryRates( + pickupAddress, + deliveryAddress, + parcel, + ); + } catch (error) { + this.logger.error( + `Shipbubble rate lookup failed for delivery booking ${bookingId} by admin ${adminId}: ${ + error instanceof Error ? error.message : String(error) + }`, + error instanceof Error ? error.stack : undefined, + ); + throw new BadGatewayException({ + message: + "Shipbubble rates could not be fetched. Manual booking remains available.", + code: "SHIPBUBBLE_RATES_FAILED", + }); + } + + return { + provider: "Shipbubble", + bookingId: booking.id, + orderId: booking.orderId, + orderCode: booking.order.orderCode, + requestToken: + rates.find((rate) => rate.requestToken)?.requestToken ?? null, + rates: rates.map((rate) => ({ + rateId: rate.rateId, + courierName: rate.courierName, + serviceCode: rate.serviceCode ?? null, + amountKobo: rate.amountKobo.toString(), + currency: rate.currency, + estimatedDeliveryDate: rate.estimatedDeliveryDate ?? null, + estimatedDeliveryWindow: rate.estimatedDeliveryWindow ?? null, + })), + }; + } + async startDelivery(orderId: string, adminId: string) { const result = await this.orderService.startDeliveryByAdmin( orderId, From 2756e9fa92fefccf97b7c7895cc30add9f34b34a Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Mon, 8 Jun 2026 00:55:53 +0100 Subject: [PATCH 036/389] feat(web): add admin Shipbubble rate selection --- .../src/app/(admin)/admin/deliveries/page.tsx | 238 +++++++++++++++--- apps/web/src/lib/admin.ts | 47 ++++ 2 files changed, 257 insertions(+), 28 deletions(-) diff --git a/apps/web/src/app/(admin)/admin/deliveries/page.tsx b/apps/web/src/app/(admin)/admin/deliveries/page.tsx index 1980798e..ce3c4841 100644 --- a/apps/web/src/app/(admin)/admin/deliveries/page.tsx +++ b/apps/web/src/app/(admin)/admin/deliveries/page.tsx @@ -15,9 +15,12 @@ import { createManualDeliveryBooking, fetchAdminDeliveries, fetchReadyForPickupDeliveries, + fetchShipbubbleRates, startAdminDelivery, type AdminDeliveryBooking, type AdminShipbubbleBookingInput, + type AdminShipbubbleRate, + type AdminShipbubbleRateQuote, type AdminListResult, type AdminReadyDeliveryRow, } from "@/lib/admin"; @@ -441,6 +444,12 @@ function BookedDeliveryRow({ requestToken: "", serviceCode: "", }); + const [rateQuote, setRateQuote] = useState( + null, + ); + const [selectedRateId, setSelectedRateId] = useState(""); + const [rateError, setRateError] = useState(null); + const [ratesBusy, setRatesBusy] = useState(false); const shipbubbleBusy = actionKey === `shipbubble:${booking.id}`; const startBusy = actionKey === `start:${booking.id}`; const canBookWithShipbubble = booking.status === "PENDING"; @@ -449,6 +458,12 @@ function BookedDeliveryRow({ (booking.status === "PENDING" || booking.status === "PICKUP_SCHEDULED"); const hasProviderSelection = shipbubbleBody.rateId.trim() && shipbubbleBody.requestToken.trim(); + const selectedRate = + rateQuote?.rates.find((rate) => rate.rateId === selectedRateId) ?? null; + const selectedRateRequestToken = + selectedRate?.requestToken ?? rateQuote?.requestToken ?? ""; + const canBookSelectedRate = + Boolean(selectedRate && selectedRateRequestToken) && !shipbubbleBusy; function updateShipbubbleField( field: keyof AdminShipbubbleBookingInput, @@ -460,6 +475,29 @@ function BookedDeliveryRow({ })); } + async function loadShipbubbleRates() { + setRatesBusy(true); + setRateError(null); + try { + const quote = await fetchShipbubbleRates(booking.id); + setRateQuote(quote); + setSelectedRateId(quote.rates[0]?.rateId ?? ""); + } catch (error) { + setRateError(deliveryRateError(error)); + } finally { + setRatesBusy(false); + } + } + + function submitSelectedRateBooking() { + if (!selectedRate || !selectedRateRequestToken) return; + onBookWithShipbubble({ + rateId: selectedRate.rateId, + requestToken: selectedRateRequestToken, + serviceCode: selectedRate.serviceCode ?? undefined, + }); + } + function submitShipbubbleBooking() { onBookWithShipbubble({ rateId: shipbubbleBody.rateId.trim(), @@ -517,40 +555,118 @@ function BookedDeliveryRow({ {canBookWithShipbubble && (
    -
    -
    - updateShipbubbleField("rateId", value)} - /> - - updateShipbubbleField("requestToken", value) - } - /> - - updateShipbubbleField("serviceCode", value) - } - /> +
    +
    +

    + Shipbubble rates +

    +

    + Fetch rates, select a courier option, then book the selected + rate. Start delivery remains separate. +

    -

    + + {rateError && ( +

    + {rateError} +

    + )} + + {rateQuote && ( +
    + {rateQuote.rates.length === 0 ? ( +

    + No Shipbubble rates were returned. Manual booking remains + available. +

    + ) : ( +
    + {rateQuote.rates.map((rate) => ( + setSelectedRateId(rate.rateId)} + /> + ))} +
    + )} + +
    +

    + Booking with Shipbubble schedules provider pickup; it does + not start delivery or generate a delivery code. +

    + +
    +
    + )} + +
    + + Manual Shipbubble fallback + +
    +
    + updateShipbubbleField("rateId", value)} + /> + + updateShipbubbleField("requestToken", value) + } + /> + + updateShipbubbleField("serviceCode", value) + } + /> +
    + +
    +
    + +

    Delivery handled by twizrr. Booking with Shipbubble schedules provider pickup; it does not start delivery or generate a delivery code. @@ -608,6 +724,57 @@ function ProviderInput({ ); } +function ShipbubbleRateOption({ + rate, + groupName, + selected, + onSelect, +}: { + rate: AdminShipbubbleRate; + groupName: string; + selected: boolean; + onSelect: () => void; +}) { + const eta = + rate.estimatedDeliveryWindow || + (rate.estimatedDeliveryDate + ? `ETA ${formatDate(rate.estimatedDeliveryDate)}` + : "ETA unavailable"); + + return ( + + ); +} + function SnapshotBlock({ title, zone, @@ -742,6 +909,21 @@ function deliveryActionError(err: unknown): string { return message || "Unable to update this delivery. Please try again."; } +function deliveryRateError(err: unknown): string { + const apiErr = err as ApiError & { statusCode?: number }; + const message = apiErr?.message ?? ""; + const code = apiErr?.code ?? ""; + + if ( + code.includes("SHIPBUBBLE") || + message.toLowerCase().includes("shipbubble") + ) { + return "Shipbubble rates could not be fetched. Manual booking remains available."; + } + + return deliveryActionError(err); +} + async function invalidateDeliveryQueries(queryClient: QueryClient) { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["admin", "readyDeliveries"] }), diff --git a/apps/web/src/lib/admin.ts b/apps/web/src/lib/admin.ts index f8d2023a..fac593bd 100644 --- a/apps/web/src/lib/admin.ts +++ b/apps/web/src/lib/admin.ts @@ -446,6 +446,53 @@ export interface AdminShipbubbleBookingInput { serviceCode?: string; } +export interface AdminShipbubbleRate { + rateId: string; + requestToken: string | null; + courierName: string; + serviceCode: string | null; + amountKobo: bigint | null; + currency: string; + estimatedDeliveryDate: string | null; + estimatedDeliveryWindow: string | null; +} + +export interface AdminShipbubbleRateQuote { + provider: "Shipbubble"; + bookingId: string; + orderId: string; + orderCode: string; + requestToken: string | null; + rates: AdminShipbubbleRate[]; +} + +type RawAdminShipbubbleRateQuote = Omit & { + rates: Array< + Omit & { + amountKobo: unknown; + requestToken?: string | null; + } + >; +}; + +export function fetchShipbubbleRates( + bookingId: string, +): Promise { + return api + .post( + `/admin/deliveries/${bookingId}/shipbubble-rates`, + {}, + ) + .then((quote) => ({ + ...quote, + rates: quote.rates.map((rate) => ({ + ...rate, + requestToken: rate.requestToken ?? quote.requestToken ?? null, + amountKobo: toBigIntOrNull(rate.amountKobo), + })), + })); +} + export function bookDeliveryWithShipbubble( bookingId: string, body: AdminShipbubbleBookingInput, From 3504a0be003d38139fb8221319c91755938fd3c5 Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Mon, 8 Jun 2026 01:15:06 +0100 Subject: [PATCH 037/389] docs(delivery): add Shipbubble sandbox QA runbook --- .../src/integrations/shipbubble/README.md | 3 + .../shipbubble/SANDBOX_QA_RUNBOOK.md | 134 ++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 apps/backend/src/integrations/shipbubble/SANDBOX_QA_RUNBOOK.md diff --git a/apps/backend/src/integrations/shipbubble/README.md b/apps/backend/src/integrations/shipbubble/README.md index b9c86d0c..e0e8075a 100644 --- a/apps/backend/src/integrations/shipbubble/README.md +++ b/apps/backend/src/integrations/shipbubble/README.md @@ -4,3 +4,6 @@ Logistics provider client for delivery booking and tracking. To be built as part of the orders/delivery domain. Required methods: getDeliveryRates(), bookDelivery(), trackShipment(). +Before webhook or tracking automation, run the sandbox/admin QA checklist in +`SANDBOX_QA_RUNBOOK.md`. + diff --git a/apps/backend/src/integrations/shipbubble/SANDBOX_QA_RUNBOOK.md b/apps/backend/src/integrations/shipbubble/SANDBOX_QA_RUNBOOK.md new file mode 100644 index 00000000..8ae64e8b --- /dev/null +++ b/apps/backend/src/integrations/shipbubble/SANDBOX_QA_RUNBOOK.md @@ -0,0 +1,134 @@ +# Shipbubble Sandbox QA Runbook + +Use this runbook before adding Shipbubble webhook or tracking automation. It is +for dev or sandbox data only. Do not use production orders, production provider +credentials, or real shopper contact details during this test. + +## Current Flow + +1. Shopper checkout creates delivery snapshots and a twizrr-calculated delivery + fee. +2. Store owner marks the order `READY_FOR_PICKUP`. +3. Admin or operator creates an internal/manual `DeliveryBooking`. +4. Admin or operator fetches Shipbubble rates for the booking. +5. Admin or operator selects and books a Shipbubble rate. +6. The booking becomes `PICKUP_SCHEDULED`. +7. The order remains `READY_FOR_PICKUP`. +8. Admin or operator starts delivery separately. +9. The order becomes `DISPATCHED`; the delivery code is generated, the buyer is + notified, and auto-confirm timers start. + +## Required Environment + +Set these only in the relevant dev or sandbox environment: + +- `SHIPBUBBLE_API_KEY`: required for live/sandbox Shipbubble rate and booking + calls. +- `SHIPBUBBLE_BASE_URL`: optional; defaults to the configured Shipbubble base + URL when omitted. +- `SHIPBUBBLE_WEBHOOK_SECRET`: later webhook/tracking automation only. + +Do not commit real values. Shipbubble envs are optional until provider testing +is being performed. + +## Required Test Data + +Use a test order that has: + +- A shopper with a verified primary account phone. +- A supported pickup zone and supported dropoff zone. +- A product with a clear name and sane item value. +- A product weight if available. If missing, the current MVP provider request + falls back to a default package weight. +- `pickupAddressSnapshot`. +- `deliveryAddressSnapshot`. +- `deliveryPrimaryPhone`. +- `pickupZone`. +- `deliveryZone`. +- Order status `READY_FOR_PICKUP`. +- A pending internal `DeliveryBooking`. + +For direct orders, pickup should come from the selling store pickup point. For +sourced or dropshipped orders, pickup should come from the physical supplier +pickup point. + +## Manual Test Steps + +1. Create or identify a dev/sandbox shopper order. +2. Confirm checkout created delivery fee and delivery snapshots. +3. Mark the order `READY_FOR_PICKUP` as the store owner. +4. Open `/admin/deliveries` as `SUPER_ADMIN` or `OPERATOR`. +5. Confirm the order appears in Ready for booking. +6. Create the internal/manual booking. +7. Confirm the booking appears in Booked deliveries with status `PENDING`. +8. Click `Fetch Shipbubble rates`. +9. Confirm the response shows: + - request token + - rate id + - courier or service name + - fee + - ETA or delivery window when provided +10. Select a rate. +11. Click `Book selected rate`. +12. Confirm the booking becomes `PICKUP_SCHEDULED`. +13. Confirm the order remains `READY_FOR_PICKUP`. +14. Confirm no delivery code has been generated yet. +15. Confirm no buyer dispatched notification has been sent yet. +16. Confirm no auto-confirm timer has started yet. +17. Click `Start delivery`. +18. Confirm the order becomes `DISPATCHED`. +19. Confirm the booking becomes `PICKED_UP`. +20. Confirm the delivery code is generated and sent to the buyer at start + delivery time. +21. Confirm the buyer can confirm delivery with the delivery code. + +## Failure Cases To Test + +| Case | Expected result | +| --- | --- | +| Missing `SHIPBUBBLE_API_KEY` | Rate/booking action fails safely; manual booking remains available. | +| Unsupported or bad pickup/dropoff address | Provider error is shown to admin only; order and booking status stay unchanged. | +| Missing pickup snapshot | Rate/booking action is rejected before provider booking. | +| Missing delivery snapshot | Rate/booking action is rejected before provider booking. | +| Missing primary delivery phone | Rate/booking action is rejected before provider booking. | +| Provider quote failure | Booking/order remain unchanged and admin sees safe retry/fallback copy. | +| Provider booking failure | Booking/order remain unchanged and admin sees safe retry/fallback copy. | +| Provider cost differs from collected delivery fee | Record the mismatch and confirm product decision before automation. | +| `SUPPORT` attempts rate, booking, or start-delivery mutation | Backend denies the mutation. | +| Manual fallback is used without Shipbubble | Internal manual booking path still works. | + +## Privacy Checklist + +- Shopper UI must not show Shipbubble or provider selection. +- Store owner UI must not show Shipbubble or provider selection. +- Store owner APIs must not expose shopper phone, shopper email, delivery phone, + delivery address snapshots, raw delivery details, or delivery code hashes. +- Admin delivery operations may show contact and address snapshots for internal + fulfillment only. +- Delivery phone and address are internal fulfillment data and may be shared only + with approved delivery or logistics providers when required. + +## Pass/Fail Template + +| Test case | Expected result | Actual result | Pass/fail | Notes or log reference | +| --- | --- | --- | --- | --- | +| Ready order has required snapshots | Pickup, dropoff, zones, and primary phone exist | | | | +| Manual booking created | DeliveryBooking is `PENDING` | | | | +| Shipbubble rates fetched | Rates include request token, rate id, courier, fee, and ETA/window when available | | | | +| Selected rate booked | Booking becomes `PICKUP_SCHEDULED`; order remains `READY_FOR_PICKUP` | | | | +| No dispatch side effects before start | No delivery code, buyer dispatched notification, or auto-confirm timer | | | | +| Start delivery | Order becomes `DISPATCHED`; booking becomes `PICKED_UP` | | | | +| Buyer receives delivery code | Notification is sent only when delivery starts | | | | +| Buyer confirms delivery | Delivery confirmation succeeds with valid delivery code | | | | +| Store-owner privacy | Store owner cannot see shopper contact or delivery snapshots | | | | +| Manual fallback | Manual booking remains usable when provider calls fail | | | | + +## Next-Step Gate + +Do not start Shipbubble webhook or tracking automation until: + +- At least one real/sandbox rate fetch succeeds. +- At least one real/sandbox booking succeeds. +- Any provider payload mismatches are documented and fixed. +- The cost mismatch handling decision is confirmed. +- Admin/operator runbook results are recorded for the dev or sandbox test order. From ed31f7cff1666e3faaaebd11ce3c2e8b7132a828 Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Mon, 8 Jun 2026 01:45:37 +0100 Subject: [PATCH 038/389] fix(delivery): configure Shipbubble package defaults --- apps/backend/.env.example | 4 ++ .../src/common/config/env.validation.ts | 8 +++ apps/backend/src/config/shipbubble.config.ts | 18 +++++ .../src/integrations/shipbubble/README.md | 4 ++ .../shipbubble/SANDBOX_QA_RUNBOOK.md | 12 +++- .../src/modules/admin/admin.service.spec.ts | 65 +++++++++++++++++++ .../src/modules/admin/admin.service.ts | 8 ++- 7 files changed, 116 insertions(+), 3 deletions(-) diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 4ef88112..ec11d6e5 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -100,6 +100,10 @@ VERTEX_AI_MODEL=multimodalembedding@001 SHIPBUBBLE_API_KEY= SHIPBUBBLE_WEBHOOK_SECRET= SHIPBUBBLE_BASE_URL=https://api.shipbubble.com/v1 +# Provider-sensitive package defaults used only when product/package data is missing. +# Confirm these in the Shipbubble sandbox before provider testing. +SHIPBUBBLE_DEFAULT_CATEGORY_ID=1 +DEFAULT_PACKAGE_WEIGHT_KG=1 # ── PREMBLY (KYB Verification) ─────────────── PREMBLY_API_KEY= diff --git a/apps/backend/src/common/config/env.validation.ts b/apps/backend/src/common/config/env.validation.ts index eea40a68..68f320c5 100644 --- a/apps/backend/src/common/config/env.validation.ts +++ b/apps/backend/src/common/config/env.validation.ts @@ -10,6 +10,12 @@ const optionalNumericString = Joi.string() .empty("") .pattern(/^\d+(\.\d+)?$/) .optional(); +const optionalPositiveInteger = Joi.number() + .integer() + .positive() + .empty("") + .optional(); +const optionalPositiveNumber = Joi.number().positive().empty("").optional(); const optionalSecret = Joi.string().empty("").min(1).optional(); export const envValidationSchema = Joi.object({ @@ -71,6 +77,8 @@ export const envValidationSchema = Joi.object({ SHIPBUBBLE_API_KEY: optionalString, SHIPBUBBLE_BASE_URL: optionalUri, SHIPBUBBLE_WEBHOOK_SECRET: optionalString, + SHIPBUBBLE_DEFAULT_CATEGORY_ID: optionalPositiveInteger, + DEFAULT_PACKAGE_WEIGHT_KG: optionalPositiveNumber, PREMBLY_API_KEY: optionalString, PREMBLY_APP_ID: optionalString, PREMBLY_BASE_URL: optionalUri, diff --git a/apps/backend/src/config/shipbubble.config.ts b/apps/backend/src/config/shipbubble.config.ts index 0875f326..f740fcf1 100644 --- a/apps/backend/src/config/shipbubble.config.ts +++ b/apps/backend/src/config/shipbubble.config.ts @@ -1,7 +1,25 @@ import { registerAs } from "@nestjs/config"; +function parsePositiveInteger(value: string | undefined, fallback: number) { + const parsed = Number.parseInt(value ?? "", 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function parsePositiveNumber(value: string | undefined, fallback: number) { + const parsed = Number.parseFloat(value ?? ""); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + export default registerAs("shipbubble", () => ({ apiKey: process.env.SHIPBUBBLE_API_KEY, baseUrl: process.env.SHIPBUBBLE_BASE_URL || "https://api.shipbubble.com/v1", webhookSecret: process.env.SHIPBUBBLE_WEBHOOK_SECRET, + defaultCategoryId: parsePositiveInteger( + process.env.SHIPBUBBLE_DEFAULT_CATEGORY_ID, + 1, + ), + defaultPackageWeightKg: parsePositiveNumber( + process.env.DEFAULT_PACKAGE_WEIGHT_KG, + 1, + ), })); diff --git a/apps/backend/src/integrations/shipbubble/README.md b/apps/backend/src/integrations/shipbubble/README.md index e0e8075a..aa3f6e02 100644 --- a/apps/backend/src/integrations/shipbubble/README.md +++ b/apps/backend/src/integrations/shipbubble/README.md @@ -7,3 +7,7 @@ Required methods: getDeliveryRates(), bookDelivery(), trackShipment(). Before webhook or tracking automation, run the sandbox/admin QA checklist in `SANDBOX_QA_RUNBOOK.md`. +Provider-sensitive package fallbacks are configured with +`SHIPBUBBLE_DEFAULT_CATEGORY_ID` and `DEFAULT_PACKAGE_WEIGHT_KG`. Confirm both +values in the Shipbubble sandbox before using live provider booking. + diff --git a/apps/backend/src/integrations/shipbubble/SANDBOX_QA_RUNBOOK.md b/apps/backend/src/integrations/shipbubble/SANDBOX_QA_RUNBOOK.md index 8ae64e8b..3458489a 100644 --- a/apps/backend/src/integrations/shipbubble/SANDBOX_QA_RUNBOOK.md +++ b/apps/backend/src/integrations/shipbubble/SANDBOX_QA_RUNBOOK.md @@ -26,6 +26,12 @@ Set these only in the relevant dev or sandbox environment: calls. - `SHIPBUBBLE_BASE_URL`: optional; defaults to the configured Shipbubble base URL when omitted. +- `SHIPBUBBLE_DEFAULT_CATEGORY_ID`: optional local/dev fallback used when an + order has no mapped provider category. Confirm the value in Shipbubble before + sandbox provider testing. +- `DEFAULT_PACKAGE_WEIGHT_KG`: optional local/dev fallback used when product or + package weight is missing. Confirm the value in Shipbubble before sandbox + provider testing because it affects quotes and booking eligibility. - `SHIPBUBBLE_WEBHOOK_SECRET`: later webhook/tracking automation only. Do not commit real values. Shipbubble envs are optional until provider testing @@ -38,8 +44,10 @@ Use a test order that has: - A shopper with a verified primary account phone. - A supported pickup zone and supported dropoff zone. - A product with a clear name and sane item value. -- A product weight if available. If missing, the current MVP provider request - falls back to a default package weight. +- A product weight if available. If missing, the provider request falls back to + `DEFAULT_PACKAGE_WEIGHT_KG`. +- A provider category mapping if available. If missing, the provider request + falls back to `SHIPBUBBLE_DEFAULT_CATEGORY_ID`. - `pickupAddressSnapshot`. - `deliveryAddressSnapshot`. - `deliveryPrimaryPhone`. diff --git a/apps/backend/src/modules/admin/admin.service.spec.ts b/apps/backend/src/modules/admin/admin.service.spec.ts index dbb397cd..0ce54f25 100644 --- a/apps/backend/src/modules/admin/admin.service.spec.ts +++ b/apps/backend/src/modules/admin/admin.service.spec.ts @@ -8,6 +8,7 @@ import { VerificationService } from "../verification/verification.service"; import { OrderService } from "../order/order.service"; import { DomainEventService } from "../../domains/platform/domain-event/domain-event.service"; import { ShipbubbleClient } from "../../integrations/shipbubble/shipbubble.client"; +import { ConfigService } from "@nestjs/config"; import { getQueueToken } from "@nestjs/bullmq"; import { PAYOUT_QUEUE } from "../../queue/queue.constants"; import { @@ -130,6 +131,10 @@ describe("AdminService", () => { getDeliveryRates: jest.fn(), }; + const mockConfig = { + get: jest.fn(), + }; + const mockDomainEvents = { append: jest.fn().mockResolvedValue({ id: "domain-event-1" }), sanitizeMetadata: jest.fn((metadata: unknown) => @@ -148,6 +153,7 @@ describe("AdminService", () => { { provide: VerificationService, useValue: mockVerification }, { provide: OrderService, useValue: mockOrderService }, { provide: ShipbubbleClient, useValue: mockShipbubble }, + { provide: ConfigService, useValue: mockConfig }, { provide: getQueueToken(PAYOUT_QUEUE), useValue: mockPayoutQueue }, { provide: DomainEventService, useValue: mockDomainEvents }, ], @@ -790,6 +796,65 @@ describe("AdminService", () => { }); }); + it("uses configured package defaults when product and snapshot values are missing", async () => { + mockConfig.get.mockImplementation((key: string) => { + if (key === "shipbubble.defaultPackageWeightKg") return 1.75; + if (key === "shipbubble.defaultCategoryId") return 42; + return undefined; + }); + mockPrisma.deliveryBooking.findUnique.mockResolvedValue({ + ...pendingShipbubbleBooking, + order: { + ...shipbubbleOrder, + pickupAddressSnapshot: { + formattedAddress: "Balogun Market, Lagos", + city: "Lagos Island", + state: "Lagos", + storeName: "Balogun Source", + }, + product: { + ...shipbubbleOrder.product, + weightKg: null, + }, + }, + }); + mockShipbubble.getDeliveryRates.mockResolvedValue([]); + + await service.getShipbubbleDeliveryRates("booking-1", "admin-1"); + + expect(mockShipbubble.getDeliveryRates).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + expect.objectContaining({ + weightKg: 1.75, + categoryId: 42, + }), + ); + }); + + it("keeps product weight ahead of configured package weight", async () => { + mockConfig.get.mockImplementation((key: string) => { + if (key === "shipbubble.defaultPackageWeightKg") return 1.75; + if (key === "shipbubble.defaultCategoryId") return 42; + return undefined; + }); + mockPrisma.deliveryBooking.findUnique.mockResolvedValue( + pendingShipbubbleBooking, + ); + mockShipbubble.getDeliveryRates.mockResolvedValue([]); + + await service.getShipbubbleDeliveryRates("booking-1", "admin-1"); + + expect(mockShipbubble.getDeliveryRates).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + expect.objectContaining({ + weightKg: 2, + categoryId: 42, + }), + ); + }); + it("rejects Shipbubble rate lookup for missing delivery bookings", async () => { mockPrisma.deliveryBooking.findUnique.mockResolvedValue(null); diff --git a/apps/backend/src/modules/admin/admin.service.ts b/apps/backend/src/modules/admin/admin.service.ts index 9130f3df..b8d43c79 100644 --- a/apps/backend/src/modules/admin/admin.service.ts +++ b/apps/backend/src/modules/admin/admin.service.ts @@ -6,6 +6,7 @@ import { Logger, ConflictException, } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { InjectQueue } from "@nestjs/bullmq"; import { Queue } from "bullmq"; import { PrismaService } from "../../prisma/prisma.service"; @@ -215,6 +216,7 @@ export class AdminService { private domainEvents: DomainEventService, private orderService: OrderService, private shipbubble: ShipbubbleClient, + private configService: ConfigService, ) {} private async appendAdminAuditDomainEvent( @@ -474,8 +476,12 @@ export class AdminService { input.weightKg ?? product?.weightKg ?? this.readJsonNumber(order.pickupAddressSnapshot, "weightKg") ?? + this.configService.get("shipbubble.defaultPackageWeightKg") ?? + 1, + categoryId: + input.categoryId ?? + this.configService.get("shipbubble.defaultCategoryId") ?? 1, - categoryId: input.categoryId ?? 1, itemName: input.itemName ?? order.sourcedProduct?.customTitle ?? From b4cd342967628cb7d59191b5d1a3338758157b1a Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Mon, 8 Jun 2026 02:50:03 +0100 Subject: [PATCH 039/389] chore(backend): include Jest types for specs --- apps/backend/tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index 9a1ea15b..a64322e0 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -11,6 +11,7 @@ "sourceMap": true, "outDir": "./dist", "baseUrl": "./", + "types": ["node", "jest"], "incremental": true, "skipLibCheck": true, "strict": true, From 90ddc5345f3d200879fa4e40eb85707cb27190c3 Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Mon, 8 Jun 2026 03:43:04 +0100 Subject: [PATCH 040/389] docs(dev): document Redis local and deployment options --- README.md | 35 ++++++++++++++++++++++++++++++----- apps/backend/.env.example | 20 ++++++++++---------- 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 95194076..6809074c 100644 --- a/README.md +++ b/README.md @@ -86,12 +86,27 @@ Install dependencies: pnpm install ``` -Start local services: +Start local services when you want local infrastructure: ```bash -docker-compose up -d +docker compose up -d postgres redis ``` +Docker is optional for local development. The backend can run directly with +pnpm and connect either to managed/cloud Redis or to the local Docker Redis +service. + +Redis options for local development: + +- Managed/cloud Redis: set `REDIS_URL` to a Redis protocol URL such as + `rediss://...`. +- Local Docker Redis: run `docker compose up -d redis` and set + `REDIS_URL=redis://localhost:6379`. + +`docker compose up -d redis` starts Redis infrastructure only. It does not start +the backend dev server because the compose file does not define a backend app +service. + Set up environment files: - Copy `apps/backend/.env.example` to `apps/backend/.env`. @@ -107,6 +122,16 @@ pnpm exec prisma db seed Run the apps: +```bash +cd apps/backend +pnpm run dev + +cd ../web +pnpm run dev +``` + +Or from the repo root: + ```bash pnpm --filter @twizrr/backend dev pnpm --filter @twizrr/web dev @@ -195,8 +220,8 @@ Render dev backend environment: - `DATABASE_URL` = Neon pooled dev database URL. - `DIRECT_URL` = Neon direct dev database URL for Prisma migrations. -- `REDIS_URL` = TCP Redis URL (`redis://` or `rediss://`), not the Upstash REST - HTTPS URL. +- `REDIS_URL` = managed Redis protocol URL (`redis://` or `rediss://`), not + `redis://localhost:6379` and not the Upstash REST HTTPS URL. - `JWT_ACCESS_SECRET` and `JWT_REFRESH_SECRET`, or the currently supported JWT fallback secret names. - `PAYSTACK_SECRET_KEY`, `PAYSTACK_PUBLIC_KEY`, and `PAYSTACK_WEBHOOK_SECRET`. @@ -212,7 +237,7 @@ Health check expectations: - Render uses `/health` for backend readiness. - `/health` checks both database and Redis availability. - The Render dev service will fail readiness if `DATABASE_URL`, `DIRECT_URL`, or - TCP `REDIS_URL` is wrong. + managed Redis `REDIS_URL` is wrong or unreachable. - Keep `/health` as the dev Render health check unless the backend intentionally separates liveness from dependency readiness later. diff --git a/apps/backend/.env.example b/apps/backend/.env.example index ec11d6e5..b15ee375 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -20,18 +20,18 @@ DATABASE_URL= # Never use this in application code DIRECT_URL= -# ── REDIS (Upstash) ─────────────────────────── -# REDIS_URL must be a TCP Redis URL (rediss:// preferred for TLS). -# For Upstash, copy the "Redis Connect URL" from the dashboard, NOT the -# REST URL. BullMQ workers require a TCP connection and will refuse to -# start if REDIS_URL is set to an https://*.upstash.io REST endpoint. -# Format: rediss://default:@.upstash.io:6379 +# ── REDIS (Queues + cache) ──────────────────── +# REDIS_URL must be a Redis protocol URL. BullMQ workers require TCP Redis. +# Local Docker Redis: +# redis://localhost:6379 +# Managed/cloud Redis (Upstash TCP, Redis Cloud, Render Redis, etc.): +# rediss://default:@:6379 +# Do NOT use an Upstash REST HTTPS URL for REDIS_URL. REDIS_URL= -# REDIS_TOKEN is only consumed when REDIS_URL is an Upstash REST URL -# (https://). For the TCP format above the password lives in the URL -# itself and this variable is unused. Safe to leave blank when using TCP. +# Legacy/optional REST helper token. Not used by the main supported queue/cache +# flow when REDIS_URL is redis:// or rediss://. Safe to leave blank. REDIS_TOKEN= -# Only used by the optional REST client path (above). Ignored for TCP. +# Only used by the optional REST helper path. Ignored for TCP Redis. REDIS_REQUEST_TIMEOUT_MS=5000 # ── AUTH (JWT) ──────────────────────────────── From cc586c239d890099d4216722aeb1a76427f57036 Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Mon, 8 Jun 2026 04:54:01 +0100 Subject: [PATCH 041/389] chore(redis): remove legacy Upstash REST path --- AGENTS.md | 5 +- TWIZRR_DEVELOPMENT_RULES.md | 2 +- apps/backend/.env.example | 5 - .../src/common/config/env.validation.ts | 14 +- apps/backend/src/core/config/redis.config.ts | 1 - .../src/core/redis/redis.module.spec.ts | 98 +++++++++++ apps/backend/src/core/redis/redis.module.ts | 164 +----------------- 7 files changed, 112 insertions(+), 177 deletions(-) create mode 100644 apps/backend/src/core/redis/redis.module.spec.ts diff --git a/AGENTS.md b/AGENTS.md index e91fdd1a..5d9b8038 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,7 +144,7 @@ twizrr/ # monorepo root │ │ │ ├── core/ # PLATFORM INFRASTRUCTURE — no business logic │ │ │ │ ├── config/ # ConfigModule per service (app, db, redis, paystack...) │ │ │ │ ├── prisma/ # PrismaService (extends PrismaClient) -│ │ │ │ ├── redis/ # RedisService wrapper (Upstash) +│ │ │ │ ├── redis/ # RedisService wrapper (Redis protocol) │ │ │ │ ├── logger/ # nestjs-pino structured logging │ │ │ │ ├── throttler/ # ThrottlerModule setup (rate limiting) │ │ │ │ ├── health/ # GET /health — UptimeRobot pings this @@ -446,8 +446,7 @@ DATABASE_URL= # Neon pooled connection (app queries via PgBouncer) DIRECT_URL= # Neon direct connection (migrations only — never for app) # REDIS -REDIS_URL= # Upstash Redis URL (BullMQ + caching + rate limiting) -REDIS_TOKEN= # Upstash Redis token +REDIS_URL= # Redis protocol URL (redis:// or rediss://) for BullMQ + caching + rate limiting # AUTH JWT_SECRET= # JWT signing secret — rotate if exposed diff --git a/TWIZRR_DEVELOPMENT_RULES.md b/TWIZRR_DEVELOPMENT_RULES.md index 6cfcf53d..cb3a2647 100644 --- a/TWIZRR_DEVELOPMENT_RULES.md +++ b/TWIZRR_DEVELOPMENT_RULES.md @@ -611,7 +611,7 @@ INFRASTRUCTURE: ├── PORT 4000 (local) ├── DATABASE_URL Neon POOLED connection string ├── DIRECT_URL Neon DIRECT connection string (migrations only) -├── REDIS_URL Upstash Redis connection string +├── REDIS_URL Redis protocol URL (redis:// or rediss://) └── FRONTEND_URL https://twizrr.com (or env-specific) SECURITY & AUTH: diff --git a/apps/backend/.env.example b/apps/backend/.env.example index b15ee375..6d45686a 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -28,11 +28,6 @@ DIRECT_URL= # rediss://default:@:6379 # Do NOT use an Upstash REST HTTPS URL for REDIS_URL. REDIS_URL= -# Legacy/optional REST helper token. Not used by the main supported queue/cache -# flow when REDIS_URL is redis:// or rediss://. Safe to leave blank. -REDIS_TOKEN= -# Only used by the optional REST helper path. Ignored for TCP Redis. -REDIS_REQUEST_TIMEOUT_MS=5000 # ── AUTH (JWT) ──────────────────────────────── # Generate with: openssl rand -hex 32 or node -e "console.log(require('crypto').randomBytes(48).toString('hex'))" diff --git a/apps/backend/src/common/config/env.validation.ts b/apps/backend/src/common/config/env.validation.ts index 68f320c5..5b3fa3ff 100644 --- a/apps/backend/src/common/config/env.validation.ts +++ b/apps/backend/src/common/config/env.validation.ts @@ -2,10 +2,6 @@ import * as Joi from "joi"; const optionalString = Joi.string().empty("").optional(); const optionalUri = Joi.string().empty("").uri().optional(); -const optionalIntegerString = Joi.string() - .empty("") - .pattern(/^\d+$/) - .optional(); const optionalNumericString = Joi.string() .empty("") .pattern(/^\d+(\.\d+)?$/) @@ -17,6 +13,12 @@ const optionalPositiveInteger = Joi.number() .optional(); const optionalPositiveNumber = Joi.number().positive().empty("").optional(); const optionalSecret = Joi.string().empty("").min(1).optional(); +const redisUrl = Joi.string() + .pattern(/^rediss?:\/\//i) + .required() + .messages({ + "string.pattern.base": "REDIS_URL must start with redis:// or rediss://", + }); export const envValidationSchema = Joi.object({ NODE_ENV: Joi.string() @@ -29,8 +31,7 @@ export const envValidationSchema = Joi.object({ DATABASE_URL: Joi.string().required(), DIRECT_URL: Joi.string().required(), - REDIS_URL: Joi.string().required(), - REDIS_TOKEN: optionalString, + REDIS_URL: redisUrl, JWT_SECRET: optionalSecret, JWT_ACCESS_SECRET: optionalSecret, @@ -88,7 +89,6 @@ export const envValidationSchema = Joi.object({ PLATFORM_FEE_TIER_1: optionalNumericString, PLATFORM_FEE_TIER_2: optionalNumericString, PLATFORM_FEE_TIER_3: optionalNumericString, - REDIS_REQUEST_TIMEOUT_MS: optionalIntegerString, AUTO_CONFIRM_HOURS: optionalString, DISPUTE_WINDOW_HOURS: optionalString, OTP_TTL_MINUTES: optionalString, diff --git a/apps/backend/src/core/config/redis.config.ts b/apps/backend/src/core/config/redis.config.ts index d5a29d7c..b10853de 100644 --- a/apps/backend/src/core/config/redis.config.ts +++ b/apps/backend/src/core/config/redis.config.ts @@ -2,5 +2,4 @@ import { registerAs } from "@nestjs/config"; export default registerAs("redis", () => ({ url: process.env.REDIS_URL, - token: process.env.REDIS_TOKEN, })); diff --git a/apps/backend/src/core/redis/redis.module.spec.ts b/apps/backend/src/core/redis/redis.module.spec.ts new file mode 100644 index 00000000..9ded14e9 --- /dev/null +++ b/apps/backend/src/core/redis/redis.module.spec.ts @@ -0,0 +1,98 @@ +import { ConfigModule } from "@nestjs/config"; +import { Test } from "@nestjs/testing"; +import Redis from "ioredis"; + +import redisConfig from "../config/redis.config"; +import { REDIS_CLIENT, RedisModule } from "./redis.module"; + +const redisInstance = { + on: jest.fn(), + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + incr: jest.fn(), + expire: jest.fn(), + ping: jest.fn(), + exists: jest.fn(), + hincrby: jest.fn(), + hgetall: jest.fn(), + keys: jest.fn(), + disconnect: jest.fn(), +}; + +jest.mock("ioredis", () => jest.fn().mockImplementation(() => redisInstance)); + +describe("RedisModule", () => { + const compileWithRedisUrl = async (redisUrl: string | undefined) => { + const previousRedisUrl = process.env.REDIS_URL; + if (redisUrl) { + process.env.REDIS_URL = redisUrl; + } else { + delete process.env.REDIS_URL; + } + + try { + return await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + ignoreEnvFile: true, + isGlobal: true, + load: [redisConfig], + }), + RedisModule, + ], + }).compile(); + } finally { + if (previousRedisUrl) { + process.env.REDIS_URL = previousRedisUrl; + } else { + delete process.env.REDIS_URL; + } + } + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("accepts redis protocol URLs", async () => { + const moduleRef = await compileWithRedisUrl("redis://localhost:6379"); + + expect(moduleRef.get(REDIS_CLIENT)).toBeDefined(); + expect(Redis).toHaveBeenCalledWith( + "redis://localhost:6379", + expect.objectContaining({ tls: undefined }), + ); + + await moduleRef.close(); + }); + + it("accepts rediss protocol URLs", async () => { + const moduleRef = await compileWithRedisUrl( + "rediss://default:pass@example.com:6379", + ); + + expect(moduleRef.get(REDIS_CLIENT)).toBeDefined(); + expect(Redis).toHaveBeenCalledWith( + "rediss://default:pass@example.com:6379", + expect.objectContaining({ tls: {} }), + ); + + await moduleRef.close(); + }); + + it("rejects HTTPS Redis REST URLs", async () => { + await expect( + compileWithRedisUrl("https://example.upstash.io"), + ).rejects.toThrow("Invalid REDIS_URL for RedisModule"); + expect(Redis).not.toHaveBeenCalled(); + }); + + it("does not require a REST token for Redis protocol URLs", async () => { + const moduleRef = await compileWithRedisUrl("redis://localhost:6379"); + + expect(moduleRef.get(REDIS_CLIENT)).toBeDefined(); + + await moduleRef.close(); + }); +}); diff --git a/apps/backend/src/core/redis/redis.module.ts b/apps/backend/src/core/redis/redis.module.ts index fb1c1fc7..d78269bf 100644 --- a/apps/backend/src/core/redis/redis.module.ts +++ b/apps/backend/src/core/redis/redis.module.ts @@ -27,151 +27,6 @@ function sanitizeRedisUrl(url: string | undefined): string | undefined { return cleanUrl.trim(); } -type UpstashResult = { - result?: T; - error?: string; -}; - -class UpstashRestRedisClient implements RedisClient { - constructor( - private readonly url: string, - private readonly token: string, - private readonly timeoutMs = 5000, - ) {} - - async get(key: string): Promise { - return this.command(["GET", key]); - } - - async set( - key: string, - value: string, - ttlSeconds?: number, - onlyIfNotExists = false, - ): Promise { - const args: Array = ["SET", key, value]; - - if (typeof ttlSeconds === "number") { - args.push("EX", ttlSeconds); - } - - if (onlyIfNotExists) { - args.push("NX"); - } - - return (await this.command(args)) === "OK"; - } - - async del(key: string): Promise { - return this.command(["DEL", key]); - } - - async incr(key: string): Promise { - return this.command(["INCR", key]); - } - - async expire(key: string, ttlSeconds: number): Promise { - return (await this.command(["EXPIRE", key, ttlSeconds])) === 1; - } - - async ping(): Promise<"PONG"> { - const response = await this.command(["PING"]); - - if (response !== "PONG") { - throw new Error("Redis ping failed"); - } - - return "PONG"; - } - - async exists(key: string): Promise { - return this.command(["EXISTS", key]); - } - - async hIncrBy( - key: string, - field: string, - increment: number, - ): Promise { - return this.command(["HINCRBY", key, field, increment]); - } - - async hGetAll(key: string): Promise> { - const result = await this.command(["HGETALL", key]); - - if (Array.isArray(result)) { - return result.reduce>( - (acc, item, index, array) => { - if (index % 2 === 0 && typeof item === "string") { - const value = array[index + 1]; - acc[item] = typeof value === "string" ? value : String(value ?? ""); - } - - return acc; - }, - {}, - ); - } - - if (result && typeof result === "object") { - return Object.entries(result).reduce>( - (acc, [keyName, value]) => { - acc[keyName] = typeof value === "string" ? value : String(value); - return acc; - }, - {}, - ); - } - - return {}; - } - - async keys(pattern: string): Promise { - const result = await this.command(["KEYS", pattern]); - return Array.isArray(result) - ? result.filter((key): key is string => typeof key === "string") - : []; - } - - private async command(args: Array): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), this.timeoutMs); - let response: Response; - - try { - response = await fetch(this.url, { - method: "POST", - headers: { - Authorization: `Bearer ${this.token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(args), - signal: controller.signal, - }); - } catch (error: unknown) { - if (error instanceof Error && error.name === "AbortError") { - throw new Error("Upstash request timed out"); - } - - throw error; - } finally { - clearTimeout(timeout); - } - - if (!response.ok) { - throw new Error(`Upstash Redis command failed with ${response.status}`); - } - - const payload = (await response.json()) as UpstashResult; - - if (payload.error) { - throw new Error(payload.error); - } - - return payload.result as T; - } -} - class IoredisClient implements RedisClient { constructor(private readonly redis: Redis) {} @@ -256,11 +111,6 @@ class IoredisClient implements RedisClient { const redisUrl = sanitizeRedisUrl( configService.get("redis.url"), ); - const redisToken = configService.get("redis.token"); - const redisRequestTimeoutMs = Number.parseInt( - configService.get("REDIS_REQUEST_TIMEOUT_MS") || "5000", - 10, - ); if (!redisUrl) { Logger.warn( @@ -275,22 +125,16 @@ class IoredisClient implements RedisClient { ); } - // RFC 3986 schemes are case-insensitive — match HTTP/HTTPS in + // RFC 3986 schemes are case-insensitive; match HTTP/HTTPS in // any casing so a `HTTPS://` URL isn't routed to the TCP client. if (/^https?:\/\//i.test(redisUrl)) { - if (!redisToken) { - throw new Error("REDIS_TOKEN is required for Upstash REST Redis"); - } - - return new UpstashRestRedisClient( - redisUrl, - redisToken, - Number.isNaN(redisRequestTimeoutMs) ? 5000 : redisRequestTimeoutMs, + throw new Error( + "Invalid REDIS_URL for RedisModule: must use redis:// or rediss:// (TCP), not https:// (REST).", ); } const redis = new Redis(redisUrl, { - tls: redisUrl.startsWith("rediss://") ? {} : undefined, + tls: /^rediss:\/\//i.test(redisUrl) ? {} : undefined, family: 0, maxRetriesPerRequest: null, enableReadyCheck: false, From 33bcee7a0c29b796224f7c9bf902febdc7f871d0 Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Mon, 8 Jun 2026 07:03:04 +0100 Subject: [PATCH 042/389] feat(auth): add provider account schema --- .../migration.sql | 29 +++++++++++++++++++ apps/backend/prisma/schema.prisma | 24 +++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 apps/backend/prisma/migrations/20260608120000_add_auth_provider_accounts/migration.sql diff --git a/apps/backend/prisma/migrations/20260608120000_add_auth_provider_accounts/migration.sql b/apps/backend/prisma/migrations/20260608120000_add_auth_provider_accounts/migration.sql new file mode 100644 index 00000000..db274e01 --- /dev/null +++ b/apps/backend/prisma/migrations/20260608120000_add_auth_provider_accounts/migration.sql @@ -0,0 +1,29 @@ +CREATE TYPE "AuthProvider" AS ENUM ('GOOGLE'); + +CREATE TABLE "auth_provider_accounts" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "provider" "AuthProvider" NOT NULL, + "provider_user_id" TEXT NOT NULL, + "provider_email" TEXT NOT NULL, + "provider_email_verified" BOOLEAN NOT NULL DEFAULT false, + "provider_avatar_url" TEXT, + "linked_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "auth_provider_accounts_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "auth_provider_accounts_provider_provider_user_id_key" +ON "auth_provider_accounts"("provider", "provider_user_id"); + +CREATE UNIQUE INDEX "auth_provider_accounts_user_id_provider_key" +ON "auth_provider_accounts"("user_id", "provider"); + +CREATE INDEX "auth_provider_accounts_provider_email_idx" +ON "auth_provider_accounts"("provider_email"); + +ALTER TABLE "auth_provider_accounts" +ADD CONSTRAINT "auth_provider_accounts_user_id_fkey" +FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index c9a242e0..563f0b65 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -594,6 +594,7 @@ model User { disputeEvidence DisputeEvidence[] @relation("DisputeEvidenceActor") staffAccessTokens StaffAccessToken[] reviewedVerificationRequests VerificationRequest[] @relation("VerificationRequestsReviewed") + authProviderAccounts AuthProviderAccount[] whatsappLink WhatsAppLink? sessions Session[] otpCodes OTPCode[] @@ -616,6 +617,25 @@ model User { @@map("users") } +model AuthProviderAccount { + id String @id @default(cuid()) + userId String @map("user_id") + provider AuthProvider + providerUserId String @map("provider_user_id") + providerEmail String @map("provider_email") + providerEmailVerified Boolean @default(false) @map("provider_email_verified") + providerAvatarUrl String? @map("provider_avatar_url") + linkedAt DateTime @default(now()) @map("linked_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerUserId]) + @@unique([userId, provider]) + @@index([providerEmail]) + @@map("auth_provider_accounts") +} + model ProductAssociation { id String @id @default(cuid()) productCategoryA String @map("product_category_a") @@ -1450,6 +1470,10 @@ enum PayoutStatus { CANCELLED } +enum AuthProvider { + GOOGLE +} + enum UserRole { USER SUPER_ADMIN From 7064315a450a1100da04b08dac22baf99d9410a3 Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Mon, 8 Jun 2026 07:36:11 +0100 Subject: [PATCH 043/389] fix(auth): align provider account boolean naming --- .../20260608120000_add_auth_provider_accounts/migration.sql | 2 +- apps/backend/prisma/schema.prisma | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/backend/prisma/migrations/20260608120000_add_auth_provider_accounts/migration.sql b/apps/backend/prisma/migrations/20260608120000_add_auth_provider_accounts/migration.sql index db274e01..b5a5f3f1 100644 --- a/apps/backend/prisma/migrations/20260608120000_add_auth_provider_accounts/migration.sql +++ b/apps/backend/prisma/migrations/20260608120000_add_auth_provider_accounts/migration.sql @@ -6,7 +6,7 @@ CREATE TABLE "auth_provider_accounts" ( "provider" "AuthProvider" NOT NULL, "provider_user_id" TEXT NOT NULL, "provider_email" TEXT NOT NULL, - "provider_email_verified" BOOLEAN NOT NULL DEFAULT false, + "is_provider_email_verified" BOOLEAN NOT NULL DEFAULT false, "provider_avatar_url" TEXT, "linked_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 563f0b65..eeba1bcf 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -623,7 +623,7 @@ model AuthProviderAccount { provider AuthProvider providerUserId String @map("provider_user_id") providerEmail String @map("provider_email") - providerEmailVerified Boolean @default(false) @map("provider_email_verified") + isProviderEmailVerified Boolean @default(false) @map("is_provider_email_verified") providerAvatarUrl String? @map("provider_avatar_url") linkedAt DateTime @default(now()) @map("linked_at") createdAt DateTime @default(now()) @map("created_at") From f7fa488bed836037a3264bee2411953ff9629653 Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Mon, 8 Jun 2026 08:30:48 +0100 Subject: [PATCH 044/389] feat(auth): add Google OAuth backend flow --- apps/backend/.env.example | 9 + .../src/common/config/env.validation.ts | 5 + .../src/domains/users/auth/auth.controller.ts | 91 +++++ .../src/domains/users/auth/auth.module.ts | 2 + .../users/auth/auth.service.google.spec.ts | 244 ++++++++++++ .../src/domains/users/auth/auth.service.ts | 349 +++++++++++++++++- .../google-oauth/google-oauth.client.ts | 108 ++++++ .../google-oauth/google-oauth.module.ts | 9 + .../google-oauth/google-oauth.types.ts | 29 ++ 9 files changed, 845 insertions(+), 1 deletion(-) create mode 100644 apps/backend/src/domains/users/auth/auth.service.google.spec.ts create mode 100644 apps/backend/src/integrations/google-oauth/google-oauth.client.ts create mode 100644 apps/backend/src/integrations/google-oauth/google-oauth.module.ts create mode 100644 apps/backend/src/integrations/google-oauth/google-oauth.types.ts diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 6d45686a..cd952f22 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -109,6 +109,15 @@ PREMBLY_BASE_URL=https://api.prembly.com GOOGLE_PLACES_API_KEY= GOOGLE_PLACES_BASE_URL=https://places.googleapis.com/v1 +# ── GOOGLE OAUTH (Backend redirect flow) ───── +# Google verifies email only. Twizrr onboarding still collects first name, +# last name, date of birth, and verified phone before account completion. +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_OAUTH_REDIRECT_URL=http://localhost:4000/auth/google/callback +GOOGLE_OAUTH_STATE_TTL_SECONDS=600 +GOOGLE_PENDING_ONBOARDING_TTL_SECONDS=900 + # ── ADMIN BOOTSTRAP ────────────────────────── # Used by prisma/seed.ts to create the first admin user # Only needed locally and in CI — not in production env diff --git a/apps/backend/src/common/config/env.validation.ts b/apps/backend/src/common/config/env.validation.ts index 5b3fa3ff..64109f81 100644 --- a/apps/backend/src/common/config/env.validation.ts +++ b/apps/backend/src/common/config/env.validation.ts @@ -85,6 +85,11 @@ export const envValidationSchema = Joi.object({ PREMBLY_BASE_URL: optionalUri, GOOGLE_PLACES_API_KEY: optionalString, GOOGLE_PLACES_BASE_URL: optionalUri, + GOOGLE_CLIENT_ID: optionalString, + GOOGLE_CLIENT_SECRET: optionalSecret, + GOOGLE_OAUTH_REDIRECT_URL: optionalUri, + GOOGLE_OAUTH_STATE_TTL_SECONDS: optionalPositiveInteger, + GOOGLE_PENDING_ONBOARDING_TTL_SECONDS: optionalPositiveInteger, PLATFORM_FEE_TIER_1: optionalNumericString, PLATFORM_FEE_TIER_2: optionalNumericString, diff --git a/apps/backend/src/domains/users/auth/auth.controller.ts b/apps/backend/src/domains/users/auth/auth.controller.ts index dabcfd9c..4c1c48a1 100644 --- a/apps/backend/src/domains/users/auth/auth.controller.ts +++ b/apps/backend/src/domains/users/auth/auth.controller.ts @@ -1,9 +1,11 @@ import { Body, Controller, + Get, HttpCode, HttpStatus, Post, + Query, Req, Res, UseGuards, @@ -76,6 +78,32 @@ export class AuthController { }); } + private setGoogleStateCookie( + response: Response, + cookieName: string, + state: string, + maxAgeMs: number, + ): void { + const isLocal = process.env.NODE_ENV === "development"; + response.cookie(cookieName, state, { + httpOnly: true, + secure: !isLocal, + sameSite: isLocal ? "lax" : "none", + maxAge: maxAgeMs, + path: "/auth/google", + }); + } + + private clearGoogleStateCookie(response: Response, cookieName: string): void { + const isLocal = process.env.NODE_ENV === "development"; + response.clearCookie(cookieName, { + httpOnly: true, + secure: !isLocal, + sameSite: isLocal ? "lax" : "none", + path: "/auth/google", + }); + } + private getSessionMetadata(request: Request): SessionMetadata { return { userAgent: request.headers["user-agent"], @@ -93,6 +121,69 @@ export class AuthController { return cookies?.[AUTH_COOKIE.REFRESH]; } + private getCookie(request: Request, cookieName: string): string | undefined { + const cookies = ( + request as Request & { + cookies?: Record; + } + ).cookies; + + return cookies?.[cookieName]; + } + + private getWebRedirectUrl(path: string): string { + const webUrl = process.env.WEB_URL || "http://localhost:3000"; + return new URL(path, webUrl).toString(); + } + + @Get("google") + async startGoogle( + @Query("redirect") redirectTo: string | undefined, + @Res() response: Response, + ) { + const result = await this.authService.startGoogleOAuth(redirectTo); + this.setGoogleStateCookie( + response, + result.cookieName, + result.state, + result.cookieMaxAgeMs, + ); + + return response.redirect(result.authorizationUrl); + } + + @Get("google/callback") + async googleCallback( + @Query("code") code: string | undefined, + @Query("state") state: string | undefined, + @Req() request: Request, + @Res() response: Response, + ) { + const cookieName = "twizrr_google_oauth_state"; + + try { + const result = await this.authService.completeGoogleOAuth({ + code, + state, + cookieState: this.getCookie(request, cookieName), + metadata: this.getSessionMetadata(request), + }); + + this.clearGoogleStateCookie(response, cookieName); + + if (result.kind === "login") { + this.setAuthCookies(response, result); + } + + return response.redirect(this.getWebRedirectUrl(result.redirectTo)); + } catch { + this.clearGoogleStateCookie(response, cookieName); + return response.redirect( + this.getWebRedirectUrl("/login?error=google_sign_in_failed"), + ); + } + } + @Throttle({ default: { limit: 5, ttl: 3_600_000 } }) @Post("register/email") async registerEmail(@Body() dto: RegisterEmailDto) { diff --git a/apps/backend/src/domains/users/auth/auth.module.ts b/apps/backend/src/domains/users/auth/auth.module.ts index 3fbeff07..7716e74a 100644 --- a/apps/backend/src/domains/users/auth/auth.module.ts +++ b/apps/backend/src/domains/users/auth/auth.module.ts @@ -8,6 +8,7 @@ import { import { PassportModule } from "@nestjs/passport"; import { AfricasTalkingModule } from "../../../integrations/africastalking/africastalking.module"; +import { GoogleOAuthModule } from "../../../integrations/google-oauth/google-oauth.module"; import { ResendModule } from "../../../integrations/resend/resend.module"; import { VerificationModule } from "../../../modules/verification/verification.module"; import { AuthController } from "./auth.controller"; @@ -34,6 +35,7 @@ const jwtTtl = (configService: ConfigService, key: string): JwtExpiresIn => }), ResendModule, AfricasTalkingModule, + GoogleOAuthModule, forwardRef(() => VerificationModule), ], controllers: [AuthController], diff --git a/apps/backend/src/domains/users/auth/auth.service.google.spec.ts b/apps/backend/src/domains/users/auth/auth.service.google.spec.ts new file mode 100644 index 00000000..b1dce3c3 --- /dev/null +++ b/apps/backend/src/domains/users/auth/auth.service.google.spec.ts @@ -0,0 +1,244 @@ +import { BadRequestException, ConflictException } from "@nestjs/common"; +import { AuthProvider, UserRole } from "@prisma/client"; + +import { AuthService } from "./auth.service"; + +describe("AuthService Google OAuth", () => { + const prisma = { + authProviderAccount: { + findUnique: jest.fn(), + findFirst: jest.fn(), + create: jest.fn(), + }, + user: { + findFirst: jest.fn(), + update: jest.fn(), + create: jest.fn(), + }, + session: { + create: jest.fn(), + }, + }; + const redis = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + }; + const googleOAuthClient = { + getAuthorizationUrl: jest.fn(), + getProfileFromCode: jest.fn(), + }; + const jwtService = { + signAsync: jest.fn(), + }; + + let service: AuthService; + + beforeEach(() => { + jest.clearAllMocks(); + service = Object.create(AuthService.prototype) as AuthService; + Object.assign(service as any, { + prisma, + redis, + googleOAuthClient, + jwtService, + accessTokenSecret: "access-secret", + refreshTokenSecret: "refresh-secret", + googleOAuthStateTtlSeconds: 600, + googlePendingOnboardingTtlSeconds: 900, + appWebUrl: "https://twizrr.test", + }); + + redis.set.mockResolvedValue(true); + redis.get.mockResolvedValue( + JSON.stringify({ redirectTo: "/buyer/orders", createdAt: 1 }), + ); + redis.del.mockResolvedValue(1); + googleOAuthClient.getAuthorizationUrl.mockReturnValue( + "https://accounts.google.com/o/oauth2/v2/auth?state=state", + ); + googleOAuthClient.getProfileFromCode.mockResolvedValue({ + sub: "google-sub-1", + email: "buyer@example.com", + emailVerified: true, + name: "Buyer One", + givenName: "Buyer", + familyName: "One", + picture: "https://lh3.googleusercontent.com/avatar", + }); + jwtService.signAsync + .mockResolvedValueOnce("access-token") + .mockResolvedValueOnce("refresh-token"); + prisma.authProviderAccount.findUnique.mockResolvedValue(null); + prisma.authProviderAccount.findFirst.mockResolvedValue(null); + prisma.user.findFirst.mockResolvedValue(null); + prisma.session.create.mockResolvedValue({}); + }); + + it("starts Google OAuth with stored state and rejects unsafe redirects", async () => { + const started = await (service as any).startGoogleOAuth("/buyer/orders"); + + expect(started.authorizationUrl).toContain("accounts.google.com"); + expect(started.state).toEqual(expect.any(String)); + expect(redis.set).toHaveBeenCalledWith( + expect.stringMatching(/^auth:google:state:/), + expect.stringContaining('"/buyer/orders"'), + 600, + true, + ); + await expect( + (service as any).startGoogleOAuth("https://evil.example"), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("rejects callback when state is missing or mismatched", async () => { + await expect( + (service as any).completeGoogleOAuth({ + code: "code", + state: "returned-state", + cookieState: "cookie-state", + metadata: {}, + }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(googleOAuthClient.getProfileFromCode).not.toHaveBeenCalled(); + }); + + it("logs in an existing linked Google provider without changing phone verification", async () => { + prisma.authProviderAccount.findUnique.mockResolvedValue({ + user: { + id: "user-1", + email: "buyer@example.com", + role: UserRole.USER, + phoneVerified: false, + isActive: true, + deletedAt: null, + storeProfile: null, + }, + }); + + const result = await (service as any).completeGoogleOAuth({ + code: "code", + state: "state", + cookieState: "state", + metadata: { userAgent: "test-agent", ipAddress: "127.0.0.1" }, + }); + + expect(result.kind).toBe("login"); + expect(result).toEqual( + expect.objectContaining({ + accessToken: "access-token", + refreshToken: "refresh-token", + }), + ); + expect(prisma.user.update).not.toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ phoneVerified: true }), + }), + ); + }); + + it("links a verified Google email to an existing user without bypassing onboarding", async () => { + prisma.user.findFirst.mockResolvedValue({ + id: "user-1", + email: "buyer@example.com", + role: UserRole.USER, + emailVerified: false, + phoneVerified: false, + dateOfBirth: null, + storeProfile: null, + }); + + const result = await (service as any).completeGoogleOAuth({ + code: "code", + state: "state", + cookieState: "state", + metadata: {}, + }); + + expect(prisma.authProviderAccount.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: "user-1", + provider: AuthProvider.GOOGLE, + providerUserId: "google-sub-1", + providerEmail: "buyer@example.com", + isProviderEmailVerified: true, + }), + }); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { emailVerified: true }, + }); + expect(prisma.user.update).not.toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ phoneVerified: true }), + }), + ); + expect(result.kind).toBe("login"); + }); + + it("creates a pending onboarding session for new Google users instead of a User", async () => { + const result = await (service as any).completeGoogleOAuth({ + code: "code", + state: "state", + cookieState: "state", + metadata: {}, + }); + + expect(result.kind).toBe("pending_onboarding"); + expect(result.redirectTo).toMatch( + /^\/register\/google\/complete\?session=/, + ); + expect(prisma.user.create).not.toHaveBeenCalled(); + expect(redis.set).toHaveBeenCalledWith( + expect.stringMatching(/^auth:google:onboarding:/), + expect.stringContaining('"givenName":"Buyer"'), + 900, + true, + ); + }); + + it("rejects unverified Google email and provider conflicts", async () => { + googleOAuthClient.getProfileFromCode.mockResolvedValueOnce({ + sub: "google-sub-1", + email: "buyer@example.com", + emailVerified: false, + }); + + await expect( + (service as any).completeGoogleOAuth({ + code: "code", + state: "state", + cookieState: "state", + metadata: {}, + }), + ).rejects.toBeInstanceOf(BadRequestException); + + googleOAuthClient.getProfileFromCode.mockResolvedValueOnce({ + sub: "google-sub-2", + email: "buyer@example.com", + emailVerified: true, + }); + prisma.user.findFirst.mockResolvedValueOnce({ + id: "user-1", + email: "buyer@example.com", + role: UserRole.USER, + emailVerified: true, + phoneVerified: true, + storeProfile: null, + }); + prisma.authProviderAccount.findFirst.mockResolvedValueOnce({ + userId: "user-1", + providerUserId: "other-google-sub", + }); + + await expect( + (service as any).completeGoogleOAuth({ + code: "code", + state: "state", + cookieState: "state", + metadata: {}, + }), + ).rejects.toBeInstanceOf(ConflictException); + }); +}); diff --git a/apps/backend/src/domains/users/auth/auth.service.ts b/apps/backend/src/domains/users/auth/auth.service.ts index b82bce36..eae6f6df 100644 --- a/apps/backend/src/domains/users/auth/auth.service.ts +++ b/apps/backend/src/domains/users/auth/auth.service.ts @@ -11,7 +11,13 @@ import { } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { JwtService } from "@nestjs/jwt"; -import { OTPPurpose, Prisma, User, UserRole } from "@prisma/client"; +import { + AuthProvider, + OTPPurpose, + Prisma, + User, + UserRole, +} from "@prisma/client"; import * as bcrypt from "bcrypt"; import { createHash, @@ -23,7 +29,10 @@ import { } from "crypto"; import { PrismaService } from "../../../core/prisma/prisma.service"; +import { RedisService } from "../../../core/redis/redis.service"; import { AfricasTalkingClient } from "../../../integrations/africastalking/africastalking.client"; +import { GoogleOAuthClient } from "../../../integrations/google-oauth/google-oauth.client"; +import { GoogleOAuthProfile } from "../../../integrations/google-oauth/google-oauth.types"; import { ResendClient } from "../../../integrations/resend/resend.client"; import { VerificationService } from "../../../modules/verification/verification.service"; import { AUTH_TTL, REDIRECT_TARGET } from "./auth.constants"; @@ -50,6 +59,10 @@ const OTP_TTL_MS = OTP_TTL_MINUTES * 60 * 1000; const MINIMUM_REGISTRATION_AGE = 13; const MAX_OTP_ATTEMPTS = 5; const PASSWORD_RESET_TTL_MS = 15 * 60 * 1000; +const GOOGLE_OAUTH_STATE_TTL_SECONDS = 10 * 60; +const GOOGLE_PENDING_ONBOARDING_TTL_SECONDS = 15 * 60; +const GOOGLE_STATE_COOKIE = "twizrr_google_oauth_state"; +const GOOGLE_PENDING_ONBOARDING_PATH = "/register/google/complete"; const GENERIC_PASSWORD_RESET_MESSAGE = "If an account exists for that email, a password reset link has been sent."; const GENERIC_EMAIL_VERIFICATION_RESEND_MESSAGE = @@ -73,6 +86,24 @@ export interface AuthUserResponse { createdAt: string; } +export interface GoogleOAuthStartResult { + authorizationUrl: string; + state: string; + cookieName: string; + cookieMaxAgeMs: number; +} + +export type GoogleOAuthCompleteResult = + | (AuthCookieTokens & { + kind: "login"; + redirectTo: string; + }) + | { + kind: "pending_onboarding"; + redirectTo: string; + pendingSessionId: string; + }; + @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); @@ -80,12 +111,16 @@ export class AuthService { private readonly accessTokenSecret: string; private readonly refreshTokenSecret: string; private readonly appWebUrl: string; + private readonly googleOAuthStateTtlSeconds: number; + private readonly googlePendingOnboardingTtlSeconds: number; constructor( private readonly prisma: PrismaService, private readonly jwtService: JwtService, private readonly resendClient: ResendClient, private readonly africasTalkingClient: AfricasTalkingClient, + private readonly googleOAuthClient: GoogleOAuthClient, + private readonly redis: RedisService, configService: ConfigService, @Inject(forwardRef(() => VerificationService)) private readonly verificationService: VerificationService, @@ -108,6 +143,159 @@ export class AuthService { configService.get("app.webUrl") || configService.get("WEB_URL") || "http://localhost:3000"; + this.googleOAuthStateTtlSeconds = this.readPositiveIntegerConfig( + configService, + "GOOGLE_OAUTH_STATE_TTL_SECONDS", + GOOGLE_OAUTH_STATE_TTL_SECONDS, + ); + this.googlePendingOnboardingTtlSeconds = this.readPositiveIntegerConfig( + configService, + "GOOGLE_PENDING_ONBOARDING_TTL_SECONDS", + GOOGLE_PENDING_ONBOARDING_TTL_SECONDS, + ); + } + + async startGoogleOAuth(redirectTo?: string): Promise { + const safeRedirect = this.normalizeRedirectPath(redirectTo); + const state = randomBytes(32).toString("base64url"); + const stateStored = await this.redis.set( + this.googleStateKey(state), + JSON.stringify({ + redirectTo: safeRedirect, + createdAt: Date.now(), + }), + this.googleOAuthStateTtlSeconds, + true, + ); + + if (!stateStored) { + throw new ServiceUnavailableException({ + message: "Google sign-in could not be started", + code: "GOOGLE_OAUTH_STATE_NOT_STORED", + }); + } + + return { + authorizationUrl: this.googleOAuthClient.getAuthorizationUrl(state), + state, + cookieName: GOOGLE_STATE_COOKIE, + cookieMaxAgeMs: this.googleOAuthStateTtlSeconds * 1000, + }; + } + + async completeGoogleOAuth(params: { + code?: string; + state?: string; + cookieState?: string; + metadata: SessionMetadata; + }): Promise { + const statePayload = await this.consumeGoogleOAuthState( + params.state, + params.cookieState, + ); + + if (!params.code) { + throw new BadRequestException({ + message: "Google sign-in code is required", + code: "GOOGLE_OAUTH_CODE_REQUIRED", + }); + } + + const profile = await this.googleOAuthClient.getProfileFromCode( + params.code, + ); + + if (!profile.emailVerified) { + throw new BadRequestException({ + message: "Google email must be verified to continue", + code: "GOOGLE_EMAIL_UNVERIFIED", + }); + } + + const linkedAccount = await this.prisma.authProviderAccount.findUnique({ + where: { + provider_providerUserId: { + provider: AuthProvider.GOOGLE, + providerUserId: profile.sub, + }, + }, + include: { + user: { + include: { + storeProfile: { + select: { id: true }, + }, + }, + }, + }, + }); + + if (linkedAccount?.user) { + if (!linkedAccount.user.isActive || linkedAccount.user.deletedAt) { + throw new UnauthorizedException({ + message: "Google sign-in is not available for this account", + code: "GOOGLE_ACCOUNT_INACTIVE", + }); + } + + return this.loginGoogleUser(linkedAccount.user, params.metadata); + } + + const existingUser = await this.prisma.user.findFirst({ + where: { + email: profile.email, + isActive: true, + deletedAt: null, + }, + include: { + storeProfile: { + select: { id: true }, + }, + }, + }); + + if (!existingUser) { + return this.createPendingGoogleOnboardingSession( + profile, + statePayload.redirectTo, + ); + } + + const existingGoogleLink = await this.prisma.authProviderAccount.findFirst({ + where: { + userId: existingUser.id, + provider: AuthProvider.GOOGLE, + }, + select: { + providerUserId: true, + userId: true, + }, + }); + + if ( + existingGoogleLink && + existingGoogleLink.providerUserId !== profile.sub + ) { + throw new ConflictException({ + message: "This account is already linked to a different Google profile", + code: "GOOGLE_PROVIDER_CONFLICT", + }); + } + + if (!existingGoogleLink) { + await this.prisma.authProviderAccount.create({ + data: this.toGoogleProviderAccountCreateInput(existingUser.id, profile), + }); + } + + if (!existingUser.emailVerified) { + await this.prisma.user.update({ + where: { id: existingUser.id }, + data: { emailVerified: true }, + }); + } + + return this.loginGoogleUser(existingUser, params.metadata); } async registerEmail( @@ -513,6 +701,165 @@ export class AuthService { return { reset: true }; } + private async consumeGoogleOAuthState( + state: string | undefined, + cookieState: string | undefined, + ): Promise<{ redirectTo: string }> { + if (!state || !cookieState || state !== cookieState) { + throw new BadRequestException({ + message: "Google sign-in state is invalid", + code: "GOOGLE_OAUTH_STATE_INVALID", + }); + } + + const key = this.googleStateKey(state); + const rawState = await this.redis.get(key); + + if (!rawState) { + throw new BadRequestException({ + message: "Google sign-in state has expired", + code: "GOOGLE_OAUTH_STATE_EXPIRED", + }); + } + + await this.redis.del(key); + + try { + const parsed = JSON.parse(rawState) as { redirectTo?: unknown }; + return { + redirectTo: this.normalizeRedirectPath( + typeof parsed.redirectTo === "string" ? parsed.redirectTo : undefined, + ), + }; + } catch { + throw new BadRequestException({ + message: "Google sign-in state is invalid", + code: "GOOGLE_OAUTH_STATE_INVALID", + }); + } + } + + private async createPendingGoogleOnboardingSession( + profile: GoogleOAuthProfile, + redirectTo: string, + ): Promise { + const pendingSessionId = randomBytes(32).toString("base64url"); + const pendingPayload = { + provider: AuthProvider.GOOGLE, + providerUserId: profile.sub, + providerEmail: profile.email, + isProviderEmailVerified: profile.emailVerified, + providerAvatarUrl: profile.picture || null, + prefill: { + email: profile.email, + name: profile.name || null, + givenName: profile.givenName || null, + familyName: profile.familyName || null, + picture: profile.picture || null, + }, + redirectTo, + createdAt: new Date().toISOString(), + }; + + const stored = await this.redis.set( + this.googlePendingOnboardingKey(pendingSessionId), + JSON.stringify(pendingPayload), + this.googlePendingOnboardingTtlSeconds, + true, + ); + + if (!stored) { + throw new ServiceUnavailableException({ + message: "Google onboarding session could not be created", + code: "GOOGLE_ONBOARDING_SESSION_NOT_STORED", + }); + } + + return { + kind: "pending_onboarding", + pendingSessionId, + redirectTo: `${GOOGLE_PENDING_ONBOARDING_PATH}?session=${encodeURIComponent( + pendingSessionId, + )}`, + }; + } + + private toGoogleProviderAccountCreateInput( + userId: string, + profile: GoogleOAuthProfile, + ): Prisma.AuthProviderAccountUncheckedCreateInput { + return { + userId, + provider: AuthProvider.GOOGLE, + providerUserId: profile.sub, + providerEmail: profile.email, + isProviderEmailVerified: profile.emailVerified, + providerAvatarUrl: profile.picture, + }; + } + + private async loginGoogleUser( + user: User & { storeProfile?: { id: string } | null }, + metadata: SessionMetadata, + ): Promise { + const tokens = await this.createSessionTokens({ + user: { + id: user.id, + email: user.email, + role: user.role, + storeId: user.storeProfile?.id, + }, + metadata, + }); + + return { + kind: "login", + ...tokens, + redirectTo: user.storeProfile + ? REDIRECT_TARGET.STORE_DASHBOARD + : REDIRECT_TARGET.EXPLORE, + }; + } + + private normalizeRedirectPath(redirectTo: string | undefined): string { + if (!redirectTo) { + return REDIRECT_TARGET.EXPLORE; + } + + if ( + !redirectTo.startsWith("/") || + redirectTo.startsWith("//") || + redirectTo.includes("\\") + ) { + throw new BadRequestException({ + message: "Redirect path is not allowed", + code: "AUTH_REDIRECT_UNSAFE", + }); + } + + return redirectTo; + } + + private googleStateKey(state: string): string { + return `auth:google:state:${this.hashToken(state)}`; + } + + private googlePendingOnboardingKey(sessionId: string): string { + return `auth:google:onboarding:${this.hashToken(sessionId)}`; + } + + private readPositiveIntegerConfig( + configService: ConfigService, + key: string, + fallback: number, + ): number { + const value = configService.get(key); + const parsed = + typeof value === "number" ? value : Number.parseInt(value || "", 10); + + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; + } + private async assertUniqueRegistrationFields( email: string, phone: string, diff --git a/apps/backend/src/integrations/google-oauth/google-oauth.client.ts b/apps/backend/src/integrations/google-oauth/google-oauth.client.ts new file mode 100644 index 00000000..7a0229f8 --- /dev/null +++ b/apps/backend/src/integrations/google-oauth/google-oauth.client.ts @@ -0,0 +1,108 @@ +import { + Injectable, + ServiceUnavailableException, + UnauthorizedException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +import { + GoogleOAuthProfile, + GoogleTokenResponse, + GoogleUserInfoResponse, +} from "./google-oauth.types"; + +const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; +const GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"; +const GOOGLE_OAUTH_SCOPE = "openid email profile"; + +@Injectable() +export class GoogleOAuthClient { + private readonly clientId: string; + private readonly clientSecret: string; + private readonly redirectUrl: string; + + constructor(private readonly configService: ConfigService) { + this.clientId = this.configService.get("GOOGLE_CLIENT_ID") || ""; + this.clientSecret = + this.configService.get("GOOGLE_CLIENT_SECRET") || ""; + this.redirectUrl = + this.configService.get("GOOGLE_OAUTH_REDIRECT_URL") || ""; + } + + getAuthorizationUrl(state: string): string { + this.assertConfigured(); + + const query = new URLSearchParams({ + client_id: this.clientId, + redirect_uri: this.redirectUrl, + response_type: "code", + scope: GOOGLE_OAUTH_SCOPE, + state, + prompt: "select_account", + }); + + return `${GOOGLE_AUTH_URL}?${query.toString()}`; + } + + async getProfileFromCode(code: string): Promise { + this.assertConfigured(); + + const tokenResponse = await fetch(GOOGLE_TOKEN_URL, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + code, + client_id: this.clientId, + client_secret: this.clientSecret, + redirect_uri: this.redirectUrl, + grant_type: "authorization_code", + }), + }); + + const tokenData = (await tokenResponse.json()) as GoogleTokenResponse; + + if (!tokenResponse.ok || !tokenData.access_token) { + throw new UnauthorizedException({ + message: "Google sign-in could not be verified", + code: "GOOGLE_OAUTH_TOKEN_INVALID", + }); + } + + const profileResponse = await fetch(GOOGLE_USERINFO_URL, { + headers: { + authorization: `Bearer ${tokenData.access_token}`, + }, + }); + const profile = (await profileResponse.json()) as GoogleUserInfoResponse; + + if (!profileResponse.ok || !profile.sub || !profile.email) { + throw new UnauthorizedException({ + message: "Google profile could not be verified", + code: "GOOGLE_OAUTH_PROFILE_INVALID", + }); + } + + return { + sub: profile.sub, + email: profile.email.trim().toLowerCase(), + emailVerified: + profile.email_verified === true || profile.email_verified === "true", + name: profile.name, + givenName: profile.given_name, + familyName: profile.family_name, + picture: profile.picture, + }; + } + + private assertConfigured(): void { + if (!this.clientId || !this.clientSecret || !this.redirectUrl) { + throw new ServiceUnavailableException({ + message: "Google sign-in is not configured", + code: "GOOGLE_OAUTH_NOT_CONFIGURED", + }); + } + } +} diff --git a/apps/backend/src/integrations/google-oauth/google-oauth.module.ts b/apps/backend/src/integrations/google-oauth/google-oauth.module.ts new file mode 100644 index 00000000..7054ed0e --- /dev/null +++ b/apps/backend/src/integrations/google-oauth/google-oauth.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; + +import { GoogleOAuthClient } from "./google-oauth.client"; + +@Module({ + providers: [GoogleOAuthClient], + exports: [GoogleOAuthClient], +}) +export class GoogleOAuthModule {} diff --git a/apps/backend/src/integrations/google-oauth/google-oauth.types.ts b/apps/backend/src/integrations/google-oauth/google-oauth.types.ts new file mode 100644 index 00000000..7fccce08 --- /dev/null +++ b/apps/backend/src/integrations/google-oauth/google-oauth.types.ts @@ -0,0 +1,29 @@ +export interface GoogleOAuthProfile { + sub: string; + email: string; + emailVerified: boolean; + name?: string; + givenName?: string; + familyName?: string; + picture?: string; +} + +interface GoogleTokenResponseBase { + access_token?: string; + error?: string; + error_description?: string; +} + +export type GoogleTokenResponse = GoogleTokenResponseBase & { + [key: string]: unknown; +}; + +export type GoogleUserInfoResponse = { + sub?: string; + email?: string; + email_verified?: boolean | string; + name?: string; + given_name?: string; + family_name?: string; + picture?: string; +}; From 96a867e7828be20e74ac4bf4e5b673a6a6594f32 Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Mon, 8 Jun 2026 09:01:47 +0100 Subject: [PATCH 045/389] fix(auth): address Google OAuth review feedback --- apps/backend/src/core/redis/redis.module.ts | 10 +++ apps/backend/src/core/redis/redis.service.ts | 5 ++ .../src/domains/users/auth/auth.constants.ts | 2 + .../src/domains/users/auth/auth.controller.ts | 4 +- .../users/auth/auth.service.google.spec.ts | 34 ++++++-- .../src/domains/users/auth/auth.service.ts | 11 +-- .../google-oauth/google-oauth.client.ts | 86 ++++++++++++++----- 7 files changed, 115 insertions(+), 37 deletions(-) diff --git a/apps/backend/src/core/redis/redis.module.ts b/apps/backend/src/core/redis/redis.module.ts index d78269bf..729000ba 100644 --- a/apps/backend/src/core/redis/redis.module.ts +++ b/apps/backend/src/core/redis/redis.module.ts @@ -34,6 +34,16 @@ class IoredisClient implements RedisClient { return this.redis.get(key); } + async getDel(key: string): Promise { + const response = await this.redis.eval( + "local value = redis.call('GET', KEYS[1]); if value then redis.call('DEL', KEYS[1]); end; return value", + 1, + key, + ); + + return typeof response === "string" ? response : null; + } + async set( key: string, value: string, diff --git a/apps/backend/src/core/redis/redis.service.ts b/apps/backend/src/core/redis/redis.service.ts index 73b58d0a..1b88ca54 100644 --- a/apps/backend/src/core/redis/redis.service.ts +++ b/apps/backend/src/core/redis/redis.service.ts @@ -2,6 +2,7 @@ import { Inject, Injectable, OnModuleDestroy } from "@nestjs/common"; export interface RedisClient { get(key: string): Promise; + getDel(key: string): Promise; set( key: string, value: string, @@ -27,6 +28,10 @@ export class RedisService implements OnModuleDestroy { return this.redis.get(key); } + async getDel(key: string): Promise { + return this.redis.getDel(key); + } + async set( key: string, value: string, diff --git a/apps/backend/src/domains/users/auth/auth.constants.ts b/apps/backend/src/domains/users/auth/auth.constants.ts index 1caab7b5..101e638d 100644 --- a/apps/backend/src/domains/users/auth/auth.constants.ts +++ b/apps/backend/src/domains/users/auth/auth.constants.ts @@ -15,6 +15,8 @@ export const AUTH_STRATEGY = { REFRESH: "jwt-refresh", } as const; +export const GOOGLE_STATE_COOKIE = "twizrr_google_oauth_state"; + export const REDIRECT_TARGET = { STORE_DASHBOARD: "/store/dashboard", EXPLORE: "/explore", diff --git a/apps/backend/src/domains/users/auth/auth.controller.ts b/apps/backend/src/domains/users/auth/auth.controller.ts index 4c1c48a1..b958171f 100644 --- a/apps/backend/src/domains/users/auth/auth.controller.ts +++ b/apps/backend/src/domains/users/auth/auth.controller.ts @@ -14,7 +14,7 @@ import { Throttle } from "@nestjs/throttler"; import type { Request, Response } from "express"; import { JwtRefreshGuard } from "../../../common/guards/jwt-refresh.guard"; -import { AUTH_COOKIE, AUTH_TTL } from "./auth.constants"; +import { AUTH_COOKIE, AUTH_TTL, GOOGLE_STATE_COOKIE } from "./auth.constants"; import { AuthService } from "./auth.service"; import { RefreshRequestUser, SessionMetadata } from "./auth.types"; import { LoginDto } from "./dto/login.dto"; @@ -159,7 +159,7 @@ export class AuthController { @Req() request: Request, @Res() response: Response, ) { - const cookieName = "twizrr_google_oauth_state"; + const cookieName = GOOGLE_STATE_COOKIE; try { const result = await this.authService.completeGoogleOAuth({ diff --git a/apps/backend/src/domains/users/auth/auth.service.google.spec.ts b/apps/backend/src/domains/users/auth/auth.service.google.spec.ts index b1dce3c3..64f34fce 100644 --- a/apps/backend/src/domains/users/auth/auth.service.google.spec.ts +++ b/apps/backend/src/domains/users/auth/auth.service.google.spec.ts @@ -21,6 +21,7 @@ describe("AuthService Google OAuth", () => { }; const redis = { get: jest.fn(), + getDel: jest.fn(), set: jest.fn(), del: jest.fn(), }; @@ -32,12 +33,24 @@ describe("AuthService Google OAuth", () => { signAsync: jest.fn(), }; + type AuthServiceTestHarness = { + prisma: typeof prisma; + redis: typeof redis; + googleOAuthClient: typeof googleOAuthClient; + jwtService: typeof jwtService; + accessTokenSecret: string; + refreshTokenSecret: string; + googleOAuthStateTtlSeconds: number; + googlePendingOnboardingTtlSeconds: number; + appWebUrl: string; + }; + let service: AuthService; beforeEach(() => { jest.clearAllMocks(); service = Object.create(AuthService.prototype) as AuthService; - Object.assign(service as any, { + Object.assign(service as unknown as AuthServiceTestHarness, { prisma, redis, googleOAuthClient, @@ -50,6 +63,9 @@ describe("AuthService Google OAuth", () => { }); redis.set.mockResolvedValue(true); + redis.getDel.mockResolvedValue( + JSON.stringify({ redirectTo: "/buyer/orders", createdAt: 1 }), + ); redis.get.mockResolvedValue( JSON.stringify({ redirectTo: "/buyer/orders", createdAt: 1 }), ); @@ -76,7 +92,7 @@ describe("AuthService Google OAuth", () => { }); it("starts Google OAuth with stored state and rejects unsafe redirects", async () => { - const started = await (service as any).startGoogleOAuth("/buyer/orders"); + const started = await service.startGoogleOAuth("/buyer/orders"); expect(started.authorizationUrl).toContain("accounts.google.com"); expect(started.state).toEqual(expect.any(String)); @@ -87,13 +103,13 @@ describe("AuthService Google OAuth", () => { true, ); await expect( - (service as any).startGoogleOAuth("https://evil.example"), + service.startGoogleOAuth("https://evil.example"), ).rejects.toBeInstanceOf(BadRequestException); }); it("rejects callback when state is missing or mismatched", async () => { await expect( - (service as any).completeGoogleOAuth({ + service.completeGoogleOAuth({ code: "code", state: "returned-state", cookieState: "cookie-state", @@ -117,7 +133,7 @@ describe("AuthService Google OAuth", () => { }, }); - const result = await (service as any).completeGoogleOAuth({ + const result = await service.completeGoogleOAuth({ code: "code", state: "state", cookieState: "state", @@ -149,7 +165,7 @@ describe("AuthService Google OAuth", () => { storeProfile: null, }); - const result = await (service as any).completeGoogleOAuth({ + const result = await service.completeGoogleOAuth({ code: "code", state: "state", cookieState: "state", @@ -178,7 +194,7 @@ describe("AuthService Google OAuth", () => { }); it("creates a pending onboarding session for new Google users instead of a User", async () => { - const result = await (service as any).completeGoogleOAuth({ + const result = await service.completeGoogleOAuth({ code: "code", state: "state", cookieState: "state", @@ -206,7 +222,7 @@ describe("AuthService Google OAuth", () => { }); await expect( - (service as any).completeGoogleOAuth({ + service.completeGoogleOAuth({ code: "code", state: "state", cookieState: "state", @@ -233,7 +249,7 @@ describe("AuthService Google OAuth", () => { }); await expect( - (service as any).completeGoogleOAuth({ + service.completeGoogleOAuth({ code: "code", state: "state", cookieState: "state", diff --git a/apps/backend/src/domains/users/auth/auth.service.ts b/apps/backend/src/domains/users/auth/auth.service.ts index eae6f6df..531d885f 100644 --- a/apps/backend/src/domains/users/auth/auth.service.ts +++ b/apps/backend/src/domains/users/auth/auth.service.ts @@ -35,7 +35,11 @@ import { GoogleOAuthClient } from "../../../integrations/google-oauth/google-oau import { GoogleOAuthProfile } from "../../../integrations/google-oauth/google-oauth.types"; import { ResendClient } from "../../../integrations/resend/resend.client"; import { VerificationService } from "../../../modules/verification/verification.service"; -import { AUTH_TTL, REDIRECT_TARGET } from "./auth.constants"; +import { + AUTH_TTL, + GOOGLE_STATE_COOKIE, + REDIRECT_TARGET, +} from "./auth.constants"; import { AccessTokenPayload, AuthCookieTokens, @@ -61,7 +65,6 @@ const MAX_OTP_ATTEMPTS = 5; const PASSWORD_RESET_TTL_MS = 15 * 60 * 1000; const GOOGLE_OAUTH_STATE_TTL_SECONDS = 10 * 60; const GOOGLE_PENDING_ONBOARDING_TTL_SECONDS = 15 * 60; -const GOOGLE_STATE_COOKIE = "twizrr_google_oauth_state"; const GOOGLE_PENDING_ONBOARDING_PATH = "/register/google/complete"; const GENERIC_PASSWORD_RESET_MESSAGE = "If an account exists for that email, a password reset link has been sent."; @@ -713,7 +716,7 @@ export class AuthService { } const key = this.googleStateKey(state); - const rawState = await this.redis.get(key); + const rawState = await this.redis.getDel(key); if (!rawState) { throw new BadRequestException({ @@ -722,8 +725,6 @@ export class AuthService { }); } - await this.redis.del(key); - try { const parsed = JSON.parse(rawState) as { redirectTo?: unknown }; return { diff --git a/apps/backend/src/integrations/google-oauth/google-oauth.client.ts b/apps/backend/src/integrations/google-oauth/google-oauth.client.ts index 7a0229f8..b0b5616e 100644 --- a/apps/backend/src/integrations/google-oauth/google-oauth.client.ts +++ b/apps/backend/src/integrations/google-oauth/google-oauth.client.ts @@ -15,6 +15,7 @@ const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; const GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"; const GOOGLE_OAUTH_SCOPE = "openid email profile"; +const GOOGLE_HTTP_TIMEOUT_MS = 10_000; @Injectable() export class GoogleOAuthClient { @@ -48,21 +49,25 @@ export class GoogleOAuthClient { async getProfileFromCode(code: string): Promise { this.assertConfigured(); - const tokenResponse = await fetch(GOOGLE_TOKEN_URL, { - method: "POST", - headers: { - "content-type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - code, - client_id: this.clientId, - client_secret: this.clientSecret, - redirect_uri: this.redirectUrl, - grant_type: "authorization_code", - }), - }); - - const tokenData = (await tokenResponse.json()) as GoogleTokenResponse; + const { response: tokenResponse, data: tokenData } = + await this.fetchGoogleJson( + GOOGLE_TOKEN_URL, + { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + code, + client_id: this.clientId, + client_secret: this.clientSecret, + redirect_uri: this.redirectUrl, + grant_type: "authorization_code", + }), + }, + "Google sign-in could not be verified", + "GOOGLE_OAUTH_TOKEN_INVALID", + ); if (!tokenResponse.ok || !tokenData.access_token) { throw new UnauthorizedException({ @@ -71,12 +76,17 @@ export class GoogleOAuthClient { }); } - const profileResponse = await fetch(GOOGLE_USERINFO_URL, { - headers: { - authorization: `Bearer ${tokenData.access_token}`, - }, - }); - const profile = (await profileResponse.json()) as GoogleUserInfoResponse; + const { response: profileResponse, data: profile } = + await this.fetchGoogleJson( + GOOGLE_USERINFO_URL, + { + headers: { + authorization: `Bearer ${tokenData.access_token}`, + }, + }, + "Google profile could not be verified", + "GOOGLE_OAUTH_PROFILE_INVALID", + ); if (!profileResponse.ok || !profile.sub || !profile.email) { throw new UnauthorizedException({ @@ -97,6 +107,40 @@ export class GoogleOAuthClient { }; } + private async fetchGoogleJson( + url: string, + init: RequestInit, + message: string, + code: string, + ): Promise<{ response: Response; data: T }> { + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + GOOGLE_HTTP_TIMEOUT_MS, + ); + let response: Response; + + try { + response = await fetch(url, { + ...init, + signal: controller.signal, + }); + } catch { + throw new UnauthorizedException({ message, code }); + } finally { + clearTimeout(timeout); + } + + try { + return { + response, + data: (await response.json()) as T, + }; + } catch { + throw new UnauthorizedException({ message, code }); + } + } + private assertConfigured(): void { if (!this.clientId || !this.clientSecret || !this.redirectUrl) { throw new ServiceUnavailableException({ From 3340a120abeddc2926aa3407d3fff231c04d03b2 Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Mon, 8 Jun 2026 10:36:38 +0100 Subject: [PATCH 046/389] feat(auth): collect first and last name during email registration --- .../auth/auth.service.registration.spec.ts | 212 ++++++++++++++++++ .../src/domains/users/auth/auth.service.ts | 89 +++++--- .../domains/users/auth/dto/register.dto.ts | 24 +- 3 files changed, 294 insertions(+), 31 deletions(-) create mode 100644 apps/backend/src/domains/users/auth/auth.service.registration.spec.ts diff --git a/apps/backend/src/domains/users/auth/auth.service.registration.spec.ts b/apps/backend/src/domains/users/auth/auth.service.registration.spec.ts new file mode 100644 index 00000000..553a5614 --- /dev/null +++ b/apps/backend/src/domains/users/auth/auth.service.registration.spec.ts @@ -0,0 +1,212 @@ +import { BadRequestException, ConflictException } from "@nestjs/common"; +import { OTPPurpose, UserRole } from "@prisma/client"; +import * as bcrypt from "bcrypt"; + +import { AuthService } from "./auth.service"; + +jest.mock("bcrypt", () => ({ + hash: jest.fn(), +})); + +describe("AuthService email registration", () => { + const prisma = { + user: { + findFirst: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + }, + storeProfile: { + findUnique: jest.fn(), + }, + oTPCode: { + updateMany: jest.fn(), + create: jest.fn(), + }, + }; + const resendClient = { + sendEmail: jest.fn(), + }; + + type AuthServiceTestHarness = { + prisma: typeof prisma; + resendClient: typeof resendClient; + otpSecret: string; + }; + + const baseDto = { + email: "buyer@example.com", + password: "Password1!", + phone: "+2348012345678", + dateOfBirth: new Date("1995-05-20T00:00:00.000Z"), + firstName: "Ada", + lastName: "Lovelace", + }; + + let service: AuthService; + + beforeEach(() => { + jest.clearAllMocks(); + service = Object.create(AuthService.prototype) as AuthService; + Object.assign(service as unknown as AuthServiceTestHarness, { + prisma, + resendClient, + otpSecret: "otp-secret", + }); + + (bcrypt.hash as jest.Mock).mockResolvedValue("hashed-password"); + prisma.user.findFirst.mockResolvedValue(null); + prisma.user.findUnique.mockResolvedValue(null); + prisma.user.create.mockImplementation(({ data }) => + Promise.resolve({ + id: "user-1", + ...data, + emailVerified: false, + phoneVerified: false, + role: UserRole.USER, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }), + ); + prisma.storeProfile.findUnique.mockResolvedValue(null); + prisma.oTPCode.updateMany.mockResolvedValue({ count: 0 }); + prisma.oTPCode.create.mockResolvedValue({}); + resendClient.sendEmail.mockResolvedValue({}); + }); + + it("stores submitted first and last names directly without splitting displayName", async () => { + const result = await service.registerEmail({ + ...baseDto, + displayName: "Ada Marie Lovelace", + username: "ada_l", + }); + + expect(prisma.user.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + firstName: "Ada", + lastName: "Lovelace", + displayName: "Ada Marie Lovelace", + username: "ada_l", + emailVerified: false, + phoneVerified: false, + role: UserRole.USER, + }), + }); + expect(result.user).toEqual( + expect.objectContaining({ + emailVerified: false, + phoneVerified: false, + username: "ada_l", + displayName: "Ada Marie Lovelace", + }), + ); + }); + + it("defaults displayName to firstName when omitted", async () => { + await service.registerEmail({ + ...baseDto, + username: "ada_l", + }); + + expect(prisma.user.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + displayName: "Ada", + firstName: "Ada", + lastName: "Lovelace", + }), + }); + }); + + it("auto-generates a unique public username when omitted", async () => { + await service.registerEmail(baseDto); + + const createdUser = prisma.user.create.mock.calls[0][0].data; + expect(createdUser.username).toMatch(/^user\d{6}$/); + expect(prisma.user.findUnique).toHaveBeenCalledWith({ + where: { username: createdUser.username }, + select: { id: true }, + }); + expect(prisma.storeProfile.findUnique).toHaveBeenCalledWith({ + where: { storeHandle: createdUser.username }, + select: { id: true }, + }); + }); + + it("preserves provided username uniqueness checks", async () => { + prisma.user.findFirst.mockResolvedValueOnce({ + email: null, + phone: null, + username: "taken_user", + }); + + await expect( + service.registerEmail({ + ...baseDto, + username: "taken_user", + }), + ).rejects.toBeInstanceOf(ConflictException); + + expect(prisma.user.create).not.toHaveBeenCalled(); + }); + + it("preserves duplicate email and phone rejection", async () => { + prisma.user.findFirst.mockResolvedValueOnce({ + email: baseDto.email, + phone: null, + username: null, + }); + + await expect( + service.registerEmail({ + ...baseDto, + username: "ada_l", + }), + ).rejects.toMatchObject({ response: { code: "EMAIL_TAKEN" } }); + + prisma.user.findFirst.mockResolvedValueOnce({ + email: null, + phone: baseDto.phone, + username: null, + }); + + await expect( + service.registerEmail({ + ...baseDto, + email: "other@example.com", + username: "ada_l", + }), + ).rejects.toMatchObject({ response: { code: "PHONE_TAKEN" } }); + }); + + it("rejects under-13 registrations", async () => { + await expect( + service.registerEmail({ + ...baseDto, + dateOfBirth: new Date("2020-01-01T00:00:00.000Z"), + username: "ada_l", + }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(prisma.user.create).not.toHaveBeenCalled(); + }); + + it("sends email verification and does not require phone verification", async () => { + await service.registerEmail({ + ...baseDto, + username: "ada_l", + }); + + expect(prisma.oTPCode.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + email: baseDto.email, + phone: undefined, + purpose: OTPPurpose.EMAIL_VERIFY, + }), + }); + expect(resendClient.sendEmail).toHaveBeenCalled(); + expect(prisma.user.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + emailVerified: false, + phoneVerified: false, + }), + }); + }); +}); diff --git a/apps/backend/src/domains/users/auth/auth.service.ts b/apps/backend/src/domains/users/auth/auth.service.ts index 531d885f..9925e259 100644 --- a/apps/backend/src/domains/users/auth/auth.service.ts +++ b/apps/backend/src/domains/users/auth/auth.service.ts @@ -66,6 +66,9 @@ const PASSWORD_RESET_TTL_MS = 15 * 60 * 1000; const GOOGLE_OAUTH_STATE_TTL_SECONDS = 10 * 60; const GOOGLE_PENDING_ONBOARDING_TTL_SECONDS = 15 * 60; const GOOGLE_PENDING_ONBOARDING_PATH = "/register/google/complete"; +const AUTO_USERNAME_PREFIX = "user"; +const AUTO_USERNAME_DIGITS = 6; +const AUTO_USERNAME_MAX_ATTEMPTS = 10; const GENERIC_PASSWORD_RESET_MESSAGE = "If an account exists for that email, a password reset link has been sent."; const GENERIC_EMAIL_VERIFICATION_RESEND_MESSAGE = @@ -313,7 +316,8 @@ export class AuthService { ); const passwordHash = await bcrypt.hash(dto.password, PASSWORD_SALT_ROUNDS); - const { firstName, lastName } = this.splitDisplayName(dto.displayName); + const username = dto.username ?? (await this.generateUniqueUsername()); + const displayName = dto.displayName || dto.firstName; let user: User; @@ -322,11 +326,11 @@ export class AuthService { data: { email: dto.email, phone: dto.phone, - username: dto.username, - displayName: dto.displayName, + username, + displayName, dateOfBirth: dto.dateOfBirth, - firstName, - lastName, + firstName: dto.firstName, + lastName: dto.lastName, passwordHash, role: UserRole.USER, emailVerified: false, @@ -864,11 +868,16 @@ export class AuthService { private async assertUniqueRegistrationFields( email: string, phone: string, - username: string, + username?: string, ): Promise { + const uniqueFieldChecks: Prisma.UserWhereInput[] = [{ email }, { phone }]; + if (username) { + uniqueFieldChecks.push({ username }); + } + const existingUser = await this.prisma.user.findFirst({ where: { - OR: [{ email }, { phone }, { username }], + OR: uniqueFieldChecks, }, select: { email: true, @@ -891,24 +900,59 @@ export class AuthService { }); } - if (existingUser?.username === username) { + if (username && existingUser?.username === username) { throw new ConflictException({ message: "Username is already taken", code: "USERNAME_TAKEN", }); } - const existingStoreHandle = await this.prisma.storeProfile.findUnique({ - where: { storeHandle: username }, - select: { id: true }, - }); - - if (existingStoreHandle) { - throw new ConflictException({ - message: "Username is already taken", - code: "USERNAME_TAKEN", + if (username) { + const existingStoreHandle = await this.prisma.storeProfile.findUnique({ + where: { storeHandle: username }, + select: { id: true }, }); + + if (existingStoreHandle) { + throw new ConflictException({ + message: "Username is already taken", + code: "USERNAME_TAKEN", + }); + } + } + } + + private async generateUniqueUsername(): Promise { + for (let attempt = 0; attempt < AUTO_USERNAME_MAX_ATTEMPTS; attempt += 1) { + const suffix = randomInt(0, 10 ** AUTO_USERNAME_DIGITS) + .toString() + .padStart(AUTO_USERNAME_DIGITS, "0"); + const username = `${AUTO_USERNAME_PREFIX}${suffix}`; + + if (await this.isUsernameAvailable(username)) { + return username; + } } + + throw new ServiceUnavailableException({ + message: "Username could not be generated. Please choose one.", + code: "USERNAME_GENERATION_FAILED", + }); + } + + private async isUsernameAvailable(username: string): Promise { + const [existingUser, existingStoreHandle] = await Promise.all([ + this.prisma.user.findUnique({ + where: { username }, + select: { id: true }, + }), + this.prisma.storeProfile.findUnique({ + where: { storeHandle: username }, + select: { id: true }, + }), + ]); + + return !existingUser && !existingStoreHandle; } private async createSessionTokens(params: { @@ -989,17 +1033,6 @@ export class AuthService { } } - private splitDisplayName(displayName: string): { - firstName: string; - lastName: string; - } { - const parts = displayName.split(" ").filter(Boolean); - const firstName = parts[0] || displayName; - const lastName = parts.length > 1 ? parts.slice(1).join(" ") : firstName; - - return { firstName, lastName }; - } - private async issueOtp(params: { userId: string; email?: string; diff --git a/apps/backend/src/domains/users/auth/dto/register.dto.ts b/apps/backend/src/domains/users/auth/dto/register.dto.ts index f439c599..8c808970 100644 --- a/apps/backend/src/domains/users/auth/dto/register.dto.ts +++ b/apps/backend/src/domains/users/auth/dto/register.dto.ts @@ -3,6 +3,7 @@ import { IsDate, IsEmail, IsNotEmpty, + IsOptional, IsString, Matches, MaxLength, @@ -23,9 +24,26 @@ export class RegisterEmailDto { }) phone!: string; + @Transform(({ value }) => + typeof value === "string" ? value.trim().replace(/\s+/g, " ") : value, + ) + @IsString() + @IsNotEmpty() + @MaxLength(60) + firstName!: string; + + @Transform(({ value }) => + typeof value === "string" ? value.trim().replace(/\s+/g, " ") : value, + ) + @IsString() + @IsNotEmpty() + @MaxLength(60) + lastName!: string; + @Transform(({ value }) => typeof value === "string" ? value.trim().toLowerCase() : value, ) + @IsOptional() @IsString() @MinLength(3) @MaxLength(30) @@ -33,15 +51,15 @@ export class RegisterEmailDto { message: "username can only contain lowercase letters, numbers, and underscores", }) - username!: string; + username?: string; @Transform(({ value }) => typeof value === "string" ? value.trim().replace(/\s+/g, " ") : value, ) + @IsOptional() @IsString() - @IsNotEmpty() @MaxLength(80) - displayName!: string; + displayName?: string; @Type(() => Date) @IsDate() From d9db66d251dee37ce221fc42bbc0d6a0a47c9ab2 Mon Sep 17 00:00:00 2001 From: SAHEED2010 Date: Tue, 9 Jun 2026 00:24:39 +1300 Subject: [PATCH 047/389] fix(whatsapp): force gemini function calls --- .../whatsapp/whatsapp-intent.service.spec.ts | 50 +++++++++++++++++++ .../whatsapp/whatsapp-intent.service.ts | 8 ++- .../src/integrations/ai/gemini.client.ts | 6 +++ 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 apps/backend/src/channels/whatsapp/whatsapp-intent.service.spec.ts diff --git a/apps/backend/src/channels/whatsapp/whatsapp-intent.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-intent.service.spec.ts new file mode 100644 index 00000000..6d40d17b --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-intent.service.spec.ts @@ -0,0 +1,50 @@ +import { ConfigService } from "@nestjs/config"; +import { GeminiClient } from "../../integrations/ai/gemini.client"; +import { GEMINI_FUNCTION_DECLARATIONS } from "./whatsapp.constants"; +import { WhatsAppIntentService } from "./whatsapp-intent.service"; + +describe("WhatsAppIntentService", () => { + const configService = { + get: jest.fn(), + }; + const geminiClient = { + isConfigured: jest.fn(), + parseFunctionCall: jest.fn(), + }; + + let service: WhatsAppIntentService; + + beforeEach(() => { + jest.clearAllMocks(); + configService.get.mockReturnValue("Base WhatsApp prompt."); + geminiClient.isConfigured.mockReturnValue(true); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + }); + + it("forces Gemini intent parsing to return a function call", async () => { + geminiClient.parseFunctionCall.mockResolvedValue({ + name: "search_products", + args: { query: "iphone 15" }, + }); + + const intent = await service.parseIntent("I want to buy an iPhone 15"); + + expect(intent).toEqual({ + functionName: "search_products", + params: { query: "iphone 15" }, + }); + expect(geminiClient.parseFunctionCall).toHaveBeenCalledWith({ + message: "I want to buy an iPhone 15", + systemPrompt: expect.stringContaining( + "You must always respond by calling one of the provided functions.", + ), + functionDeclarations: GEMINI_FUNCTION_DECLARATIONS, + }); + expect( + geminiClient.parseFunctionCall.mock.calls[0][0].systemPrompt, + ).toContain("Base WhatsApp prompt."); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-intent.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-intent.service.ts index 169c3a4d..dda324a9 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp-intent.service.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp-intent.service.ts @@ -11,6 +11,9 @@ export interface ParsedIntent { params: Record; } +const FORCE_FUNCTION_CALL_PROMPT = + "You must always respond by calling one of the provided functions. Never respond with text."; + /** * AI-powered intent parsing using Gemini function calling. * @@ -110,10 +113,13 @@ export class WhatsAppIntentService { private async parseWithGemini(messageText: string): Promise { const systemPrompt = this.configService.get("whatsapp.systemPrompt") || ""; + const functionCallingSystemPrompt = systemPrompt + ? `${systemPrompt}\n\n${FORCE_FUNCTION_CALL_PROMPT}` + : FORCE_FUNCTION_CALL_PROMPT; const functionCall = await this.geminiClient.parseFunctionCall({ message: messageText, - systemPrompt, + systemPrompt: functionCallingSystemPrompt, functionDeclarations: GEMINI_FUNCTION_DECLARATIONS, }); diff --git a/apps/backend/src/integrations/ai/gemini.client.ts b/apps/backend/src/integrations/ai/gemini.client.ts index 5dc2bff1..09e249e8 100644 --- a/apps/backend/src/integrations/ai/gemini.client.ts +++ b/apps/backend/src/integrations/ai/gemini.client.ts @@ -6,6 +6,7 @@ import { } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { + FunctionCallingMode, FunctionDeclaration, GoogleGenerativeAI, SchemaType, @@ -118,6 +119,11 @@ export class GeminiClient { functionDeclarations: this.normalizeFunctionDeclarations(functions), }, ], + toolConfig: { + functionCallingConfig: { + mode: FunctionCallingMode.ANY, + }, + }, }); const result = await model.generateContent(prompt); From f3072e4fa78a4f09b1a896de38aefee4533af192 Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Mon, 8 Jun 2026 15:29:13 +0100 Subject: [PATCH 048/389] feat(web): collect first and last name during registration --- apps/web/src/app/(auth)/register/page.tsx | 257 ++++------------------ 1 file changed, 44 insertions(+), 213 deletions(-) diff --git a/apps/web/src/app/(auth)/register/page.tsx b/apps/web/src/app/(auth)/register/page.tsx index 85ccdd6a..09ae936c 100644 --- a/apps/web/src/app/(auth)/register/page.tsx +++ b/apps/web/src/app/(auth)/register/page.tsx @@ -11,25 +11,6 @@ import { Input } from "@/components/ui/Input"; import { Button } from "@/components/ui/Button"; import { api, type ApiError } from "@/lib/api"; -// ─── Interest categories (client-side only — for feed personalisation) ─────── - -const INTERESTS = [ - "Fashion & Clothing", - "Footwear", - "Beauty & Skincare", - "Electronics", - "Food & Groceries", - "Home & Living", - "Kids & Baby", - "Sports & Fitness", - "Arts & Crafts", - "Jewellery & Accessories", - "Health & Wellness", - "Books", -]; - -// ─── Shared utility ─────────────────────────────────────────────────────────── - function calcAge(dobString: string): number { const dob = new Date(dobString); const now = new Date(); @@ -60,8 +41,6 @@ function normalizeNigerianPhone(input: string): string | null { return null; } -// ─── Zod schemas per step ───────────────────────────────────────────────────── - const step2Schema = z.object({ email: z.string().email("Enter a valid email address"), password: z @@ -88,7 +67,7 @@ const step4Schema = z.object({ dateOfBirth: z .string() .min(1, "Date of birth is required") - .refine((v) => !isNaN(new Date(v).getTime()), "Enter a valid date") + .refine((v) => !Number.isNaN(new Date(v).getTime()), "Enter a valid date") .refine((v) => new Date(v) < new Date(), "Date must be in the past") .refine( (v) => calcAge(v) >= 13, @@ -97,15 +76,16 @@ const step4Schema = z.object({ }); const step5Schema = z.object({ - displayName: z + firstName: z .string() - .min(1, "Your name is required") - .max(80, "At most 80 characters"), - username: z + .trim() + .min(1, "First name is required") + .max(60, "At most 60 characters"), + lastName: z .string() - .min(3, "At least 3 characters") - .max(30, "At most 30 characters") - .regex(/^[a-z0-9_]+$/, "Only lowercase letters, numbers, and underscores"), + .trim() + .min(1, "Last name is required") + .max(60, "At most 60 characters"), }); type Step2Fields = z.infer; @@ -113,20 +93,16 @@ type Step3Fields = z.infer; type Step4Fields = z.infer; type Step5Fields = z.infer; -// ─── Wizard state ───────────────────────────────────────────────────────────── - type WizardData = { email: string; password: string; phone: string; dateOfBirth: string; - username: string; - displayName: string; - interests: string[]; - bio: string; + firstName: string; + lastName: string; }; -// ─── Step progress bar ──────────────────────────────────────────────────────── +const TOTAL_STEPS = 5; function StepProgress({ step, total }: { step: number; total: number }) { return ( @@ -144,13 +120,10 @@ function StepProgress({ step, total }: { step: number; total: number }) { ); } -// ─── Main page ──────────────────────────────────────────────────────────────── - export default function RegisterPage() { const router = useRouter(); const [step, setStep] = useState(1); - const [isMinor, setIsMinor] = useState(false); const [serverError, setServerError] = useState(null); const [isRegistering, setIsRegistering] = useState(false); const [showPassword, setShowPassword] = useState(false); @@ -160,31 +133,23 @@ export default function RegisterPage() { password: "", phone: "", dateOfBirth: "", - username: "", - displayName: "", - interests: [], - bio: "", + firstName: "", + lastName: "", }); - // Steps 6-8 are post-registration; step 8 (store prompt) only for 18+ - const TOTAL_STEPS = isMinor ? 7 : 8; - function goBack() { setStep((s) => Math.max(1, s - 1)); setServerError(null); } - // Redirect to verify-email then on to `destination` - function goToVerify(destination: string) { + function goToVerify(destination: string, email = data.email) { const params = new URLSearchParams({ - email: data.email, + email, next: destination, }); router.push(`/verify-email?${params.toString()}`); } - // ── Per-step forms ────────────────────────────────────────────────────────── - const form2 = useForm({ resolver: zodResolver(step2Schema), defaultValues: { email: data.email, password: data.password }, @@ -202,7 +167,7 @@ export default function RegisterPage() { const form5 = useForm({ resolver: zodResolver(step5Schema), - defaultValues: { displayName: data.displayName, username: data.username }, + defaultValues: { firstName: data.firstName, lastName: data.lastName }, }); const onStep2: SubmitHandler = (fields) => { @@ -216,27 +181,26 @@ export default function RegisterPage() { }; const onStep4: SubmitHandler = (fields) => { - setIsMinor(calcAge(fields.dateOfBirth) < 18); setData((d) => ({ ...d, dateOfBirth: fields.dateOfBirth })); setStep(5); }; - // Step 5 submits to the backend const onStep5: SubmitHandler = async (fields) => { const merged: WizardData = { ...data, ...fields }; setData(merged); setServerError(null); setIsRegistering(true); + try { await api.post("/auth/register/email", { email: merged.email, + password: merged.password, phone: merged.phone, - username: merged.username, - displayName: merged.displayName, dateOfBirth: new Date(merged.dateOfBirth), - password: merged.password, + firstName: merged.firstName, + lastName: merged.lastName, }); - setStep(6); + goToVerify("/explore", merged.email); } catch (err) { setServerError( (err as ApiError).message ?? "Something went wrong. Please try again.", @@ -246,21 +210,9 @@ export default function RegisterPage() { } }; - function toggleInterest(interest: string) { - setData((d) => ({ - ...d, - interests: d.interests.includes(interest) - ? d.interests.filter((i) => i !== interest) - : [...d.interests, interest], - })); - } - - // ── Render ────────────────────────────────────────────────────────────────── - return (

    - {/* Back button — visible on data-collection steps only */} - {step > 1 && step <= 5 && ( + {step > 1 && (
    @@ -317,14 +267,13 @@ export default function RegisterPage() {
    )} - {/* ===== STEP 2: Email + Password ===== */} {step === 2 && (

    Your email

    - Use an email you can access — we'll send a verification code. + Use an email you can access. We will send a verification code.

    )} - {/* ===== STEP 3: Phone ===== */} {step === 3 && (

    Phone number

    - Your Nigerian mobile number — used for order updates. + Your Nigerian mobile number is required for account and order + updates.

    )} - {/* ===== STEP 4: Date of birth ===== */} {step === 4 && (

    @@ -455,14 +403,13 @@ export default function RegisterPage() {

    )} - {/* ===== STEP 5: Identity (username + display name) → backend call ===== */} {step === 5 && (

    - Your identity + Your name

    - Choose a name and a unique username for your profile. + Enter your first and last name to finish creating your account.

    {serverError && ( @@ -510,121 +456,6 @@ export default function RegisterPage() {
    )} - - {/* ===== STEP 6: Interests (UI only — no backend) ===== */} - {step === 6 && ( -
    -

    - What are you into? -

    -

    - Personalise your feed. Select as many as you like. -

    - -
    - {INTERESTS.map((interest) => { - const selected = data.interests.includes(interest); - return ( - - ); - })} -
    - - -
    - )} - - {/* ===== STEP 7: Bio (optional) ===== */} - {step === 7 && ( -
    -

    - Tell us about yourself -

    -

    - Optional — you can always update this later from your profile. -

    - -
    -
    - -