feat: Implement message request functionality, add suggested creators…#17
feat: Implement message request functionality, add suggested creators…#17
Conversation
…, and introduce a course learning screen.
|
Caution Review failedThe pull request is closed. 📝 WalkthroughWalkthroughAdds 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
Sequence DiagramsequenceDiagram
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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 | 🟡 MinorWrap debug logs with
__DEV__guard.The user version of this screen (
mobile/app/(user)/message.tsx) wraps theseconsole.logcalls 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,
toggleModulehere (lines 81-89) readsexpandedModulesdirectly from the closure, whilecourse-learn.tsxuses the functional updatersetExpandedModules((prev) => ...). The functional form is safer against stale closures — consider aligning the two.mobile/app/course-learn.tsx (1)
48-53:formatDurationreturnsnullfor a 0-second video.
if (!seconds)is falsy for0, so a 0-duration video would show no duration badge. If 0 is a valid value from the API, consider usingif (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 reusableBadgeIconcomponent.♻️ 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 whetherinitiated_byshould be NOT NULL for new rows.Currently
initiated_byis 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.tsxandmobile/app/(user)/message.tsxare almost the same component (SearchModal, Message, banner, conversation list, formatDate, etc.). This is a significant duplication surface. Consider extracting a sharedMessageScreencomponent and importing it from both layouts.backend/src/controllers/follow.controller.js (2)
137-139: Cap thelimitparameter 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 — theifguard is redundant.
followingIdsalways contains at least one element (the user's own ID is pushed at line 149), sofollowingIds.length > 0is 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: DuplicategetInitialsimplementation with different behavior.There's already a
getInitialsutility inmobile/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 inmobile/app/chat/[id].tsx(line 208) andmobile/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 aSetforpurchasedIdslookups.
purchasedIds.includes(s.id)is O(n) per item, making the overall filter O(n×m). Using aSetmakes 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: InconsistentgetInitials— only extracts one character here vs. two elsewhere.This version takes 1 character (
slice(0, 1)), whilerequests.tsxtakes 2 andmobile/lib/utils.tssplits by spaces. Import the shared utility from@/lib/utilsfor 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.errorthem. The user gets no feedback if the operation fails — the spinner just disappears and nothing happens. Consider showing anAlerton 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 ofgetInitialswith yet another behavior variant.This version takes 2 characters (like
requests.tsx), but the shared util inmobile/lib/utils.tssplits 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: Usecountwithhead: trueto 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 } });
|
|
||
| await supabase | ||
| .from("conversations") | ||
| .update({ | ||
| request_status: requestStatus, | ||
| initiated_by: senderId, | ||
| }) | ||
| .eq("id", conversationId); |
There was a problem hiding this comment.
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.
| 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.
| <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> |
There was a problem hiding this comment.
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.
| export default function CourseLearn() { | ||
| const router = useRouter(); | ||
| const { id } = useLocalSearchParams<{ id: string }>(); | ||
| const { data: service, isLoading } = useService(id!); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
git ls-files | head -20Repository: 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 -20Repository: 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 -20Repository: Ra7ush/capstone
Length of output: 702
🏁 Script executed:
cat -n ./mobile/app/course-learn.tsxRepository: Ra7ush/capstone
Length of output: 16513
🏁 Script executed:
# Search for usePurchasedServiceIds hook definition
rg -n "usePurchasedServiceIds" --type ts --type tsxRepository: Ra7ush/capstone
Length of output: 86
🏁 Script executed:
# Search for useService hook implementation
rg -n "useService" --type ts --type tsx -A 5 | head -40Repository: 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 3Repository: Ra7ush/capstone
Length of output: 7689
🏁 Script executed:
# Check the service controller for purchase verification
cat ./backend/src/controllers/service.controller.jsRepository: 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 -100Repository: Ra7ush/capstone
Length of output: 3010
🏁 Script executed:
# Check service routes to see if there's any middleware
cat ./backend/src/routes/service.route.jsRepository: 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_urlfor 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.
| <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); | ||
| }} | ||
| /> | ||
| </> |
There was a problem hiding this comment.
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.
| <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.
…, and introduce a course learning screen.
Summary by CodeRabbit
New Features
Improvements