diff --git a/convex/influencers.ts b/convex/influencers.ts index 1e55789..527a33a 100644 --- a/convex/influencers.ts +++ b/convex/influencers.ts @@ -66,6 +66,29 @@ const influencerFields = { notes: v.optional(v.string()), }; +/** + * Removes server-managed influencer fields from an object. + * + * @param value - The influencer-like object to sanitize; may contain server-managed fields. + * @returns A shallow copy of `value` without the `source`, `sourceLookupValue`, `sourceResolvedAt`, or `sourceRefreshError` properties. + */ +function stripServerManagedInfluencerFields>(value: T) { + const { + source: _source, + sourceLookupValue: _sourceLookupValue, + sourceResolvedAt: _sourceResolvedAt, + sourceRefreshError: _sourceRefreshError, + ...rest + } = value as T & { + source?: unknown; + sourceLookupValue?: unknown; + sourceResolvedAt?: unknown; + sourceRefreshError?: unknown; + }; + + return rest; +} + export const getInfluencers = query({ args: { platform: v.optional(platformValidator), @@ -175,8 +198,10 @@ export const createInfluencer = mutation({ handler: async (ctx, args) => { await requireAuth(ctx); + const safeArgs = stripServerManagedInfluencerFields(args); + return await ctx.db.insert('influencers', { - ...args, + ...safeArgs, complianceStatus: args.complianceStatus ?? 'pending', source: 'manual', lastDataRefresh: Date.now(), @@ -195,7 +220,8 @@ export const updateInfluencer = mutation({ await requireAuth(ctx); const { id, ...updates } = args; - const cleanUpdates = removeUndefined(updates); + const safeUpdates = stripServerManagedInfluencerFields(updates); + const cleanUpdates = removeUndefined(safeUpdates); await ctx.db.patch(id, cleanUpdates); }, @@ -239,6 +265,7 @@ export const upsertYoutubeInfluencer = mutation({ source: 'youtube_api' as const, sourceLookupValue: args.sourceLookupValue, sourceResolvedAt: now, + sourceRefreshError: undefined, lastDataRefresh: now, complianceStatus, }; diff --git a/src/app/(auth)/sign-in/page.tsx b/src/app/(auth)/sign-in/page.tsx index af7106a..da36908 100644 --- a/src/app/(auth)/sign-in/page.tsx +++ b/src/app/(auth)/sign-in/page.tsx @@ -10,13 +10,12 @@ import { Label } from '@/components/ui/label'; import { authClient } from '@/lib/auth-client'; /** - * Render the authentication page for the GRA Tax Dashboard with sign-in and sign-up modes. + * Render the GRA Tax Dashboard authentication page with switchable sign-in and sign-up modes. * - * Displays GRA branding and an authentication form that switches between "sign in" and "create account" - * modes, shows validation and error states, and manages loading state during submission. On successful - * authentication the page navigates to the application home. + * Displays GRA branding alongside a form that manages credentials, validation/error states, and loading state; + * on successful authentication it navigates to the application home. * - * @returns The React element for the sign-in / sign-up page containing branding and the authentication form. + * @returns The React element for the authentication (sign-in / sign-up) page. */ export default function SignInPage() { const router = useRouter(); @@ -27,7 +26,7 @@ export default function SignInPage() { const [error, setError] = useState(''); const [loading, setLoading] = useState(false); - const handleSubmit = async (e: React.ChangeEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); setLoading(true); diff --git a/src/app/(dashboard)/channel-lookup/page.tsx b/src/app/(dashboard)/channel-lookup/page.tsx index c97935b..c144beb 100644 --- a/src/app/(dashboard)/channel-lookup/page.tsx +++ b/src/app/(dashboard)/channel-lookup/page.tsx @@ -63,6 +63,13 @@ function normalizeHandle(handle: string) { return handle.startsWith('@') ? handle.slice(1) : handle; } +/** + * UI for looking up public YouTube channels by handle, channel ID, or URL and importing the channel data into the registry. + * + * Presents a searchable form, displays loading, error, and result states, preserves the exact user input that produced a lookup for use during import, and invokes a server mutation to create or update the influencer record by channel ID. + * + * @returns The React element for the Channel Lookup page. + */ export default function ChannelLookupPage() { const upsertYoutubeInfluencer = useMutation(upsertYoutubeInfluencerRef); @@ -71,6 +78,7 @@ export default function ChannelLookupPage() { const [isImporting, setIsImporting] = useState(false); const [error, setError] = useState(null); const [result, setResult] = useState(null); + const [sourceLookupValue, setSourceLookupValue] = useState(''); const [importMessage, setImportMessage] = useState(null); const hasLookup = useMemo(() => Boolean(result || error), [result, error]); @@ -83,6 +91,7 @@ export default function ChannelLookupPage() { setIsLoading(true); setError(null); setResult(null); + setSourceLookupValue(''); setImportMessage(null); try { @@ -99,9 +108,11 @@ export default function ChannelLookupPage() { } setResult({ ...data }); + setSourceLookupValue(trimmed); } catch (lookupError) { setError(lookupError instanceof Error ? lookupError.message : 'Lookup failed.'); setResult(null); + setSourceLookupValue(''); } finally { setIsLoading(false); } @@ -118,7 +129,7 @@ export default function ChannelLookupPage() { name: result.name, handle: normalizeHandle(result.handle), channelId: result.channelId, - sourceLookupValue: query.trim(), + sourceLookupValue: sourceLookupValue, customUrl: result.customUrl, profileImageUrl: result.profileImageUrl, description: result.description, diff --git a/src/app/api/youtube/channel/route.ts b/src/app/api/youtube/channel/route.ts index 46720cf..38bd21b 100644 --- a/src/app/api/youtube/channel/route.ts +++ b/src/app/api/youtube/channel/route.ts @@ -2,6 +2,15 @@ import { NextResponse } from 'next/server'; import { lookupPublicYouTubeChannel } from '@/lib/youtube'; +/** + * Handles GET requests to look up a public YouTube channel using the `q` query parameter. + * + * Parses `q` from the request URL, validates it, performs a lookup, and returns JSON containing + * the channel data on success or an error message with an appropriate HTTP status on failure. + * + * @param request - The incoming request; its URL must include the `q` query parameter (YouTube handle, channel ID, or channel URL). + * @returns A NextResponse with the channel data when the lookup succeeds. On failure, a JSON object `{ error: string }` is returned with an HTTP status indicating the error: 400 for missing/invalid input, 404 when the channel is not found, 429 for quota-related errors, 500 for API key issues, and 502 for other failures. + */ export async function GET(request: Request) { const { searchParams } = new URL(request.url); const query = searchParams.get('q')?.trim() ?? ''; @@ -14,7 +23,9 @@ export async function GET(request: Request) { const result = await lookupPublicYouTubeChannel(query); if ('error' in result) { - return NextResponse.json({ error: result.error }, { status: 404 }); + const status = + result.error === 'Enter a YouTube handle, channel ID, or channel URL.' ? 400 : 404; + return NextResponse.json({ error: result.error }, { status }); } return NextResponse.json(result.data); @@ -23,11 +34,9 @@ export async function GET(request: Request) { const status = message.includes('quota') || message.includes('Quota') ? 429 - : message.includes('Missing q query parameter') - ? 400 - : message.includes('YOUTUBE_API_KEY') - ? 500 - : 502; + : message.includes('YOUTUBE_API_KEY') + ? 500 + : 502; return NextResponse.json({ error: message }, { status }); } diff --git a/src/lib/youtube.ts b/src/lib/youtube.ts index cc9449a..425e3ef 100644 --- a/src/lib/youtube.ts +++ b/src/lib/youtube.ts @@ -197,6 +197,12 @@ async function getChannelByHandle(handle: string) { return data.items?.[0] ?? null; } +/** + * Retrieve a YouTube channel resource by legacy username. + * + * @param username - The channel's legacy username (the identifier used in /user/ URLs) + * @returns The first matching YouTube channel item, or `null` if no channel is found + */ async function getChannelByUsername(username: string) { const data = await youtubeFetch<{ items?: YouTubeChannelItem[] }>('channels', { part: 'snippet,statistics,contentDetails,topicDetails,status', @@ -205,16 +211,49 @@ async function getChannelByUsername(username: string) { return data.items?.[0] ?? null; } +/** + * Finds a single YouTube channel ID that exactly matches the provided query's title or custom URL. + * + * The query is normalized by trimming, converting to lower case, and removing a leading `@` before comparison. + * + * @param query - A channel lookup string (name, handle with or without `@`, or other search text) + * @returns The matching channel's ID if exactly one exact match is found; `null` otherwise + */ async function searchChannelId(query: string) { - const data = await youtubeFetch<{ items?: Array<{ id?: { channelId?: string } }> }>('search', { + const normalizedQuery = query.trim().toLowerCase().replace(/^@/, ''); + + const data = await youtubeFetch<{ + items?: Array<{ + id?: { channelId?: string }; + snippet?: { title?: string; customUrl?: string }; + }>; + }>('search', { part: 'snippet', q: query, type: 'channel', - maxResults: 1, + maxResults: 5, }); - return data.items?.[0]?.id?.channelId ?? null; + + const matches = (data.items ?? []).filter((item) => { + const channelId = item.id?.channelId; + if (!channelId) return false; + + const title = item.snippet?.title?.trim().toLowerCase(); + const customUrl = item.snippet?.customUrl?.trim().toLowerCase().replace(/^@/, ''); + + return title === normalizedQuery || customUrl === normalizedQuery; + }); + + return matches.length === 1 ? (matches[0]?.id?.channelId ?? null) : null; } +/** + * Retrieves recent video IDs from an uploads playlist. + * + * @param uploadsPlaylistId - The uploads playlist ID to fetch items from. + * @param maxResults - Maximum number of playlist items to request (defaults to 10). + * @returns An array of video ID strings in the order returned by the API; an empty array if no videos are found. + */ async function getRecentVideoIds(uploadsPlaylistId: string, maxResults = 10) { const data = await youtubeFetch<{ items?: YouTubePlaylistItem[] }>('playlistItems', { part: 'contentDetails',