Skip to content

feat: Implement message request functionality, add suggested creators…#17

Merged
Ra7ush merged 2 commits intomainfrom
user-homeTabFix
Feb 15, 2026
Merged

feat: Implement message request functionality, add suggested creators…#17
Ra7ush merged 2 commits intomainfrom
user-homeTabFix

Conversation

@Ra7ush
Copy link
Copy Markdown
Owner

@Ra7ush Ra7ush commented Feb 15, 2026

…, and introduce a course learning screen.

Summary by CodeRabbit

  • New Features

    • Instagram-style Message Requests (view, accept, decline) with request counts and request list screen
    • Suggested creators discovery and endpoint for personalized suggestions
    • Unread badges on Home and Messages tabs
    • New Course Learn screen for purchased content; redesigned Home with multiple sections and pull-to-refresh
  • Improvements

    • Course preview now shows expandable module/lesson groups
    • Improved safe-area spacing and header behavior across profile screens

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 15, 2026

Caution

Review failed

The pull request is closed.

📝 Walkthrough

Walkthrough

Adds Instagram-style Message Requests: DB migration, backend logic and endpoints to create/accept/decline requests, messaging validations; mobile UI/hooks for request counts/banners, a Message Requests screen, unread badges, and course/service UI enhancements.

Changes

Cohort / File(s) Summary
Database Schema
backend/migrations/message_requests.sql
Adds request_status (accepted
Backend — Messaging
backend/src/controllers/message.controller.js, backend/src/routes/message.route.js, backend/src/validators/schemas.js
Implements mutual-follow check, getOrCreateConversation now returns requestStatus, enforces request gating in sendMessage, adds endpoints: getMessageRequests, getMessageRequestsCount, acceptMessageRequest, declineMessageRequest; adds request-related request validators.
Backend — Follow
backend/src/controllers/follow.controller.js, backend/src/routes/follow.route.js
Adds getSuggestedCreators controller and /suggested route to return creators not yet followed by current user.
Backend — Service Redaction
backend/src/controllers/service.controller.js
Redacts lesson.video_url for non-purchasers (and non-creators) when returning service data.
Mobile — Messaging Hooks & API
mobile/hooks/useMessaging.ts, mobile/lib/api.ts
Adds useUnreadMessageCount, useMessageRequests (with accept/decline mutations), extends useChat to include requestStatus and initiatedBy; mobile API endpoints for requests and suggested creators.
Mobile — Message UI & Screens
mobile/app/chat/requests.tsx, mobile/app/chat/[id].tsx, mobile/app/(user)/message.tsx, mobile/app/(creator)/message.tsx
New Message Requests screen; message request banners on Messages screens; chat UI updated to show Accept/Decline for recipients, pending notice for requesters, and gating of send UI based on request status.
Mobile — Tab Badges & Layouts
mobile/app/(creator)/_layout.tsx, mobile/app/(user)/_layout.tsx
Adds unread message/notification red badges to Home and Message tabs.
Mobile — Home & Course Flows
mobile/app/(user)/index.tsx, mobile/app/course-learn.tsx, mobile/app/service-detail.tsx, mobile/app/creator/[id].tsx
Home screen refactor (sections, refresh, profile header); new CourseLearn screen; service-detail switched to module preview and purchased routing; creator profile routes to course when purchased.
Mobile — Hooks (Follow)
mobile/hooks/useFollow.ts
Adds useSuggestedCreators hook to fetch suggested creators.
Misc — Validators & Routes
backend/src/validators/schemas.js, backend/src/routes/message.route.js
Adds sendMessageSchema and getOrCreateConversationSchema; routes wired with validation middleware.

Sequence Diagram

sequenceDiagram
    participant Sender as User A (Sender)
    participant Backend as Backend API
    participant DB as Database
    participant Recipient as User B (Recipient)
    participant MobileB as User B's Mobile

    Sender->>Backend: sendMessage(conversationId? / receiverId, content)
    Backend->>DB: Lookup or create conversation
    DB-->>Backend: conversation exists? / details
    alt New conversation
        Backend->>DB: Check mutual follows between users
        DB-->>Backend: mutual = true/false
        alt mutual = true
            Backend->>DB: create conversation (request_status='accepted', initiated_by=Sender)
        else mutual = false
            Backend->>DB: create conversation (request_status='pending', initiated_by=Sender)
        end
    end
    alt conversation.request_status = 'pending' and Sender ≠ initiated_by
        Backend-->>Sender: reject send (request pending)
    else conversation.request_status = 'declined'
        Backend-->>Sender: reject send (declined)
    else conversation.request_status = 'accepted'
        Backend->>DB: insert message
        Backend->>MobileB: push real-time update / badge increment
    end

    Note over Recipient,MobileB: Recipient fetches pending requests
    MobileB->>Backend: GET /api/message/requests
    Backend->>DB: query conversations where request_status='pending' and recipient is current user
    DB-->>Backend: return pending requests
    Backend-->>MobileB: respond with requests
    MobileB->>Recipient: display requests list
    Recipient->>MobileB: tap Accept/Decline
    MobileB->>Backend: PUT /requests/:id/accept or /decline
    Backend->>DB: update conversation.request_status
    Backend->>DB: create notification for requester (on accept)
    Backend-->>MobileB: updated status
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐇 I hopped into code with a twitchy nose,
New requests bloom where the message flows,
Accept, decline—decisions so fleet,
Banners and badges make inboxes sweet,
A carrot-shaped cheer for chats complete! 🎉

🚥 Pre-merge checks | ✅ 2 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title partially captures the changeset scope by mentioning message request functionality and suggested creators, but is truncated (incomplete with ellipsis) and omits the third major feature: course learning screen implementation. Complete the title to include all three major features or focus on the primary change if one dominates; examples: 'feat: Add message requests, suggested creators, and course learning screen' or focus on the most impactful feature.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch user-homeTabFix

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
mobile/app/(creator)/message.tsx (1)

44-48: ⚠️ Potential issue | 🟡 Minor

Wrap debug logs with __DEV__ guard.

The user version of this screen (mobile/app/(user)/message.tsx) wraps these console.log calls in __DEV__ checks, but this creator version does not. These logs will appear in production builds.

🐛 Proposed fix
-    console.log(`[Frontend] Searching for: "${text}"`);
+    if (__DEV__) {
+      console.log(`[Frontend] Searching for: "${text}"`);
+    }
     setLoading(true);
     try {
       const data = await profileApi.searchProfiles(text);
-      console.log(`[Frontend] Found ${data?.length || 0} results`);
+      if (__DEV__) {
+        console.log(`[Frontend] Found ${data?.length || 0} results`);
+      }
🤖 Fix all issues with AI agents
In `@backend/migrations/message_requests.sql`:
- Around line 13-14: Add a CHECK constraint to enforce allowed values for the
conversations.request_status column: after ensuring the column exists (your
ALTER TABLE ... ADD COLUMN IF NOT EXISTS request_status ...), run a separate
ALTER TABLE to add a constraint (e.g., chk_conversations_request_status) that
restricts request_status to the approved set (e.g., 'accepted', 'pending',
'rejected'); if the column may already exist, split the migration into the ADD
COLUMN step and then ALTER TABLE ... ADD CONSTRAINT
chk_conversations_request_status CHECK (request_status IN (...)) so the
constraint is applied reliably.

In `@backend/src/controllers/message.controller.js`:
- Around line 348-355: The conversation update call using
supabase.from("conversations").update({...}).eq("id", conversationId) is not
checking the returned result for errors; modify the code around that call to
capture the response (e.g., const { data, error } = await
supabase.from("conversations").update(...).eq(...)), check if error is present
and handle it (log via your logger/processLogger and return or throw an
appropriate error response), and only proceed to send the client response when
the update succeeded so requestStatus and initiated_by reflect the DB state;
ensure you reference conversationId, requestStatus, and senderId in the handling
logic.
- Around line 227-249: The conversation status check uses convCheck from the
supabase .single() call but does not handle the case where convCheck is null;
update the block after the call to supabase.from(...).select(...).single() to
explicitly handle a missing row (e.g., if (!convCheck) return
res.status(404).json({ success: false, error: "Conversation not found" }) )
before evaluating convCheck.request_status and convCheck.initiated_by so you
don't allow inserting messages into a non-existent conversation (references:
convCheck, actualConversationId, senderId, the supabase .single() call).
- Around line 720-776: The declineMessageRequest handler currently allows
declining regardless of current request_status; add a guard in
declineMessageRequest that checks conv.request_status === "pending" (using the
fetched conv from the conversations select) and return an appropriate error
(e.g., 400/409 with a message like "Request is not pending") if it is not
pending, before verifying participation and updating the row; keep the existing
initiated_by check and only proceed to the supabase .update(...) when the status
is pending.
- Around line 648-714: In acceptMessageRequest, add a guard that checks
conv.request_status === "pending" and return a 400/409 error if it's not pending
to prevent re-accepting; additionally make the DB update conditional (update
where id = conversationId AND request_status = "pending") and verify the update
affected a row before creating the notification (use the update result/error to
detect no-op and return a suitable error). Ensure you reference
conv.request_status and the conversations update call so the notification is
only sent when the status actually transitions from "pending" to "accepted".
- Around line 334-356: The current read-then-write on conversations (reading
convData then updating initiated_by) has a race where two callers both see
initiated_by null; change this to a single atomic conditional update so only the
first writer sets initiated_by: perform the mutual-follow check first, then
issue an UPDATE on the conversations row that sets request_status and
initiated_by WHERE id = conversationId AND initiated_by IS NULL (use Supabase's
.is('initiated_by', null) or equivalent) and inspect the update result/rowcount
to know whether you won the race; if the update did not modify a row, reload the
conversation to get the final request_status/initiated_by. Apply this to the
code paths using convData, conversationId, senderId, receiverId,
areMutualFollowers and the supabase update call (or move the whole logic into a
DB RPC to run server-side atomically).
- Around line 9-22: The sendMessage route currently passes unvalidated
req.body.receiverId into areMutualFollowers where it gets interpolated into a
PostgREST filter; add a UUID validation to the sendMessage route using the
existing validateRequest middleware and uuidSchema (validate receiverId as a
UUID) so receiverId is validated before calling areMutualFollowers; update the
route declaration to apply validateRequest with a schema that requires
receiverId to match uuidSchema, and ensure sendMessage uses the validated value
(not raw req.body) when calling areMutualFollowers to prevent PostgREST filter
injection.

In `@mobile/app/`(user)/index.tsx:
- Around line 361-365: The community card always routes to the generic listing
path; update the onPress in the TouchableOpacity inside communities.map to push
the specific community detail route using the community's identifier (e.g., use
router.push(`/.../community/${community.id}`) or the app's existing detail route
pattern), so modify the onPress handler in the component that renders
communities.map to construct the correct path from community.id (or
community.slug) instead of the hardcoded "/(user)/community".

In `@mobile/app/`(user)/message.tsx:
- Line 294: The View element currently uses unsupported NativeWind gradient
utilities ("bg-gradient-to-br from-blue-500 to-blue-600") which are dead code;
edit the View with the className containing "w-12 h-12 rounded-full
bg-gradient-to-br from-blue-500 to-blue-600 items-center justify-center
bg-blue-500" and remove the gradient classes so only supported classes remain
(e.g., keep "bg-blue-500" plus sizing/positioning classes) to avoid confusion
and rely on the actual rendered background.

In `@mobile/app/chat/requests.tsx`:
- Around line 194-196: handleDecline is being called with
conv.other_user?.username which can be undefined and cause the decline alert to
display "@undefined"; update the call site and/or handleDecline to normalize the
username (e.g., use a fallback like '' or 'unknown' before constructing the
alert) so the alert never interpolates undefined. Locate the onPress invoking
handleDecline(conv.id, conv.other_user?.username) and change it to pass a safe
string (or add a guard inside handleDecline that coalesces username to a
fallback) so the alert shows a sensible value instead of "@undefined".
- Around line 178-206: The global booleans isAccepting/isDeclining from
useMessageRequests cause every card to show loading; change to per-item loading
by tracking the in-flight conversationId (e.g., activeConversationId) and expose
it from useMessageRequests or set local state in the list component inside
handleAccept/handleDecline; when starting a mutation set activeConversationId =
conv.id and a flag like activeAction = 'accept'|'decline', clear them on
success/error, and in the card render use (activeConversationId === conv.id &&
activeAction === 'accept') to show the Accept spinner (and similarly for
Decline) and disable buttons only when the current card matches.

In `@mobile/app/course-learn.tsx`:
- Around line 86-100: The onPress handler for the video overlay uses
Linking.openURL without guarding against failures; update the TouchableOpacity
onPress (and the similar "Play Video" button handler) to first call
Linking.canOpenURL(activeLesson.video_url) and only call Linking.openURL inside
a try/catch block if canOpenURL returns true, otherwise handle the failure
(e.g., log or show an Alert); ensure you reference the activeLesson.video_url
value and catch/handle any thrown error from Linking.openURL to prevent
unhandled promise rejections.
- Around line 21-24: CourseLearn currently shows lessons without verifying
purchase: add a client-side guard in the CourseLearn component (where
useLocalSearchParams, useService and router are used) that mirrors
service-detail.tsx — check if the logged-in user has purchased the service
before rendering lesson titles/descriptions/video URLs and redirect
(router.push) or show an upgrade message if not; additionally update the
server-side getServiceById endpoint to enforce purchase checks (either return
403 for unpurchased users or omit/video_url from lesson objects unless the
requester has purchased the course) so clients cannot access video URLs by
calling useService directly.

In `@mobile/hooks/useMessaging.ts`:
- Around line 103-117: useUnreadMessageCount currently calls useMessaging(),
which re-runs its useEffect subscriptions (messaging_global_* and
notifications:*) and creates duplicate Supabase realtime channels; fix by not
calling useMessaging inside useUnreadMessageCount and instead derive the total
from the shared query cache or a piggybacking useQuery: either read cached
conversations via the query client (e.g. getQueryData(["conversations"]) and sum
conv.unreadCount) or call useQuery(["conversations"], { select: data => compute
total unreadCount }) so you reuse the same cache entry and avoid spawning new
subscriptions/typing state from useMessaging; update useUnreadMessageCount to
reference conversations only via the cache/select and remove the useMessaging()
call.
🧹 Nitpick comments (14)
mobile/app/service-detail.tsx (1)

310-390: Module/lesson preview for non-purchased users is well-structured, but consider extracting shared curriculum UI.

The expand/collapse module listing with preview badges and lock icons is a good UX pattern. However, the module rendering logic (numbered header, expand/collapse, lesson rows) is largely duplicated between this file and course-learn.tsx. A shared <CurriculumList> component could reduce this duplication.

Also, toggleModule here (lines 81-89) reads expandedModules directly from the closure, while course-learn.tsx uses the functional updater setExpandedModules((prev) => ...). The functional form is safer against stale closures — consider aligning the two.

mobile/app/course-learn.tsx (1)

48-53: formatDuration returns null for a 0-second video.

if (!seconds) is falsy for 0, so a 0-duration video would show no duration badge. If 0 is a valid value from the API, consider using if (seconds == null) instead. If 0 never occurs in practice, this is fine.

mobile/app/(user)/_layout.tsx (1)

43-71: Extract the badge icon into a shared component to reduce duplication.

The badge rendering logic (positioning, styling, "99+" cap) is duplicated here and in mobile/app/(creator)/_layout.tsx. Consider extracting a reusable BadgeIcon component.

♻️ Example shared component
// e.g., mobile/components/BadgeIcon.tsx
function BadgeIcon({ iconName, size, color, count }: {
  iconName: string;
  size: number;
  color: string;
  count: number;
}) {
  return (
    <View>
      <Ionicons name={iconName} size={size} color={color} />
      {count > 0 && (
        <View style={{
          position: "absolute", top: -4, right: -8,
          backgroundColor: "#EF4444", borderRadius: 10,
          minWidth: 18, height: 18,
          alignItems: "center", justifyContent: "center",
          paddingHorizontal: 4,
        }}>
          <Text style={{ color: "#FFFFFF", fontSize: 10, fontWeight: "700" }}>
            {count > 99 ? "99+" : count}
          </Text>
        </View>
      )}
    </View>
  );
}

Also applies to: 98-126

backend/migrations/message_requests.sql (1)

17-18: Consider whether initiated_by should be NOT NULL for new rows.

Currently initiated_by is nullable, which is fine for backfilling existing conversations. However, new conversations should always have an initiator. You could add an application-level enforcement or a partial NOT NULL constraint for new rows via a trigger. At minimum, document that NULL means "pre-feature legacy conversation."

mobile/app/(creator)/message.tsx (1)

24-395: The creator and user Message screens are near-identical — consider sharing the implementation.

mobile/app/(creator)/message.tsx and mobile/app/(user)/message.tsx are almost the same component (SearchModal, Message, banner, conversation list, formatDate, etc.). This is a significant duplication surface. Consider extracting a shared MessageScreen component and importing it from both layouts.

backend/src/controllers/follow.controller.js (2)

137-139: Cap the limit parameter to prevent excessively large queries.

A client can pass an arbitrarily large limit (e.g., ?limit=100000). Consider capping it.

🛡️ Proposed fix
  const userId = req.user.id;
- const limit = parseInt(req.query.limit) || 10;
+ const limit = Math.min(parseInt(req.query.limit) || 10, 50);

161-163: The exclusion filter is always applied — the if guard is redundant.

followingIds always contains at least one element (the user's own ID is pushed at line 149), so followingIds.length > 0 is always true. The guard is harmless but misleading.

♻️ Simplify
-    if (followingIds.length > 0) {
-      query = query.not("id", "in", `(${followingIds.join(",")})`);
-    }
+    query = query.not("id", "in", `(${followingIds.join(",")})`);
mobile/app/(user)/message.tsx (1)

196-205: Remove commented-out code.

The old filter implementation is commented out and replaced by the version on lines 207-213. This dead code should be removed to keep the file clean.

mobile/app/chat/requests.tsx (1)

28-30: Duplicate getInitials implementation with different behavior.

There's already a getInitials utility in mobile/lib/utils.ts (lines 67-75) that splits by spaces and extracts the first character of each word. This local version just takes the first 2 characters of the string, producing different results (e.g., for "John Doe": "JO" here vs "JD" from the shared util). The same duplication exists in mobile/app/chat/[id].tsx (line 208) and mobile/app/(user)/index.tsx (line 91).

Consider importing and reusing the shared utility for consistency.

mobile/app/(user)/index.tsx (2)

57-60: Consider using a Set for purchasedIds lookups.

purchasedIds.includes(s.id) is O(n) per item, making the overall filter O(n×m). Using a Set makes each lookup O(1).

Suggested improvement
  const purchasedCourses = useMemo(() => {
    if (!allServices || !purchasedIds.length) return [];
-   return allServices.filter((s: Service) => purchasedIds.includes(s.id));
+   const idSet = new Set(purchasedIds);
+   return allServices.filter((s: Service) => idSet.has(s.id));
  }, [allServices, purchasedIds]);

91-92: Inconsistent getInitials — only extracts one character here vs. two elsewhere.

This version takes 1 character (slice(0, 1)), while requests.tsx takes 2 and mobile/lib/utils.ts splits by spaces. Import the shared utility from @/lib/utils for consistency.

mobile/app/chat/[id].tsx (2)

520-576: Accept/decline inline handlers swallow errors silently.

Both the accept (line 536-541) and decline (line 555-561) handlers catch errors but only console.error them. The user gets no feedback if the operation fails — the spinner just disappears and nothing happens. Consider showing an Alert on failure so the user knows to retry.

Suggested improvement for accept handler
  onPress={async () => {
    try {
      await acceptRequest(id!);
    } catch (error) {
      console.error("Error accepting request:", error);
+     Alert.alert("Error", "Failed to accept request. Please try again.");
    }
  }}

208-210: Third copy of getInitials with yet another behavior variant.

This version takes 2 characters (like requests.tsx), but the shared util in mobile/lib/utils.ts splits by space and takes initials of each word. Consider importing the shared utility for consistency across the app.

backend/src/controllers/message.controller.js (1)

628-637: Use count with head: true to avoid fetching unnecessary row data.

The current code fetches all matching rows only to count them with data?.length. Use { count: "exact", head: true } instead, which returns only the count in the response without row data:

Proposed fix
-   const { data, error } = await supabase
+   const { count, error } = await supabase
      .from("conversations")
-     .select("id", { count: "exact" })
+     .select("id", { count: "exact", head: true })
      .in("id", conversationIds)
      .eq("request_status", "pending")
      .neq("initiated_by", userId);

    if (error) throw error;

-   res.status(200).json({ success: true, data: { count: data?.length || 0 } });
+   res.status(200).json({ success: true, data: { count: count || 0 } });

Comment on lines +348 to +355

await supabase
.from("conversations")
.update({
request_status: requestStatus,
initiated_by: senderId,
})
.eq("id", conversationId);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing error handling on the conversation update.

The supabase.update() result is not checked for errors. If the update fails silently, the response will return a stale requestStatus that doesn't match the database state.

Proposed fix
-     await supabase
+     const { error: updateError } = await supabase
        .from("conversations")
        .update({
          request_status: requestStatus,
          initiated_by: senderId,
        })
        .eq("id", conversationId);
+
+     if (updateError) throw updateError;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await supabase
.from("conversations")
.update({
request_status: requestStatus,
initiated_by: senderId,
})
.eq("id", conversationId);
const { error: updateError } = await supabase
.from("conversations")
.update({
request_status: requestStatus,
initiated_by: senderId,
})
.eq("id", conversationId);
if (updateError) throw updateError;
🤖 Prompt for AI Agents
In `@backend/src/controllers/message.controller.js` around lines 348 - 355, The
conversation update call using
supabase.from("conversations").update({...}).eq("id", conversationId) is not
checking the returned result for errors; modify the code around that call to
capture the response (e.g., const { data, error } = await
supabase.from("conversations").update(...).eq(...)), check if error is present
and handle it (log via your logger/processLogger and return or throw an
appropriate error response), and only proceed to send the client response when
the update succeeded so requestStatus and initiated_by reflect the DB state;
ensure you reference conversationId, requestStatus, and senderId in the handling
logic.

Comment on lines +178 to +206
<View className="flex-row gap-3">
<TouchableOpacity
className="flex-1 bg-black rounded-xl py-3 items-center"
onPress={() => handleAccept(conv.id)}
disabled={isAccepting || isDeclining}
>
{isAccepting ? (
<ActivityIndicator size="small" color="white" />
) : (
<Text className="text-white font-black text-[14px]">
Accept
</Text>
)}
</TouchableOpacity>
<TouchableOpacity
className="flex-1 bg-gray-100 rounded-xl py-3 items-center"
onPress={() =>
handleDecline(conv.id, conv.other_user?.username)
}
disabled={isAccepting || isDeclining}
>
{isDeclining ? (
<ActivityIndicator size="small" color="black" />
) : (
<Text className="text-black font-black text-[14px]">
Decline
</Text>
)}
</TouchableOpacity>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

isAccepting/isDeclining are global — all request cards show spinners and become disabled simultaneously.

These booleans come from useMessageRequests and represent a single mutation state. When the user taps Accept on one request, every card's Accept button shows a spinner and both buttons on every card are disabled. This is confusing UX when multiple requests are listed.

Consider tracking the in-flight conversationId so only the affected card shows loading state:

💡 Sketch of per-item loading
+ const [actionId, setActionId] = useState<string | null>(null);

  const handleAccept = async (conversationId: string) => {
+   setActionId(conversationId);
    try {
      await acceptRequest(conversationId);
      router.push({ pathname: "/chat/[id]", params: { id: conversationId } } as any);
    } catch (error) {
      console.error("Error accepting request:", error);
+   } finally {
+     setActionId(null);
    }
  };

Then in the card:

- disabled={isAccepting || isDeclining}
+ disabled={actionId === conv.id}
🤖 Prompt for AI Agents
In `@mobile/app/chat/requests.tsx` around lines 178 - 206, The global booleans
isAccepting/isDeclining from useMessageRequests cause every card to show
loading; change to per-item loading by tracking the in-flight conversationId
(e.g., activeConversationId) and expose it from useMessageRequests or set local
state in the list component inside handleAccept/handleDecline; when starting a
mutation set activeConversationId = conv.id and a flag like activeAction =
'accept'|'decline', clear them on success/error, and in the card render use
(activeConversationId === conv.id && activeAction === 'accept') to show the
Accept spinner (and similarly for Decline) and disable buttons only when the
current card matches.

Comment on lines +21 to +24
export default function CourseLearn() {
const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>();
const { data: service, isLoading } = useService(id!);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

git ls-files | head -20

Repository: Ra7ush/capstone

Length of output: 521


🏁 Script executed:

# First, locate the file in question
find . -name "course-learn.tsx" -o -name "*purchase*" -o -name "*service*" | grep -E "\.(tsx?|js)$" | head -20

Repository: Ra7ush/capstone

Length of output: 393


🏁 Script executed:

# Get a better view of the mobile/app directory structure
find ./mobile/app -type f -name "*.tsx" -o -name "*.ts" 2>/dev/null | head -20

Repository: Ra7ush/capstone

Length of output: 702


🏁 Script executed:

cat -n ./mobile/app/course-learn.tsx

Repository: Ra7ush/capstone

Length of output: 16513


🏁 Script executed:

# Search for usePurchasedServiceIds hook definition
rg -n "usePurchasedServiceIds" --type ts --type tsx

Repository: Ra7ush/capstone

Length of output: 86


🏁 Script executed:

# Search for useService hook implementation
rg -n "useService" --type ts --type tsx -A 5 | head -40

Repository: Ra7ush/capstone

Length of output: 86


🏁 Script executed:

# Search for useService hook
find ./mobile -name "*hook*" -o -name "*service*" | grep -E "\.(ts|tsx|js)$"

Repository: Ra7ush/capstone

Length of output: 189


🏁 Script executed:

# Look for hooks directory and useService
rg -n "useService|usePurchased" --type js --type ts -A 3

Repository: Ra7ush/capstone

Length of output: 7689


🏁 Script executed:

# Check the service controller for purchase verification
cat ./backend/src/controllers/service.controller.js

Repository: Ra7ush/capstone

Length of output: 27880


🏁 Script executed:

# Check the purchase controller to understand the purchase flow
cat ./backend/src/controllers/purchase.controller.js | head -100

Repository: Ra7ush/capstone

Length of output: 3010


🏁 Script executed:

# Check service routes to see if there's any middleware
cat ./backend/src/routes/service.route.js

Repository: Ra7ush/capstone

Length of output: 1712


Add purchase verification guard before displaying course content.

The course-learn screen assumes the user has purchased the course but never verifies it. An authenticated user can navigate directly to /course-learn?id=<any_service_id> and access all lesson titles, descriptions, and video URLs.

Add a client-side purchase check matching the pattern in service-detail.tsx:

🛡️ Suggested client-side guard
 export default function CourseLearn() {
   const router = useRouter();
   const { id } = useLocalSearchParams<{ id: string }>();
   const { data: service, isLoading } = useService(id!);
+  const { data: purchasedIds = [], isLoading: loadingPurchases } = usePurchasedServiceIds();
+
+  const isPurchased = purchasedIds.includes(id || "");

   // ...

+  // ── Not Purchased ──
+  if (!isLoading && !loadingPurchases && !isPurchased) {
+    router.replace({ pathname: "/service-detail", params: { id } });
+    return null;
+  }

Critical: Backend must also enforce purchase verification. The getServiceById() endpoint currently returns full lesson data including video_url to any authenticated user regardless of purchase status. Add a server-side check to either:

  • Return HTTP 403 for unpurchased courses
  • Omit or redact video_url for lessons unless the user has purchased
🤖 Prompt for AI Agents
In `@mobile/app/course-learn.tsx` around lines 21 - 24, CourseLearn currently
shows lessons without verifying purchase: add a client-side guard in the
CourseLearn component (where useLocalSearchParams, useService and router are
used) that mirrors service-detail.tsx — check if the logged-in user has
purchased the service before rendering lesson titles/descriptions/video URLs and
redirect (router.push) or show an upgrade message if not; additionally update
the server-side getServiceById endpoint to enforce purchase checks (either
return 403 for unpurchased users or omit/video_url from lesson objects unless
the requester has purchased the course) so clients cannot access video URLs by
calling useService directly.

Comment on lines +86 to +100
<View className="w-full h-56 bg-black items-center justify-center">
{activeLesson.video_url ? (
<>
<Ionicons name="play-circle" size={64} color="white" />
<Text className="text-white/70 text-xs font-medium mt-2">
Tap to play video
</Text>
<TouchableOpacity
className="absolute inset-0 items-center justify-center"
onPress={() => {
if (activeLesson.video_url)
Linking.openURL(activeLesson.video_url);
}}
/>
</>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Unhandled promise rejection from Linking.openURL.

If the video URL is malformed or the device can't handle the scheme, Linking.openURL will throw. Wrap in try/catch or use Linking.canOpenURL first.

🛡️ Suggested fix
               <TouchableOpacity
                 className="absolute inset-0 items-center justify-center"
                 onPress={() => {
-                  if (activeLesson.video_url)
-                    Linking.openURL(activeLesson.video_url);
+                  if (activeLesson.video_url) {
+                    Linking.openURL(activeLesson.video_url).catch((err) =>
+                      console.warn("Could not open video URL:", err)
+                    );
+                  }
                 }}
               />

Apply the same pattern to the "Play Video" button at line 149.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<View className="w-full h-56 bg-black items-center justify-center">
{activeLesson.video_url ? (
<>
<Ionicons name="play-circle" size={64} color="white" />
<Text className="text-white/70 text-xs font-medium mt-2">
Tap to play video
</Text>
<TouchableOpacity
className="absolute inset-0 items-center justify-center"
onPress={() => {
if (activeLesson.video_url)
Linking.openURL(activeLesson.video_url);
}}
/>
</>
<View className="w-full h-56 bg-black items-center justify-center">
{activeLesson.video_url ? (
<>
<Ionicons name="play-circle" size={64} color="white" />
<Text className="text-white/70 text-xs font-medium mt-2">
Tap to play video
</Text>
<TouchableOpacity
className="absolute inset-0 items-center justify-center"
onPress={() => {
if (activeLesson.video_url) {
Linking.openURL(activeLesson.video_url).catch((err) =>
console.warn("Could not open video URL:", err)
);
}
}}
/>
</>
🤖 Prompt for AI Agents
In `@mobile/app/course-learn.tsx` around lines 86 - 100, The onPress handler for
the video overlay uses Linking.openURL without guarding against failures; update
the TouchableOpacity onPress (and the similar "Play Video" button handler) to
first call Linking.canOpenURL(activeLesson.video_url) and only call
Linking.openURL inside a try/catch block if canOpenURL returns true, otherwise
handle the failure (e.g., log or show an Alert); ensure you reference the
activeLesson.video_url value and catch/handle any thrown error from
Linking.openURL to prevent unhandled promise rejections.

…hance message request handling with validation, UI improvements, and database schema updates.
@Ra7ush Ra7ush merged commit 8aeed4b into main Feb 15, 2026
1 check was pending
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant