Skip to content

Conversation

@sanviagarwal7211-a11y
Copy link

No description provided.

@coderabbitai
Copy link

coderabbitai bot commented Feb 5, 2026

📝 Walkthrough

Walkthrough

This pull request introduces a complete Vite + React + TypeScript web application for a Scholarship Finder system. It establishes a full-stack architecture with a frontend UI layer built using React Router, shadcn UI components, and Tailwind CSS; a middleware integration layer connecting to Supabase; and serverless backend functions (Deno) orchestrating AI-driven scholarship discovery through Lovable and Mino automation. The application enables users to search for scholarships by type, university, and region through a form-based interface, visualizes real-time search progress with agent-based scraping, displays results in comparable cards, and persists configuration through environment variables. No breaking changes to existing systems are introduced; this is a new project scaffolding.

Sequence Diagram

sequenceDiagram
    actor User
    participant Frontend as React App
    participant Backend as Deno Function<br/>(search-scholarships)
    participant AI as Lovable AI
    participant Mino as Mino Agent Pool
    participant Websites as Scholarship Sites
    
    User->>Frontend: Submit search form<br/>(type, university, region)
    Frontend->>Frontend: Initialize loading state<br/>with SearchState
    Frontend->>Backend: POST /search with params
    
    Backend->>Backend: STEP 1: Generate URLs
    Backend->>AI: Request 5-8 provider URLs<br/>based on criteria
    AI-->>Backend: Return ScholarshipUrl list
    Backend->>Frontend: URLS_FOUND SSE event
    
    Backend->>Backend: STEP 2: Launch parallel agents
    par Agent Execution
        Backend->>Mino: POST scrape goal for Site A
        Mino->>Websites: Fetch & extract scholarships
        Backend->>Frontend: AGENT_STARTED for Site A
        Backend->>Frontend: AGENT_STREAMING with URL
        Mino-->>Backend: Progressive SSE updates
        Backend->>Frontend: AGENT_PROGRESS events
        Backend->>Backend: Parse resultJson
        Backend->>Frontend: AGENT_COMPLETE with data
    and
        Backend->>Mino: POST scrape goal for Site B
        Mino->>Websites: Fetch & extract scholarships
        Backend->>Frontend: AGENT_STARTED for Site B
        Backend->>Frontend: AGENT_STREAMING with URL
        Mino-->>Backend: Progressive SSE updates
        Backend->>Frontend: AGENT_PROGRESS events
        Backend->>Backend: Parse resultJson
        Backend->>Frontend: AGENT_COMPLETE with data
    and
        note over Backend: Additional agents...
    end
    
    Backend->>Backend: Aggregate all<br/>scholarships
    Backend->>Frontend: ALL_COMPLETE with<br/>full results
    Frontend->>Frontend: Update results state<br/>& display SearchResults
    Frontend->>User: Show scholarship cards<br/>with compare button
Loading
🚥 Pre-merge checks | ✅ 1 | ❌ 1
❌ Failed checks (1 inconclusive)
Check name Status Explanation Resolution
Description check ❓ Inconclusive No pull request description was provided by the author, making it impossible to assess relatedness to the changeset. Add a description explaining the scholarship finder application, its features, and its integration into the Tiny Fish cookbook.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Add scholarship finder to Tiny Fish cookbook' is clearly related to the main changeset, which introduces a complete scholarship finder application.

✏️ 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

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

Copy link

@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

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🤖 Fix all issues with AI agents
In `@scholarship-finder/.env`:
- Around line 1-3: Summary: Sensitive Supabase credentials were committed in
.env; remove them and replace with safe placeholders. Fix: delete the committed
.env from the repository (stop tracking it and remove from the commit/branch),
add .env to .gitignore and also .env.local, create a .env.example containing the
keys VITE_SUPABASE_PROJECT_ID, VITE_SUPABASE_PUBLISHABLE_KEY, and
VITE_SUPABASE_URL with placeholder values (no quoted values) for documentation,
and commit only the .env.example; ensure any real keys are rotated/invalidated
outside the repo and confirm no credentials remain in history or subsequent
commits.

In `@scholarship-finder/.gitignore`:
- Around line 10-13: The repository .gitignore is missing environment file
patterns; update the .gitignore to ignore environment/credential files by adding
patterns like .env, .env.local, .env.* and .env.*.local (and any other
project-specific env variants) so that Supabase keys and other secrets are not
committed; ensure these patterns are appended to the existing entries
(node_modules, dist, dist-ssr, *.local) so all common .env files are excluded
from version control.

In `@scholarship-finder/eslint.config.js`:
- Around line 11-15: The ESLint config currently sets languageOptions.globals to
globals.browser for all TypeScript files, causing Deno globals (e.g.,
Deno.serve, Deno.env.get) to be flagged; add an override entry in
eslint.config.js that targets the backend functions (use files pattern like
"supabase/functions/**/*.ts") and set languageOptions.globals to globals.deno
for that override so those files recognize Deno globals; keep the existing
browser globals for the general files setting and ensure the override object is
appended to the top-level overrides array.

In `@scholarship-finder/src/components/CompareDashboard.tsx`:
- Around line 177-190: The Apply link in CompareDashboard renders
s.applicationLink directly inside the anchor, which allows unsafe schemes like
javascript:; update CompareDashboard to reuse the same http/https
allowlist/sanitizer used by ScholarshipCard (or call the existing utility such
as isSafeUrl/sanitizeUrl if present) before rendering: validate
s.applicationLink in the scholarships.map iteration and only render the anchor
href when the URL passes the allowlist (or replace with a safe fallback/disable
the Apply button), ensuring the anchor inside Button uses the
sanitized/validated URL and preserves target="_blank" rel="noopener noreferrer".

In `@scholarship-finder/src/components/ScholarshipCard.tsx`:
- Around line 8-18: The ScholarshipCard component currently renders
scholarship.applicationLink directly; validate and allowlist only http and https
schemes before enabling any link action: in ScholarshipCard, check
scholarship.applicationLink (and any place using it) for a safe URL (startsWith
"http://" or "https://" or use a URL parser to inspect protocol) and only render
an actionable anchor/button or set Card's action when the scheme is allowed; if
the URL is missing or unsafe (e.g., "javascript:"/"data:"), render a
disabled/unchangeable fallback (e.g., non-clickable text or disabled button) and
avoid setting href/onClick that would navigate to the raw value so clicks cannot
execute unsafe code.

In `@scholarship-finder/src/components/SearchForm.tsx`:
- Around line 71-119: Generate unique IDs via React's useId() (e.g., const
idScholarshipType = useId(), idUniversity = useId(), idRegion = useId()) and
attach them to each form control and label: set the label htmlFor to the
matching id and pass the id into the corresponding control (the Input for
university and the SelectTrigger for scholarshipType and region). Update the
Scholarship Type Select (value={scholarshipType} ...), the University Input
(value={university}, onChange={setUniversity}), and the Region Select
(value={region} ...) to accept the id prop so the labels are properly associated
for screen readers. Ensure nothing else about behavior changes (keep
disabled={isLoading} and existing props).

In `@scholarship-finder/src/components/ui/carousel.tsx`:
- Around line 70-81: The handleKeyDown callback currently prevents arrow keys
globally and ignores vertical carousels; update handleKeyDown to first
early-return when the event target is a form input (e.g., tagName 'INPUT',
'TEXTAREA', 'SELECT') or has isContentEditable true to avoid blocking cursor
movement, and then branch on an orientation prop (e.g., orientation ===
'horizontal' vs 'vertical') so ArrowLeft/ArrowRight only trigger
scrollPrev/scrollNext for horizontal carousels and ArrowUp/ArrowDown trigger
scrollPrev/scrollNext for vertical ones; keep calling event.preventDefault()
only when you actually handle the key and preserve the existing dependency array
[scrollPrev, scrollNext] (add orientation if needed).

In `@scholarship-finder/src/components/ui/chart.tsx`:
- Around line 61-88: The CSS output is vulnerable to injection because the
ChartStyle component interpolates id and raw color strings into a stylesheet;
update ChartStyle to escape the id when building the selector (e.g., use
CSS.escape(id) or otherwise wrap/escape the value so the selector becomes
[data-chart="..."] safely) and validate/sanitize each computed color before
emitting a --color-<key> rule (e.g., implement a small isValidCssColor check or
whitelist/normalize values and skip or fallback on invalid values). Ensure you
apply these fixes where ChartStyle uses id and where it computes color from
itemConfig.theme/itemConfig.color (and keep THEMES mapping usage intact).

In `@scholarship-finder/src/components/ui/collapsible.tsx`:
- Around line 5-7: The local exports CollapsibleTrigger and CollapsibleContent
are assigned non-existent Radix members; replace
CollapsiblePrimitive.CollapsibleTrigger with CollapsiblePrimitive.Trigger and
CollapsiblePrimitive.CollapsibleContent with CollapsiblePrimitive.Content so
CollapsibleTrigger = CollapsiblePrimitive.Trigger and CollapsibleContent =
CollapsiblePrimitive.Content (update any references to
CollapsibleTrigger/CollapsibleContent accordingly).

In `@scholarship-finder/src/components/ui/input-otp.tsx`:
- Line 44: The component uses a custom CSS animation class "animate-caret-blink"
(seen in input-otp.tsx as the div with className "animate-caret-blink") but the
keyframes/animation are not defined; fix by adding a "caret-blink" keyframes
entry and an "caret-blink" animation in your Tailwind config's extend.keyframes
and extend.animation (tailwind.config.ts) so "animate-caret-blink" maps to
"caret-blink 1.2s steps(2, start) infinite" (or switch to the tw-animate-css
plugin which already includes this animation).

In `@scholarship-finder/src/components/ui/sonner.tsx`:
- Around line 1-7: The file uses React.ComponentProps (type alias) but never
imports React, causing a missing symbol; add an import for React so the
ComponentProps type resolves (e.g., import React from "react" or import type
React from "react") at the top of the file alongside existing imports so the
Toaster type alias (type ToasterProps = React.ComponentProps<typeof Sonner>) and
the Toaster component compile correctly.

In `@scholarship-finder/src/integrations/supabase/client.ts`:
- Around line 5-16: Add a module-load guard that validates SUPABASE_URL and
SUPABASE_PUBLISHABLE_KEY before calling createClient: check the imported
constants (SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY) and if either is missing or
falsy throw an explicit Error with a clear message (e.g., "Missing
VITE_SUPABASE_URL or VITE_SUPABASE_PUBLISHABLE_KEY") so
createClient<Database>(...) is never invoked with undefined values; update the
top of the file where SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY are declared to
perform this validation prior to exporting the supabase client.

In `@scholarship-finder/supabase/functions/search-scholarships/index.ts`:
- Around line 108-172: The AI-returned scholarshipUrls are untrusted and
currently mapped into agentPromises without validation or limits, which can
spawn unbounded parallel agents; after the JSON.parse of content
(scholarshipUrls) validate that scholarshipUrls is an Array, filter out entries
missing required fields (e.g., name, url, description), slice the array to a
safe concurrency cap (e.g., 8), and throw an error if no valid entries remain so
agentPromises.map(...) (the agentPromises creation and subsequent Mino calls)
only iterates a validated, bounded list.
- Around line 26-291: The ReadableStream lacks cancellation so downstream
Lovable and Mino fetches keep running; create an AbortController (e.g.,
abortController) before calling req.json(), pass abortController.signal into the
Lovable AI fetch (the fetch to
"https://ai.gateway.lovable.dev/v1/chat/completions") and into each Mino fetch
(the fetch to "https://mino.ai/v1/automation/run-sse" inside the agentPromises),
and implement a cancel() handler on the ReadableStream that calls
abortController.abort() to terminate all in-flight requests when the client
disconnects; ensure error handling in your existing try/catch (used around
parsing and agent work) gracefully handles AbortError cases.
🟡 Minor comments (19)
scholarship-finder/src/components/ui/drawer.tsx-25-41 (1)

25-41: ⚠️ Potential issue | 🟡 Minor

Mark the grab handle as decorative.
The grab indicator is purely visual; hide it from screen readers to reduce noise.

✅ Suggested fix
-      <div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
+      <div
+        aria-hidden="true"
+        className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted"
+      />
scholarship-finder/src/components/ui/menubar.tsx-185-188 (1)

185-188: ⚠️ Potential issue | 🟡 Minor

Typo: displayname should be displayName.

The property name uses lowercase n instead of the correct camelCase displayName. This prevents React DevTools from showing the component's proper name during debugging.

🔧 Proposed fix
 const MenubarShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
   return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />;
 };
-MenubarShortcut.displayname = "MenubarShortcut";
+MenubarShortcut.displayName = "MenubarShortcut";
scholarship-finder/src/components/ui/skeleton.tsx-1-5 (1)

1-5: ⚠️ Potential issue | 🟡 Minor

Missing React import for type reference.

React.HTMLAttributes<HTMLDivElement> is used without importing React. This will cause a TypeScript error. All other UI components in this project explicitly import React, including card.tsx which uses the same type pattern.

🔧 Proposed fix
+import * as React from "react";
+
 import { cn } from "@/lib/utils";

 function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
scholarship-finder/src/components/ui/breadcrumb.tsx-80-80 (1)

80-80: ⚠️ Potential issue | 🟡 Minor

Typo in displayName.

"BreadcrumbElipssis" should be "BreadcrumbEllipsis" (correct spelling: one 'l', two 's's in the middle).

Proposed fix
-BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
+BreadcrumbEllipsis.displayName = "BreadcrumbEllipsis";
scholarship-finder/src/components/ui/breadcrumb.tsx-7-13 (1)

7-13: ⚠️ Potential issue | 🟡 Minor

Unused separator prop declared but not implemented.

The separator prop is defined in the component's type signature (line 10) but is never used in the implementation. It gets spread into the nav element where it has no effect. Either implement the separator functionality or remove the prop from the type.

Proposed fix to remove unused prop
 const Breadcrumb = React.forwardRef<
   HTMLElement,
-  React.ComponentPropsWithoutRef<"nav"> & {
-    separator?: React.ReactNode;
-  }
+  React.ComponentPropsWithoutRef<"nav">
 >(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
scholarship-finder/src/components/ui/carousel.tsx-91-103 (1)

91-103: ⚠️ Potential issue | 🟡 Minor

Unsubscribe from the "reInit" listener on cleanup.

The "reInit" listener is subscribed on line 97 but never unsubscribed in the cleanup function, creating a listener leak that accumulates on unmount or when api/onSelect changes.

Suggested fix
      return () => {
        api?.off("select", onSelect);
+       api?.off("reInit", onSelect);
      };
scholarship-finder/src/components/ui/chart.tsx-163-214 (1)

163-214: ⚠️ Potential issue | 🟡 Minor

Use stable React keys and render zero values in the tooltip.

The key={item.dataKey} prop lacks a fallback and can be undefined or duplicate across items, causing React warnings. Additionally, item.value && … skips rendering when the value is 0, a valid numeric result that should display.

Suggested fixes
          {payload.map((item, index) => {
            const key = `${nameKey || item.name || item.dataKey || "value"}`;
            const itemConfig = getPayloadConfigFromPayload(config, item, key);
            const indicatorColor = color || item.payload.fill || item.color;

            return (
              <div
-               key={item.dataKey}
+               key={`${item.dataKey ?? item.name ?? index}`}
                 className={cn(
                   "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
                   indicator === "dot" && "items-center",
                 )}
               >
                {...}
-               {item.value && (
+               {item.value !== undefined && item.value !== null && (
                  <span className="font-mono font-medium tabular-nums text-foreground">
-                   {item.value.toLocaleString()}
+                   {typeof item.value === "number"
+                     ? item.value.toLocaleString()
+                     : String(item.value)}
                  </span>
                )}
scholarship-finder/src/components/ui/chart.tsx-244-269 (1)

244-269: ⚠️ Potential issue | 🟡 Minor

Use stable legend keys and provide a label fallback.

key={item.value} can collide across multiple items, and itemConfig?.label has no fallback, resulting in blank legend entries. Use item.dataKey (or index as fallback) for the key, and provide a fallback label value similar to how the tooltip component handles it.

✅ Suggested fix
-      {payload.map((item) => {
+      {payload.map((item, index) => {
         const key = `${nameKey || item.dataKey || "value"}`;
         const itemConfig = getPayloadConfigFromPayload(config, item, key);
+        const legendKey = `${item.dataKey ?? item.value ?? index}`;
+        const label = itemConfig?.label ?? item.value ?? item.dataKey;

         return (
           <div
-            key={item.value}
+            key={legendKey}
             className={cn("flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground")}
           >
@@
-            {itemConfig?.label}
+            {label}
           </div>
         );
       })}
scholarship-finder/index.html-6-19 (1)

6-19: ⚠️ Potential issue | 🟡 Minor

Update placeholder metadata to reflect the actual app.

The title/description/OG/Twitter fields still reference the “Lovable” placeholders, which will leak into SEO and social previews. Please align them with the Scholarship Finder branding.

✏️ Suggested update
-    <title>Lovable App</title>
-    <meta name="description" content="Lovable Generated Project" />
+    <title>Scholarship Finder</title>
+    <meta name="description" content="Find scholarships by type, university, and region." />

-    <meta property="og:title" content="Lovable App" />
-    <meta property="og:description" content="Lovable Generated Project" />
+    <meta property="og:title" content="Scholarship Finder" />
+    <meta property="og:description" content="Find scholarships by type, university, and region." />
scholarship-finder/README.md-77-84 (1)

77-84: ⚠️ Potential issue | 🟡 Minor

Align the “Tech Stack” section with the actual tooling.

The README lists “Next.js (TypeScript),” but the repository scaffolding is Vite + React + TS. This mismatch can confuse contributors and operators.

You can verify with:

#!/bin/bash
# Confirm README tech stack and actual tooling
rg -n 'Next\.js|Vite' scholarship-finder/README.md
rg -n 'vite\.config|createRoot|main\.tsx' -S
✏️ Suggested edit
-Next.js (TypeScript)
+Vite + React (TypeScript)
scholarship-finder/src/components/ui/card.tsx-17-20 (1)

17-20: ⚠️ Potential issue | 🟡 Minor

Align CardTitle ref type with the rendered heading.

CardTitle renders an <h3> (HTMLHeadingElement) but the ref is typed as HTMLParagraphElement. This type mismatch will cause TypeScript errors and incorrect type hints for consumers.

🛠️ Suggested fix
-const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
+const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
   ({ className, ...props }, ref) => (
     <h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
   ),
scholarship-finder/src/components/ui/alert.tsx-29-39 (1)

29-39: ⚠️ Potential issue | 🟡 Minor

Align ref/props types with rendered elements.

AlertTitle and AlertDescription forwardRef generics don't match the actual elements rendered:

  • AlertTitle renders <h5> but ref type is HTMLParagraphElement (should be HTMLHeadingElement)
  • AlertDescription renders <div> but both ref and props types are HTMLParagraphElement (should be HTMLDivElement)

This mis-typing can mislead TypeScript tooling and consumers of these components.

Suggested fix
-const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
+const AlertTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
   ({ className, ...props }, ref) => (
     <h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
   ),
 );

-const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
+const AlertDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
   ({ className, ...props }, ref) => (
     <div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
   ),
 );
scholarship-finder/src/components/ui/form.tsx-33-52 (1)

33-52: ⚠️ Potential issue | 🟡 Minor

Guard context usage before calling getFieldState.
getFieldState runs before verifying FormField/FormItem context. When misused, this yields unclear errors. Check fieldContext.name and itemContext.id first and throw explicit errors.

🛡️ Suggested defensive guards
 const useFormField = () => {
   const fieldContext = React.useContext(FormFieldContext);
   const itemContext = React.useContext(FormItemContext);
   const { getFieldState, formState } = useFormContext();
 
-  const fieldState = getFieldState(fieldContext.name, formState);
-
-  if (!fieldContext) {
+  if (!fieldContext?.name) {
     throw new Error("useFormField should be used within <FormField>");
   }
+  if (!itemContext?.id) {
+    throw new Error("useFormField should be used within <FormItem>");
+  }
+
+  const fieldState = getFieldState(fieldContext.name, formState);
scholarship-finder/src/test/setup.ts-3-15 (1)

3-15: ⚠️ Potential issue | 🟡 Minor

dispatchEvent mock should return boolean.

The dispatchEvent method in the matchMedia mock returns void, but per the EventTarget.dispatchEvent spec it should return boolean. This could cause issues if any code checks the return value.

🔧 Proposed fix
     addEventListener: () => {},
     removeEventListener: () => {},
-    dispatchEvent: () => {},
+    dispatchEvent: () => false,
   }),
scholarship-finder/src/hooks/use-mobile.tsx-5-18 (1)

5-18: ⚠️ Potential issue | 🟡 Minor

Initial render returns false regardless of actual viewport.

The hook returns !!isMobile which coerces undefined to false on the initial render (before the effect runs). This causes mobile devices to briefly see the desktop layout before hydration completes.

Consider initializing with a server-safe check or returning undefined to let consumers handle the loading state.

🔧 Option: Return undefined during SSR for explicit handling
-export function useIsMobile() {
-  const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
+export function useIsMobile(): boolean | undefined {
+  const [isMobile, setIsMobile] = React.useState<boolean | undefined>(() => {
+    if (typeof window === "undefined") return undefined;
+    return window.innerWidth < MOBILE_BREAKPOINT;
+  });

   React.useEffect(() => {
     const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
     const onChange = () => {
       setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
     };
     mql.addEventListener("change", onChange);
     setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
     return () => mql.removeEventListener("change", onChange);
   }, []);

-  return !!isMobile;
+  return isMobile;
 }
scholarship-finder/src/pages/NotFound.tsx-16-18 (1)

16-18: ⚠️ Potential issue | 🟡 Minor

Use <Link> instead of <a> for internal navigation.

Using an anchor tag causes a full page reload, losing SPA benefits (state, faster navigation). Since you're already importing from react-router-dom, use Link for client-side routing.

🔧 Proposed fix
-import { useLocation } from "react-router-dom";
+import { useLocation, Link } from "react-router-dom";
 import { useEffect } from "react";
-        <a href="/" className="text-primary underline hover:text-primary/90">
+        <Link to="/" className="text-primary underline hover:text-primary/90">
           Return to Home
-        </a>
+        </Link>
scholarship-finder/src/components/ui/sidebar.tsx-153-169 (1)

153-169: ⚠️ Potential issue | 🟡 Minor

Mobile branch is missing ref forwarding and className/props handling.

The mobile branch (lines 153–170) passes ...props to Sheet (a non-DOM component) and does not forward ref or merge the className passed to Sidebar, creating an inconsistency with the desktop branch. This prevents custom classes, data attributes, and ref forwarding from working correctly on mobile.

Forward ref to SheetContent, merge className with cn(), and spread ...props on SheetContent instead of Sheet.

scholarship-finder/src/components/ui/sidebar.tsx-58-69 (1)

58-69: ⚠️ Potential issue | 🟡 Minor

Use previous state in functional updates for uncontrolled mode.

Line 60 resolves functional updates against open from the render closure, causing back-to-back toggles (like in toggleSidebar()) to collapse into a single state change. When uncontrolled, use the setState updater form to access the actual previous state.

🐛 Suggested fix
  const setOpen = React.useCallback(
    (value: boolean | ((value: boolean) => boolean)) => {
+     if (setOpenProp) {
+       const openState = typeof value === "function" ? value(open) : value;
+       setOpenProp(openState);
+       document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
+       return;
+     }
+
-      const openState = typeof value === "function" ? value(open) : value;
-      if (setOpenProp) {
-        setOpenProp(openState);
-      } else {
-        _setOpen(openState);
+      _setOpen((prev) => {
+        const openState = typeof value === "function" ? value(prev) : value;
+        document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
+        return openState;
+      });
     }
-
-      // This sets the cookie to keep the sidebar state.
-      document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
    },
    [setOpenProp, open],
  );
scholarship-finder/src/components/ui/sidebar.tsx-375-392 (1)

375-392: ⚠️ Potential issue | 🟡 Minor

Set explicit type="button" on sidebar action buttons to prevent unwanted form submissions.

HTML buttons without an explicit type default to type="submit" inside forms. SidebarGroupAction, SidebarMenuButton, and SidebarMenuAction currently render <button> elements without specifying a type. If these components are ever placed inside a form context, they will incorrectly trigger form submission. Set type="button" as the default when asChild is false to ensure correct behavior.

🔧 Suggested fix (default button type)
-const SidebarGroupAction = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button"> & { asChild?: boolean }>(
-  ({ className, asChild = false, ...props }, ref) => {
+const SidebarGroupAction = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button"> & { asChild?: boolean }>(
+  ({ className, asChild = false, type, ...props }, ref) => {
@@
-      <Comp
+      <Comp
         ref={ref}
         data-sidebar="group-action"
+        type={asChild ? undefined : type ?? "button"}
         className={cn(
           "absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
@@
-const SidebarMenuButton = React.forwardRef<
+const SidebarMenuButton = React.forwardRef<
   HTMLButtonElement,
   React.ComponentProps<"button"> & {
     asChild?: boolean;
     isActive?: boolean;
     tooltip?: string | React.ComponentProps<typeof TooltipContent>;
   } & VariantProps<typeof sidebarMenuButtonVariants>
->(({ asChild = false, isActive = false, variant = "default", size = "default", tooltip, className, ...props }, ref) => {
+>(({ asChild = false, isActive = false, variant = "default", size = "default", tooltip, className, type, ...props }, ref) => {
@@
     <Comp
       ref={ref}
       data-sidebar="menu-button"
       data-size={size}
       data-active={isActive}
+      type={asChild ? undefined : type ?? "button"}
       className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
       {...props}
     />
@@
-const SidebarMenuAction = React.forwardRef<
+const SidebarMenuAction = React.forwardRef<
   HTMLButtonElement,
   React.ComponentProps<"button"> & {
     asChild?: boolean;
     showOnHover?: boolean;
   }
->(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
+>(({ className, asChild = false, showOnHover = false, type, ...props }, ref) => {
@@
     <Comp
       ref={ref}
       data-sidebar="menu-action"
+      type={asChild ? undefined : type ?? "button"}
       className={cn(
         "absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform peer-hover/menu-button:text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
🧹 Nitpick comments (16)
scholarship-finder/.gitignore (1)

1-24: Consider adding patterns for build caches and test coverage.

While not critical, adding patterns for common development artifacts can help keep the repository clean as the project evolves.

📦 Optional patterns to consider
 *.local
 
+# Build caches
+.cache
+.vite
+
+# Test coverage
+coverage
+
 # Editor directories and files
scholarship-finder/src/components/ui/slider.tsx (1)

6-19: Document single-thumb limitation or prepare for multi-thumb support.

The Slider component is not currently used elsewhere in the codebase. However, Radix Slider requires one Thumb per value in the array. If this component is later used with range inputs (value as an array with length > 1), the single hardcoded thumb will render incorrectly. Either explicitly document that this component supports single-value-only sliders, or refactor to render thumbs based on the value/defaultValue length to support range usage when needed (as shown in the anime-watch-hub implementation).

scholarship-finder/eslint.config.js (1)

20-23: Consider re‑enabling @typescript-eslint/no-unused-vars at least as warn.

Turning this off entirely can mask real defects and leave dead code behind. A common compromise is warn with _-prefixed ignore patterns.

Suggested rule tweak
   rules: {
     ...reactHooks.configs.recommended.rules,
     "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
-    "@typescript-eslint/no-unused-vars": "off",
+    "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
   },
scholarship-finder/src/components/ui/input-otp.tsx (1)

27-29: Consider adding defensive check for slot access.

Accessing inputOTPContext.slots[index] without validation could throw if an invalid index is passed or if the context is not properly initialized. While shadcn patterns typically trust consumers to use components correctly, a guard would improve robustness.

🛡️ Optional defensive check
 >(({ index, className, ...props }, ref) => {
   const inputOTPContext = React.useContext(OTPInputContext);
-  const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
+  const slot = inputOTPContext.slots[index];
+  const { char, hasFakeCaret, isActive } = slot ?? {};
scholarship-finder/src/components/SelectableScholarshipCard.tsx (1)

6-6: Remove unused import.

Checkbox is imported but not used. The component implements a custom checkbox UI using a styled div with a Check icon (lines 29-42).

Suggested fix
-import { Checkbox } from "@/components/ui/checkbox";

Alternatively, consider using the imported Checkbox component for consistency with the UI library.

scholarship-finder/src/components/CompareDashboard.tsx (1)

58-66: Use row header cells for feature labels.
The leftmost “Feature” column cells are row headers; use <th scope="row"> so screen readers announce labels for each row’s data.

♿ Example for one row (apply to each feature row)
-                  <td className="p-4 font-semibold bg-orange-50 text-orange-900">
+                  <th scope="row" className="p-4 font-semibold bg-orange-50 text-orange-900">
                     <div className="flex items-center gap-2">
                       <Building2 className="w-4 h-4" />
                       Provider
                     </div>
-                  </td>
+                  </th>
scholarship-finder/src/components/LoadingAnimation.tsx (2)

186-194: Unused isExpanded prop in AgentCard.

The isExpanded parameter is passed to AgentCard but never used within the component. The expanded modal is rendered in the parent component using expandedAgent state directly. Consider removing this unused prop to reduce confusion.

♻️ Proposed fix
 function AgentCard({ 
   agent, 
-  isExpanded, 
   onExpand 
 }: { 
   agent: AgentStatus; 
-  isExpanded: boolean; 
   onExpand: () => void;
 }) {

And update the call site at line 101-106:

             <AgentCard 
               key={agent.agentId} 
               agent={agent} 
-              isExpanded={expandedAgent === agent.agentId}
               onExpand={() => setExpandedAgent(expandedAgent === agent.agentId ? null : agent.agentId)}
             />

14-24: Inconsistent status enum value with similar interface in summer-school-finder.

This AgentStatus interface uses "complete" as a status value, while the similar AgentStatus interface in summer-school-finder/src/types/summer-school.ts (lines 23-31) uses "completed". Consider aligning these for consistency across the codebase if these modules may share logic or types in the future.

scholarship-finder/src/main.tsx (1)

1-5: Consider wrapping App in React.StrictMode.

Adding StrictMode helps identify potential problems during development by enabling additional checks and warnings for deprecated patterns, unsafe lifecycles, and side effects.

♻️ Proposed change
-import { createRoot } from "react-dom/client";
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
 import App from "./App.tsx";
 import "./index.css";

-createRoot(document.getElementById("root")!).render(<App />);
+createRoot(document.getElementById("root")!).render(
+  <StrictMode>
+    <App />
+  </StrictMode>
+);
scholarship-finder/src/test/example.test.ts (1)

1-7: Placeholder test provides no real coverage.

This scaffold test verifies the test infrastructure works but provides no meaningful coverage. Consider adding tests for the actual components (e.g., ScholarshipCard, LoadingAnimation) or removing this file once real tests are added.

scholarship-finder/package.json (1)

1-5: Package name doesn't match project folder.

The package name "vite_react_shadcn_ts" appears to be a template default and doesn't match the project folder scholarship-finder. Consider updating it for clarity.

♻️ Proposed fix
 {
-  "name": "vite_react_shadcn_ts",
+  "name": "scholarship-finder",
   "private": true,
   "version": "0.0.0",
scholarship-finder/tsconfig.json (1)

9-14: Consider enabling stricter TypeScript checks for a new project.

Several important type-safety features are disabled:

  • strictNullChecks: false — This is the most impactful; disabling it allows null/undefined to flow unchecked, which is a common source of runtime errors
  • noImplicitAny: false — Permits implicit any types, reducing type coverage

For a greenfield project, starting with stricter settings helps catch bugs early and establishes good practices. You can always relax specific rules later if needed.

🔧 Suggested stricter configuration
   "compilerOptions": {
     "baseUrl": ".",
     "paths": {
       "@/*": ["./src/*"]
     },
-    "noImplicitAny": false,
+    "noImplicitAny": true,
     "noUnusedParameters": false,
     "skipLibCheck": true,
     "allowJs": true,
     "noUnusedLocals": false,
-    "strictNullChecks": false
+    "strictNullChecks": true
   }
scholarship-finder/src/components/CompareButton.tsx (1)

13-29: Simplify duplicate toast branches.

Both branches show the same description; you can collapse the conditions for a smaller, clearer handler.

♻️ Possible simplification
-    if (selectedCount === 0) {
-      toast({
-        title: "Select Scholarships",
-        description: "Please select at least 2 scholarships to compare.",
-        variant: "destructive",
-      });
-      return;
-    }
-    if (selectedCount < 2) {
+    if (selectedCount < 2) {
       toast({
-        title: "Select More Scholarships",
+        title: selectedCount === 0 ? "Select Scholarships" : "Select More Scholarships",
         description: "Please select at least 2 scholarships to compare.",
         variant: "destructive",
       });
       return;
     }
scholarship-finder/vite.config.ts (1)

7-19: Optional: make dev host/port configurable.

Hardcoding host/port can be limiting in shared dev setups; consider env overrides with sane defaults. The project already follows Vite's VITE_ prefix convention for environment variables, so VITE_DEV_SERVER_HOST and VITE_DEV_SERVER_PORT fit naturally into the existing pattern.

♻️ Possible tweak
-import { defineConfig } from "vite";
+import { defineConfig, loadEnv } from "vite";
@@
-export default defineConfig(({ mode }) => ({
-  server: {
-    host: "::",
-    port: 8080,
+export default defineConfig(({ mode }) => {
+  const env = loadEnv(mode, process.cwd(), "");
+  const host = env.VITE_DEV_SERVER_HOST ?? "::";
+  const port = Number(env.VITE_DEV_SERVER_PORT ?? 8080);
+  return ({
+    server: {
+      host,
+      port,
       hmr: {
         overlay: false,
       },
     },
     plugins: [react(), mode === "development" && componentTagger()].filter(Boolean),
     resolve: {
       alias: {
         "@": path.resolve(__dirname, "./src"),
       },
     },
-}));
+  });
+});
scholarship-finder/tsconfig.app.json (1)

18-23: Align TypeScript strictness with project standards.

This project's tsconfig.app.json disables strict checking while most other projects in the monorepo (bestbet, anime-watch-hub, competitor-analysis, tinyskills) enable it. While the loose settings are intentional here, tightening strict, noImplicitAny, and noUnused* would improve consistency and catch issues earlier. Before re-enabling, audit the codebase for type safety violations that would need fixing.

scholarship-finder/src/hooks/useScholarshipSearch.ts (1)

200-208: Close the stream when ALL_COMPLETE arrives.

The reader is left open when you return early; canceling it prevents dangling connections if the server keeps the stream alive.

🔧 Suggested hardening
               if (data.type === "ALL_COMPLETE") {
                 setResults({
                   scholarships: data.scholarships || [],
                   searchSummary: data.searchSummary || "",
                 });
                 setIsLoading(false);
+                reader.cancel().catch(() => {});
                 return;
               }

Comment on lines +1 to +3
VITE_SUPABASE_PROJECT_ID="ikudbmsjgzirpyjagdgm"
VITE_SUPABASE_PUBLISHABLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImlrdWRibXNqZ3ppcnB5amFnZGdtIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njg5NjY5ODAsImV4cCI6MjA4NDU0Mjk4MH0.pjgHkm5fniTp0Dovn4U46VCPztrYBXjWxHEaLsLP5j0"
VITE_SUPABASE_URL="https://ikudbmsjgzirpyjagdgm.supabase.co"
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Do not commit .env files with real credentials to version control.

This file contains actual Supabase credentials and should not be committed. While the anon key is designed to be public (it's embedded in client-side code), committing .env files sets a dangerous precedent and can lead to accidental exposure of sensitive secrets in the future.

Additionally, the values should not be quoted in .env files per dotenv conventions.

Recommended approach:

  1. Add .env to .gitignore
  2. Create a .env.example with placeholder values for documentation
📁 Proposed .env.example
-VITE_SUPABASE_PROJECT_ID="ikudbmsjgzirpyjagdgm"
-VITE_SUPABASE_PUBLISHABLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImlrdWRibXNqZ3ppcnB5amFnZGdtIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njg5NjY5ODAsImV4cCI6MjA4NDU0Mjk4MH0.pjgHkm5fniTp0Dovn4U46VCPztrYBXjWxHEaLsLP5j0"
-VITE_SUPABASE_URL="https://ikudbmsjgzirpyjagdgm.supabase.co"
+VITE_SUPABASE_PROJECT_ID=your_project_id
+VITE_SUPABASE_PUBLISHABLE_KEY=your_anon_key
+VITE_SUPABASE_URL=https://your_project_id.supabase.co

Also add to .gitignore:

.env
.env.local
🧰 Tools
🪛 dotenv-linter (4.0.0)

[warning] 1-1: [QuoteCharacter] The value has quote characters (', ")

(QuoteCharacter)


[warning] 2-2: [QuoteCharacter] The value has quote characters (', ")

(QuoteCharacter)


[warning] 3-3: [QuoteCharacter] The value has quote characters (', ")

(QuoteCharacter)

🪛 Gitleaks (8.30.0)

[high] 2-2: Uncovered a JSON Web Token, which may lead to unauthorized access to web applications and sensitive user data.

(jwt)

🤖 Prompt for AI Agents
In `@scholarship-finder/.env` around lines 1 - 3, Summary: Sensitive Supabase
credentials were committed in .env; remove them and replace with safe
placeholders. Fix: delete the committed .env from the repository (stop tracking
it and remove from the commit/branch), add .env to .gitignore and also
.env.local, create a .env.example containing the keys VITE_SUPABASE_PROJECT_ID,
VITE_SUPABASE_PUBLISHABLE_KEY, and VITE_SUPABASE_URL with placeholder values (no
quoted values) for documentation, and commit only the .env.example; ensure any
real keys are rotated/invalidated outside the repo and confirm no credentials
remain in history or subsequent commits.

Comment on lines +10 to +13
node_modules
dist
dist-ssr
*.local
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add .env patterns to prevent credential leakage.

Environment files containing sensitive credentials (Supabase API keys, etc.) are not ignored. This creates a risk that developers could accidentally commit secrets to the repository.

🔒 Proposed fix to add environment file patterns
 node_modules
 dist
 dist-ssr
 *.local
+
+# Environment variables
+.env
+.env.local
+.env*.local
📝 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
node_modules
dist
dist-ssr
*.local
node_modules
dist
dist-ssr
*.local
# Environment variables
.env
.env.local
.env*.local
🤖 Prompt for AI Agents
In `@scholarship-finder/.gitignore` around lines 10 - 13, The repository
.gitignore is missing environment file patterns; update the .gitignore to ignore
environment/credential files by adding patterns like .env, .env.local, .env.*
and .env.*.local (and any other project-specific env variants) so that Supabase
keys and other secrets are not committed; ensure these patterns are appended to
the existing entries (node_modules, dist, dist-ssr, *.local) so all common .env
files are excluded from version control.

Comment on lines +11 to +15
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
Copy link

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:

#!/bin/bash
# First, examine the full eslint.config.js file
cd scholarship-finder
wc -l eslint.config.js

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 92


🏁 Script executed:

#!/bin/bash
# Read the full eslint.config.js to understand complete configuration
cd scholarship-finder
cat -n eslint.config.js

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 1019


🏁 Script executed:

#!/bin/bash
# Search for Deno/Node globals in TS/TSX files as suggested in review
cd scholarship-finder
rg -n -g '*.ts' -g '*.tsx' '\bDeno\b|\bprocess\b|\bBuffer\b' -C2

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 1012


🏁 Script executed:

#!/bin/bash
# Check if there are ESLint overrides for supabase/functions in the config
cd scholarship-finder
rg -n 'override|supabase|functions' eslint.config.js

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 55


🏁 Script executed:

#!/bin/bash
# Check project structure to see if supabase/functions should be linted
cd scholarship-finder
ls -la | head -20
fd -t d 'supabase|functions' --max-depth 2

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 1324


🏁 Script executed:

#!/bin/bash
# Search for other Deno/Node globals in TS files (process, Buffer, etc.)
cd scholarship-finder
rg -n -g '*.ts' -g '*.tsx' '\bprocess\b|\bBuffer\b' --max-count 5

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 55


🏁 Script executed:

#!/bin/bash
# Check if there are more TS files in supabase/functions
cd scholarship-finder
find supabase/functions -name '*.ts' -type f

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 120


🏁 Script executed:

#!/bin/bash
# Check what other TS files might be in the project to see full scope
cd scholarship-finder
find . -name '*.ts' -o -name '*.tsx' | grep -v node_modules | head -20

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 732


Add ESLint override with Deno globals for serverless functions.

The config applies to all .ts/.tsx files but only defines browser globals. The repo contains Deno serverless functions (supabase/functions/search-scholarships/index.ts) that use Deno.serve() and Deno.env.get(), which will be flagged as undefined. Add an override for backend files to include globals.deno:

Suggested override
{
  files: ["supabase/functions/**/*.ts"],
  languageOptions: {
    globals: globals.deno,
  },
}
🤖 Prompt for AI Agents
In `@scholarship-finder/eslint.config.js` around lines 11 - 15, The ESLint config
currently sets languageOptions.globals to globals.browser for all TypeScript
files, causing Deno globals (e.g., Deno.serve, Deno.env.get) to be flagged; add
an override entry in eslint.config.js that targets the backend functions (use
files pattern like "supabase/functions/**/*.ts") and set languageOptions.globals
to globals.deno for that override so those files recognize Deno globals; keep
the existing browser globals for the general files setting and ensure the
override object is appended to the top-level overrides array.

Comment on lines +177 to +190
{/* Apply Links */}
<tr>
<td className="p-4 font-semibold bg-orange-50 text-orange-900">Apply</td>
{scholarships.map((s) => (
<td key={s.id} className="p-4">
<Button
asChild
className="bg-orange-500 hover:bg-orange-600 text-white"
>
<a href={s.applicationLink} target="_blank" rel="noopener noreferrer">
Apply Now
<ExternalLink className="w-4 h-4 ml-2" />
</a>
</Button>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Guard external apply links against unsafe URL schemes.
Line 186 renders s.applicationLink directly. Please reuse the same http/https allowlist approach as in ScholarshipCard (or sanitize before persisting) to prevent javascript: links from executing in-page.

🤖 Prompt for AI Agents
In `@scholarship-finder/src/components/CompareDashboard.tsx` around lines 177 -
190, The Apply link in CompareDashboard renders s.applicationLink directly
inside the anchor, which allows unsafe schemes like javascript:; update
CompareDashboard to reuse the same http/https allowlist/sanitizer used by
ScholarshipCard (or call the existing utility such as isSafeUrl/sanitizeUrl if
present) before rendering: validate s.applicationLink in the scholarships.map
iteration and only render the anchor href when the URL passes the allowlist (or
replace with a safe fallback/disable the Apply button), ensuring the anchor
inside Button uses the sanitized/validated URL and preserves target="_blank"
rel="noopener noreferrer".

Comment on lines +8 to +18
interface ScholarshipCardProps {
scholarship: Scholarship;
index: number;
}

export function ScholarshipCard({ scholarship, index }: ScholarshipCardProps) {
return (
<Card
className="overflow-hidden border-border/50 hover:shadow-lg transition-all duration-300 animate-fade-in"
style={{ animationDelay: `${index * 100}ms` }}
>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Validate external application links before rendering.
Line 126 uses scholarship.applicationLink directly; if data is scraped/AI-sourced, a javascript: or data: URL can execute code on click. Please allowlist http/https (or sanitize upstream) and disable the action otherwise.

🔒 Suggested guard for unsafe URL schemes
+const isSafeExternalUrl = (value: string) => {
+  try {
+    const url = new URL(value);
+    return url.protocol === "http:" || url.protocol === "https:";
+  } catch {
+    return false;
+  }
+};

 export function ScholarshipCard({ scholarship, index }: ScholarshipCardProps) {
+  const applicationHref = isSafeExternalUrl(scholarship.applicationLink)
+    ? scholarship.applicationLink
+    : undefined;
   return (
@@
-          <a href={scholarship.applicationLink} target="_blank" rel="noopener noreferrer">
+          <a
+            href={applicationHref}
+            aria-disabled={!applicationHref}
+            onClick={(e) => {
+              if (!applicationHref) e.preventDefault();
+            }}
+            target="_blank"
+            rel="noopener noreferrer"
+          >
             Apply Now
             <ExternalLink className="w-4 h-4 ml-2" />
           </a>

Also applies to: 121-129

🤖 Prompt for AI Agents
In `@scholarship-finder/src/components/ScholarshipCard.tsx` around lines 8 - 18,
The ScholarshipCard component currently renders scholarship.applicationLink
directly; validate and allowlist only http and https schemes before enabling any
link action: in ScholarshipCard, check scholarship.applicationLink (and any
place using it) for a safe URL (startsWith "http://" or "https://" or use a URL
parser to inspect protocol) and only render an actionable anchor/button or set
Card's action when the scheme is allowed; if the URL is missing or unsafe (e.g.,
"javascript:"/"data:"), render a disabled/unchangeable fallback (e.g.,
non-clickable text or disabled button) and avoid setting href/onClick that would
navigate to the raw value so clicks cannot execute unsafe code.

{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink h-4 w-px bg-foreground duration-1000" />
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for Tailwind configuration files
fd -e js -e ts -e cjs -e mjs "tailwind.config" --exec cat {} \; 2>/dev/null

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 6725


🏁 Script executed:

# Check if there's a CSS or configuration file that defines the animation separately
fd -e css -e scss "input-otp" --exec cat {} \;

# Also search for any definitions of caret-blink in the codebase
rg "caret-blink" -i

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 481


🏁 Script executed:

# Search for CSS files that might define the animation
fd -e css | head -20

# Check package.json for dependencies
cat package.json | grep -A 20 "dependencies\|devDependencies"

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 420


🏁 Script executed:

# Check the CSS files for the scholarship-finder project
cat scholarship-finder/src/App.css
echo "---"
cat scholarship-finder/src/index.css

# Also search for any `@keyframes` definitions
rg "@keyframes.*caret" -i

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 3906


🌐 Web query:

tailwindcss-animate caret-blink animation

💡 Result:

To get a caret blink utility in a Tailwind setup:

Tailwind CSS v4 (recommended): tw-animate-css

tw-animate-css is the Tailwind v4, CSS-first replacement and includes a built-in caret-blink animation class. Install + import it, then use caret-blink on the element you’re using as the “cursor” (often a small <span>/<div>). You can tune timing with Tailwind’s animation parameter utilities like duration-*. [1]

npm i -D tw-animate-css
/* app.css / globals.css */
`@import` "tailwindcss";
`@import` "tw-animate-css";
<span class="caret-blink duration-700">|</span>

Shadcn/ui deprecated tailwindcss-animate in favor of tw-animate-css (March 19, 2025). [2]

If you mean the actual text-input caret

Browsers already blink the input caret by default. If you’re trying to apply a custom caret animation, MDN notes you may need to disable the default blink using caret-animation: manual (this property is experimental). [3]

Sources: [1] [2] [3]


Add the missing animate-caret-blink keyframes definition.

The animate-caret-blink class is used but not defined in the Tailwind configuration or CSS files. The current tailwindcss-animate plugin does not provide this animation. Add the keyframes to tailwind.config.ts in the extend.keyframes section:

keyframes: {
  "caret-blink": {
    "0%, 49%, 100%": { opacity: "1" },
    "50%, 99%": { opacity: "0" },
  },
},
animation: {
  "caret-blink": "caret-blink 1.2s steps(2, start) infinite",
},

Alternatively, consider upgrading to tw-animate-css, the newer replacement for tailwindcss-animate that includes this animation built-in.

🤖 Prompt for AI Agents
In `@scholarship-finder/src/components/ui/input-otp.tsx` at line 44, The component
uses a custom CSS animation class "animate-caret-blink" (seen in input-otp.tsx
as the div with className "animate-caret-blink") but the keyframes/animation are
not defined; fix by adding a "caret-blink" keyframes entry and an "caret-blink"
animation in your Tailwind config's extend.keyframes and extend.animation
(tailwind.config.ts) so "animate-caret-blink" maps to "caret-blink 1.2s steps(2,
start) infinite" (or switch to the tw-animate-css plugin which already includes
this animation).

Comment on lines +1 to +7
import { useTheme } from "next-themes";
import { Toaster as Sonner, toast } from "sonner";

type ToasterProps = React.ComponentProps<typeof Sonner>;

const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's read the complete sonner.tsx file
cat -n scholarship-finder/src/components/ui/sonner.tsx

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 1138


🏁 Script executed:

# Check the tsconfig.json to see JSX configuration
fd tsconfig.json scholarship-finder --exec cat {} \;

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 441


🏁 Script executed:

# Also check if there's any type definitions or imports we might have missed
rg "import.*React" scholarship-finder/src/components/ui/sonner.tsx

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 55


🏁 Script executed:

# Check tsconfig.app.json for JSX transform settings
cat scholarship-finder/tsconfig.app.json

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 757


🏁 Script executed:

# Also check tsconfig.node.json in case JSX is configured there
cat scholarship-finder/tsconfig.node.json

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 553


🏁 Script executed:

# Let's also check if maybe React is imported as a side-effect or if the build actually works
rg "React" scholarship-finder/src/components/ui/sonner.tsx

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 129


🏁 Script executed:

# Let's check if there's a global types file or ambient declarations that might define React globally
fd "*.d.ts" scholarship-finder/src --exec cat {} \;

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 358


🏁 Script executed:

# Also check package.json to understand the React version and setup
rg '"react":|"typescript":' scholarship-finder/package.json -A 1

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 198


🏁 Script executed:

# Let's check for .d.ts files using glob pattern
fd --glob "*.d.ts" scholarship-finder/src

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 109


🏁 Script executed:

# Also let's check if there's an index.d.ts or vite-env.d.ts that might declare React globally
cat scholarship-finder/src/vite-env.d.ts 2>/dev/null || cat scholarship-finder/vite-env.d.ts 2>/dev/null || echo "No ambient types found"

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 110


🏁 Script executed:

# Let's check if React is imported anywhere in the app setup
rg "import.*React" scholarship-finder/src --max-count=5

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 3648


🏁 Script executed:

# Let's check if there's an eslint config that might catch this
fd eslint.config.js scholarship-finder

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 108


🏁 Script executed:

# Let's also verify the TypeScript would actually error by checking if there are any type checking results
cat scholarship-finder/src/components/ui/sonner.tsx | head -20

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 880


🏁 Script executed:

# Check if maybe there's a .eslintrc or similar that auto-imports
rg "eslintrc|\.eslintignore" scholarship-finder --files

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 4421


🏁 Script executed:

# Let's check the eslint config to see if there's auto-import settings
cat scholarship-finder/eslint.config.js

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 837


🏁 Script executed:

# Let's also double-check by looking at how other components in the same directory handle React.ComponentProps
rg "ComponentProps" scholarship-finder/src/components/ui/ --max-count=10

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 17422


Add missing React import for ComponentProps type usage.

React.ComponentProps requires React to be in scope. Although react-jsx config allows JSX without importing React, explicit type references like React.ComponentProps still need the import. All other UI components in this directory follow this pattern.

Suggested fix
+import type { ComponentProps } from "react";
 import { useTheme } from "next-themes";
 import { Toaster as Sonner, toast } from "sonner";

-type ToasterProps = React.ComponentProps<typeof Sonner>;
+type ToasterProps = ComponentProps<typeof Sonner>;
📝 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
import { useTheme } from "next-themes";
import { Toaster as Sonner, toast } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
import type { ComponentProps } from "react";
import { useTheme } from "next-themes";
import { Toaster as Sonner, toast } from "sonner";
type ToasterProps = ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
🤖 Prompt for AI Agents
In `@scholarship-finder/src/components/ui/sonner.tsx` around lines 1 - 7, The file
uses React.ComponentProps (type alias) but never imports React, causing a
missing symbol; add an import for React so the ComponentProps type resolves
(e.g., import React from "react" or import type React from "react") at the top
of the file alongside existing imports so the Toaster type alias (type
ToasterProps = React.ComponentProps<typeof Sonner>) and the Toaster component
compile correctly.

Comment on lines +5 to +16
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL;
const SUPABASE_PUBLISHABLE_KEY = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY;

// Import the supabase client like this:
// import { supabase } from "@/integrations/supabase/client";

export const supabase = createClient<Database>(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
auth: {
storage: localStorage,
persistSession: true,
autoRefreshToken: true,
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add explicit validation for required Supabase environment variables.

The Supabase JavaScript client (createClient) does not validate that the URL and publishable key are defined before initialization. If VITE_SUPABASE_URL or VITE_SUPABASE_PUBLISHABLE_KEY are undefined, the client will initialize with undefined values, and authentication or database operations will fail at runtime with unclear error messages. Add a guard clause at module load time to fail fast with a clear error if either variable is missing.

🛠️ Suggested fix
 const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL;
 const SUPABASE_PUBLISHABLE_KEY = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY;
+
+if (!SUPABASE_URL || !SUPABASE_PUBLISHABLE_KEY) {
+  throw new Error("Missing VITE_SUPABASE_URL or VITE_SUPABASE_PUBLISHABLE_KEY.");
+}
 
 export const supabase = createClient<Database>(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
   auth: {
     storage: localStorage,
     persistSession: true,
     autoRefreshToken: true,
   }
 });
📝 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
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL;
const SUPABASE_PUBLISHABLE_KEY = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY;
// Import the supabase client like this:
// import { supabase } from "@/integrations/supabase/client";
export const supabase = createClient<Database>(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
auth: {
storage: localStorage,
persistSession: true,
autoRefreshToken: true,
}
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL;
const SUPABASE_PUBLISHABLE_KEY = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY;
if (!SUPABASE_URL || !SUPABASE_PUBLISHABLE_KEY) {
throw new Error("Missing VITE_SUPABASE_URL or VITE_SUPABASE_PUBLISHABLE_KEY.");
}
// Import the supabase client like this:
// import { supabase } from "@/integrations/supabase/client";
export const supabase = createClient<Database>(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
auth: {
storage: localStorage,
persistSession: true,
autoRefreshToken: true,
}
});
🤖 Prompt for AI Agents
In `@scholarship-finder/src/integrations/supabase/client.ts` around lines 5 - 16,
Add a module-load guard that validates SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY
before calling createClient: check the imported constants (SUPABASE_URL,
SUPABASE_PUBLISHABLE_KEY) and if either is missing or falsy throw an explicit
Error with a clear message (e.g., "Missing VITE_SUPABASE_URL or
VITE_SUPABASE_PUBLISHABLE_KEY") so createClient<Database>(...) is never invoked
with undefined values; update the top of the file where SUPABASE_URL and
SUPABASE_PUBLISHABLE_KEY are declared to perform this validation prior to
exporting the supabase client.

Comment on lines +26 to +291
const stream = new ReadableStream({
async start(controller) {
const sendEvent = (data: object) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
};

try {
const { scholarshipType, university, region }: SearchParams = await req.json();
const MINO_API_KEY = Deno.env.get("MINO_API_KEY");
const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY");

if (!MINO_API_KEY) {
throw new Error("MINO_API_KEY not configured");
}
if (!LOVABLE_API_KEY) {
throw new Error("LOVABLE_API_KEY not configured");
}

const today = new Date();
const currentDate = today.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
const currentYear = today.getFullYear();

const locationContext = [
university ? `at ${university}` : "",
region ? `in ${region}` : "",
].filter(Boolean).join(" ");

// STEP 1: Use Lovable AI to get scholarship URLs
sendEvent({
type: "STEP",
step: 1,
message: "Finding scholarship websites..."
});

const aiPrompt = `Find 5-8 official scholarship provider websites for ${scholarshipType} scholarships ${locationContext}.

Return a JSON array of scholarship websites to search. Focus on:
- Official university financial aid pages
- Well-known scholarship foundations (Fulbright, Gates, Rhodes, etc.)
- Government scholarship programs
- Reputable scholarship aggregators

Return ONLY a JSON array like this:
[
{
"name": "MIT Financial Aid",
"url": "https://sfs.mit.edu/undergraduate-students/",
"description": "MIT's official financial aid office"
}
]

Include diverse sources: university-specific, national programs, and international opportunities if applicable.
Make sure all URLs are real, official websites.`;

const aiResponse = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${LOVABLE_API_KEY}`,
},
body: JSON.stringify({
model: "google/gemini-3-flash-preview",
messages: [
{ role: "system", content: "You are a scholarship research assistant. Return only valid JSON arrays." },
{ role: "user", content: aiPrompt },
],
temperature: 0.7,
max_tokens: 2000,
}),
});

if (!aiResponse.ok) {
throw new Error(`AI Gateway error: ${aiResponse.status}`);
}

const aiData = await aiResponse.json();
const content = aiData.choices?.[0]?.message?.content;

if (!content) {
throw new Error("No content from AI");
}

// Parse the URLs from AI response
let scholarshipUrls: ScholarshipUrl[];
try {
const cleanedContent = content.replace(/```json\n?|\n?```/g, "").trim();
scholarshipUrls = JSON.parse(cleanedContent);
} catch {
throw new Error("Failed to parse scholarship URLs");
}

sendEvent({
type: "URLS_FOUND",
urls: scholarshipUrls,
message: `Found ${scholarshipUrls.length} scholarship sources to search`
});

// STEP 2: Run Mino agents in parallel
sendEvent({
type: "STEP",
step: 2,
message: `Launching ${scholarshipUrls.length} browser agents...`
});

const goal = `You are searching for ${scholarshipType} scholarships ${locationContext}.

CURRENT DATE: ${currentDate}

For this scholarship provider, extract:
1. Scholarship name(s)
2. Award amounts
3. Application deadlines (MUST be after ${currentDate})
4. Eligibility requirements
5. How to apply / application link

Return a JSON object:
{
"scholarships": [
{
"id": "unique-id",
"name": "Scholarship Name",
"provider": "Organization",
"amount": "$X,XXX",
"deadline": "Month Day, Year",
"eligibility": ["Requirement 1", "Requirement 2"],
"description": "Brief description",
"applicationRequirements": ["Document 1", "Document 2"],
"applicationLink": "https://...",
"region": "${region || 'International'}",
"university": "${university || 'Various'}",
"type": "${scholarshipType}"
}
]
}

Only include scholarships with deadlines AFTER ${currentDate}.`;

// Start all Mino agents in parallel
const agentPromises = scholarshipUrls.map(async (site, index) => {
const agentId = `agent-${index}`;

sendEvent({
type: "AGENT_STARTED",
agentId,
siteName: site.name,
siteUrl: site.url,
description: site.description,
});

try {
const minoResponse = await fetch("https://mino.ai/v1/automation/run-sse", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": MINO_API_KEY,
},
body: JSON.stringify({
url: site.url,
goal: goal,
}),
});

if (!minoResponse.ok) {
throw new Error(`Mino error: ${minoResponse.status}`);
}

// Process SSE stream from Mino
const reader = minoResponse.body?.getReader();
if (!reader) throw new Error("No response body");

const decoder = new TextDecoder();
let buffer = "";

while (true) {
const { done, value } = await reader.read();
if (done) break;

buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";

for (const line of lines) {
if (line.startsWith("data: ")) {
const jsonStr = line.slice(6).trim();
if (!jsonStr || jsonStr === "[DONE]") continue;

try {
const data = JSON.parse(jsonStr);

// Forward streaming URL
if (data.type === "STREAMING_URL" && data.streamingUrl) {
sendEvent({
type: "AGENT_STREAMING",
agentId,
siteName: site.name,
streamingUrl: data.streamingUrl,
});
}

// Forward progress updates
if (data.type === "PROGRESS" && data.purpose) {
sendEvent({
type: "AGENT_PROGRESS",
agentId,
siteName: site.name,
message: data.purpose,
});
}

// Handle completion
if (data.type === "COMPLETE" && data.resultJson) {
const result = typeof data.resultJson === "string"
? JSON.parse(data.resultJson.replace(/```json\n?|\n?```/g, "").trim())
: data.resultJson;

sendEvent({
type: "AGENT_COMPLETE",
agentId,
siteName: site.name,
scholarships: result.scholarships || [],
});

return { agentId, site, scholarships: result.scholarships || [] };
}
} catch {
// Continue on parse error
}
}
}
}

return { agentId, site, scholarships: [] };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Agent failed";
sendEvent({
type: "AGENT_ERROR",
agentId,
siteName: site.name,
error: errorMessage,
});
return { agentId, site, scholarships: [], error: errorMessage };
}
});

// Wait for all agents to complete
const results = await Promise.all(agentPromises);

// Combine all scholarships
const allScholarships = results.flatMap(r => r.scholarships || []);

sendEvent({
type: "ALL_COMPLETE",
totalScholarships: allScholarships.length,
scholarships: allScholarships,
searchSummary: `Found ${allScholarships.length} scholarships from ${scholarshipUrls.length} sources.`,
});

controller.close();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Search failed";
console.error("Error:", error);
sendEvent({ type: "ERROR", error: errorMessage });
controller.close();
}
},
});
Copy link

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:

# First, let's check the full context of the file to see the complete function
cd scholarship-finder && git ls-files | grep -E "(search-scholarships|index\.ts)" | head -20

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 120


🏁 Script executed:

# Look for the full file to understand the complete implementation
find . -name "index.ts" -path "*/search-scholarships/*" -type f

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 141


🏁 Script executed:

# Check the full function implementation
cat -n supabase/functions/search-scholarships/index.ts | head -300

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 152


🏁 Script executed:

# Search for existing ReadableStream usage patterns in the codebase
rg -A 3 "ReadableStream\s*\{" --type ts

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 55


🏁 Script executed:

cat -n ./scholarship-finder/supabase/functions/search-scholarships/index.ts | head -300

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 11952


🏁 Script executed:

# Check if there's a cancel handler already defined
rg "cancel\s*\(\s*\)" ./scholarship-finder/supabase/functions/search-scholarships/index.ts -A 2

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 55


🏁 Script executed:

# Check the end of the ReadableStream definition to see the full structure
tail -50 ./scholarship-finder/supabase/functions/search-scholarships/index.ts

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 1558


Add cancel() handler to abort downstream fetch requests on client disconnect.

The ReadableStream lacks a cancel() handler, so the Lovable AI and Mino agent fetch requests continue running after the client closes the SSE connection. In a parallel execution context with 5-8+ concurrent agents, this wastes resources and can incur unexpected API costs. Add an AbortController and pass its signal to both the Lovable fetch (line 80) and Mino fetch (line 176), then implement a cancel() handler to abort the controller when the client disconnects.

Suggested fix
-  const stream = new ReadableStream({
+  const abortController = new AbortController();
+  const stream = new ReadableStream({
     async start(controller) {
       const sendEvent = (data: object) => {
         controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
       };

       try {
         const { scholarshipType, university, region }: SearchParams = await req.json();
         const MINO_API_KEY = Deno.env.get("MINO_API_KEY");
         const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY");

         if (!MINO_API_KEY) {
           throw new Error("MINO_API_KEY not configured");
         }
         if (!LOVABLE_API_KEY) {
           throw new Error("LOVABLE_API_KEY not configured");
         }

         const today = new Date();
         const currentDate = today.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
         const currentYear = today.getFullYear();

         const locationContext = [
           university ? `at ${university}` : "",
           region ? `in ${region}` : "",
         ].filter(Boolean).join(" ");

         // STEP 1: Use Lovable AI to get scholarship URLs
         sendEvent({ 
           type: "STEP", 
           step: 1, 
           message: "Finding scholarship websites..." 
         });

         const aiPrompt = `Find 5-8 official scholarship provider websites for ${scholarshipType} scholarships ${locationContext}.

Return a JSON array of scholarship websites to search. Focus on:
- Official university financial aid pages
- Well-known scholarship foundations (Fulbright, Gates, Rhodes, etc.)
- Government scholarship programs
- Reputable scholarship aggregators

Return ONLY a JSON array like this:
[
  {
    "name": "MIT Financial Aid",
    "url": "https://sfs.mit.edu/undergraduate-students/",
    "description": "MIT's official financial aid office"
  }
]

Include diverse sources: university-specific, national programs, and international opportunities if applicable.
Make sure all URLs are real, official websites.`;

         const aiResponse = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
           method: "POST",
           headers: {
             "Content-Type": "application/json",
             "Authorization": `Bearer ${LOVABLE_API_KEY}`,
           },
+          signal: abortController.signal,
           body: JSON.stringify({
             model: "google/gemini-3-flash-preview",
             messages: [
               { role: "system", content: "You are a scholarship research assistant. Return only valid JSON arrays." },
               { role: "user", content: aiPrompt },
             ],
             temperature: 0.7,
             max_tokens: 2000,
           }),
         });

         if (!aiResponse.ok) {
           throw new Error(`AI Gateway error: ${aiResponse.status}`);
         }

         const aiData = await aiResponse.json();
         const content = aiData.choices?.[0]?.message?.content;

         if (!content) {
           throw new Error("No content from AI");
         }

         // Parse the URLs from AI response
         let scholarshipUrls: ScholarshipUrl[];
         try {
           const cleanedContent = content.replace(/```json\n?|\n?```/g, "").trim();
           scholarshipUrls = JSON.parse(cleanedContent);
         } catch {
           throw new Error("Failed to parse scholarship URLs");
         }

         sendEvent({ 
           type: "URLS_FOUND", 
           urls: scholarshipUrls,
           message: `Found ${scholarshipUrls.length} scholarship sources to search`
         });

         // STEP 2: Run Mino agents in parallel
         sendEvent({ 
           type: "STEP", 
           step: 2, 
           message: `Launching ${scholarshipUrls.length} browser agents...` 
         });

         const goal = `You are searching for ${scholarshipType} scholarships ${locationContext}.

CURRENT DATE: ${currentDate}

For this scholarship provider, extract:
1. Scholarship name(s)
2. Award amounts
3. Application deadlines (MUST be after ${currentDate})
4. Eligibility requirements
5. How to apply / application link

Return a JSON object:
{
  "scholarships": [
    {
      "id": "unique-id",
      "name": "Scholarship Name",
      "provider": "Organization",
      "amount": "$X,XXX",
      "deadline": "Month Day, Year",
      "eligibility": ["Requirement 1", "Requirement 2"],
      "description": "Brief description",
      "applicationRequirements": ["Document 1", "Document 2"],
      "applicationLink": "https://...",
      "region": "${region || 'International'}",
      "university": "${university || 'Various'}",
      "type": "${scholarshipType}"
    }
  ]
}

Only include scholarships with deadlines AFTER ${currentDate}.`;

         // Start all Mino agents in parallel
         const agentPromises = scholarshipUrls.map(async (site, index) => {
           const agentId = `agent-${index}`;
           
           sendEvent({
             type: "AGENT_STARTED",
             agentId,
             siteName: site.name,
             siteUrl: site.url,
             description: site.description,
           });

           try {
             const minoResponse = await fetch("https://mino.ai/v1/automation/run-sse", {
               method: "POST",
               headers: {
                 "Content-Type": "application/json",
                 "X-API-Key": MINO_API_KEY,
               },
+              signal: abortController.signal,
               body: JSON.stringify({
                 url: site.url,
                 goal: goal,
               }),
             });

             if (!minoResponse.ok) {
               throw new Error(`Mino error: ${minoResponse.status}`);
             }

             // Process SSE stream from Mino
             const reader = minoResponse.body?.getReader();
             if (!reader) throw new Error("No response body");

             const decoder = new TextDecoder();
             let buffer = "";

             while (true) {
               const { done, value } = await reader.read();
               if (done) break;

               buffer += decoder.decode(value, { stream: true });
               const lines = buffer.split("\n");
               buffer = lines.pop() || "";

               for (const line of lines) {
                 if (line.startsWith("data: ")) {
                   const jsonStr = line.slice(6).trim();
                   if (!jsonStr || jsonStr === "[DONE]") continue;

                   try {
                     const data = JSON.parse(jsonStr);

                     // Forward streaming URL
                     if (data.type === "STREAMING_URL" && data.streamingUrl) {
                       sendEvent({
                         type: "AGENT_STREAMING",
                         agentId,
                         siteName: site.name,
                         streamingUrl: data.streamingUrl,
                       });
                     }

                     // Forward progress updates
                     if (data.type === "PROGRESS" && data.purpose) {
                       sendEvent({
                         type: "AGENT_PROGRESS",
                         agentId,
                         siteName: site.name,
                         message: data.purpose,
                       });
                     }

                     // Handle completion
                     if (data.type === "COMPLETE" && data.resultJson) {
                       const result = typeof data.resultJson === "string" 
                         ? JSON.parse(data.resultJson.replace(/```json\n?|\n?```/g, "").trim())
                         : data.resultJson;

                       sendEvent({
                         type: "AGENT_COMPLETE",
                         agentId,
                         siteName: site.name,
                         scholarships: result.scholarships || [],
                       });

                       return { agentId, site, scholarships: result.scholarships || [] };
                     }
                   } catch {
                     // Continue on parse error
                   }
                 }
               }
             }

             return { agentId, site, scholarships: [] };
           } catch (error) {
             const errorMessage = error instanceof Error ? error.message : "Agent failed";
             sendEvent({
               type: "AGENT_ERROR",
               agentId,
               siteName: site.name,
               error: errorMessage,
             });
             return { agentId, site, scholarships: [], error: errorMessage };
           }
         });

         // Wait for all agents to complete
         const results = await Promise.all(agentPromises);

         // Combine all scholarships
         const allScholarships = results.flatMap(r => r.scholarships || []);

         sendEvent({
           type: "ALL_COMPLETE",
           totalScholarships: allScholarships.length,
           scholarships: allScholarships,
           searchSummary: `Found ${allScholarships.length} scholarships from ${scholarshipUrls.length} sources.`,
         });

         controller.close();
       } catch (error) {
         const errorMessage = error instanceof Error ? error.message : "Search failed";
         console.error("Error:", error);
         sendEvent({ type: "ERROR", error: errorMessage });
         controller.close();
       }
     },
+    cancel() {
+      abortController.abort();
+    },
   });
🤖 Prompt for AI Agents
In `@scholarship-finder/supabase/functions/search-scholarships/index.ts` around
lines 26 - 291, The ReadableStream lacks cancellation so downstream Lovable and
Mino fetches keep running; create an AbortController (e.g., abortController)
before calling req.json(), pass abortController.signal into the Lovable AI fetch
(the fetch to "https://ai.gateway.lovable.dev/v1/chat/completions") and into
each Mino fetch (the fetch to "https://mino.ai/v1/automation/run-sse" inside the
agentPromises), and implement a cancel() handler on the ReadableStream that
calls abortController.abort() to terminate all in-flight requests when the
client disconnects; ensure error handling in your existing try/catch (used
around parsing and agent work) gracefully handles AbortError cases.

Comment on lines +108 to +172
// Parse the URLs from AI response
let scholarshipUrls: ScholarshipUrl[];
try {
const cleanedContent = content.replace(/```json\n?|\n?```/g, "").trim();
scholarshipUrls = JSON.parse(cleanedContent);
} catch {
throw new Error("Failed to parse scholarship URLs");
}

sendEvent({
type: "URLS_FOUND",
urls: scholarshipUrls,
message: `Found ${scholarshipUrls.length} scholarship sources to search`
});

// STEP 2: Run Mino agents in parallel
sendEvent({
type: "STEP",
step: 2,
message: `Launching ${scholarshipUrls.length} browser agents...`
});

const goal = `You are searching for ${scholarshipType} scholarships ${locationContext}.

CURRENT DATE: ${currentDate}

For this scholarship provider, extract:
1. Scholarship name(s)
2. Award amounts
3. Application deadlines (MUST be after ${currentDate})
4. Eligibility requirements
5. How to apply / application link

Return a JSON object:
{
"scholarships": [
{
"id": "unique-id",
"name": "Scholarship Name",
"provider": "Organization",
"amount": "$X,XXX",
"deadline": "Month Day, Year",
"eligibility": ["Requirement 1", "Requirement 2"],
"description": "Brief description",
"applicationRequirements": ["Document 1", "Document 2"],
"applicationLink": "https://...",
"region": "${region || 'International'}",
"university": "${university || 'Various'}",
"type": "${scholarshipType}"
}
]
}

Only include scholarships with deadlines AFTER ${currentDate}.`;

// Start all Mino agents in parallel
const agentPromises = scholarshipUrls.map(async (site, index) => {
const agentId = `agent-${index}`;

sendEvent({
type: "AGENT_STARTED",
agentId,
siteName: site.name,
siteUrl: site.url,
description: site.description,
Copy link

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:

cat -n scholarship-finder/supabase/functions/search-scholarships/index.ts | sed -n '90,185p'

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 3630


🏁 Script executed:

# Also search for ReadableStream usage and cancel handlers in the file
rg -n "ReadableStream|\.cancel|abort|AbortController" scholarship-finder/supabase/functions/search-scholarships/index.ts

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 113


🏁 Script executed:

cat -n scholarship-finder/supabase/functions/search-scholarships/index.ts | sed -n '1,50p'

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 1905


🏁 Script executed:

cat -n scholarship-finder/supabase/functions/search-scholarships/index.ts | sed -n '160,220p'

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 2557


🏁 Script executed:

cat -n scholarship-finder/supabase/functions/search-scholarships/index.ts | sed -n '220,300p'

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 3409


Validate and cap AI-generated URL list before spawning agents.

AI responses are untrusted. Without validation, a malformed or oversized response can trigger uncontrolled parallel agents: line 164 maps over scholarshipUrls with no array check or length cap, creating unbounded concurrent fetch requests to the Mino API at line 176. A single response with 100+ URLs could trigger cost spikes and reliability issues.

🛠️ Suggested fix
         let scholarshipUrls: ScholarshipUrl[];
         try {
           const cleanedContent = content.replace(/```json\n?|\n?```/g, "").trim();
           scholarshipUrls = JSON.parse(cleanedContent);
         } catch {
           throw new Error("Failed to parse scholarship URLs");
         }
+
+        if (!Array.isArray(scholarshipUrls)) {
+          throw new Error("Scholarship URLs must be an array");
+        }
+        scholarshipUrls = scholarshipUrls
+          .filter((u) => u?.name && u?.url && u?.description)
+          .slice(0, 8);
+        if (scholarshipUrls.length === 0) {
+          throw new Error("No valid scholarship URLs returned");
+        }
📝 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
// Parse the URLs from AI response
let scholarshipUrls: ScholarshipUrl[];
try {
const cleanedContent = content.replace(/```json\n?|\n?```/g, "").trim();
scholarshipUrls = JSON.parse(cleanedContent);
} catch {
throw new Error("Failed to parse scholarship URLs");
}
sendEvent({
type: "URLS_FOUND",
urls: scholarshipUrls,
message: `Found ${scholarshipUrls.length} scholarship sources to search`
});
// STEP 2: Run Mino agents in parallel
sendEvent({
type: "STEP",
step: 2,
message: `Launching ${scholarshipUrls.length} browser agents...`
});
const goal = `You are searching for ${scholarshipType} scholarships ${locationContext}.
CURRENT DATE: ${currentDate}
For this scholarship provider, extract:
1. Scholarship name(s)
2. Award amounts
3. Application deadlines (MUST be after ${currentDate})
4. Eligibility requirements
5. How to apply / application link
Return a JSON object:
{
"scholarships": [
{
"id": "unique-id",
"name": "Scholarship Name",
"provider": "Organization",
"amount": "$X,XXX",
"deadline": "Month Day, Year",
"eligibility": ["Requirement 1", "Requirement 2"],
"description": "Brief description",
"applicationRequirements": ["Document 1", "Document 2"],
"applicationLink": "https://...",
"region": "${region || 'International'}",
"university": "${university || 'Various'}",
"type": "${scholarshipType}"
}
]
}
Only include scholarships with deadlines AFTER ${currentDate}.`;
// Start all Mino agents in parallel
const agentPromises = scholarshipUrls.map(async (site, index) => {
const agentId = `agent-${index}`;
sendEvent({
type: "AGENT_STARTED",
agentId,
siteName: site.name,
siteUrl: site.url,
description: site.description,
// Parse the URLs from AI response
let scholarshipUrls: ScholarshipUrl[];
try {
const cleanedContent = content.replace(/
🤖 Prompt for AI Agents
In `@scholarship-finder/supabase/functions/search-scholarships/index.ts` around
lines 108 - 172, The AI-returned scholarshipUrls are untrusted and currently
mapped into agentPromises without validation or limits, which can spawn
unbounded parallel agents; after the JSON.parse of content (scholarshipUrls)
validate that scholarshipUrls is an Array, filter out entries missing required
fields (e.g., name, url, description), slice the array to a safe concurrency cap
(e.g., 8), and throw an error if no valid entries remain so
agentPromises.map(...) (the agentPromises creation and subsequent Mino calls)
only iterates a validated, bounded list.

Copy link
Contributor

@uttambharadwaj uttambharadwaj left a comment

Choose a reason for hiding this comment

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

really cool idea!

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.

3 participants