Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions convex/influencers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,29 @@
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<T extends Record<string, unknown>>(value: T) {
const {
source: _source,

Check warning on line 77 in convex/influencers.ts

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck & Build

'_source' is assigned a value but never used
sourceLookupValue: _sourceLookupValue,

Check warning on line 78 in convex/influencers.ts

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck & Build

'_sourceLookupValue' is assigned a value but never used
sourceResolvedAt: _sourceResolvedAt,

Check warning on line 79 in convex/influencers.ts

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck & Build

'_sourceResolvedAt' is assigned a value but never used
sourceRefreshError: _sourceRefreshError,

Check warning on line 80 in convex/influencers.ts

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck & Build

'_sourceRefreshError' is assigned a value but never used
...rest
} = value as T & {
source?: unknown;
sourceLookupValue?: unknown;
sourceResolvedAt?: unknown;
sourceRefreshError?: unknown;
};

return rest;
}

export const getInfluencers = query({
args: {
platform: v.optional(platformValidator),
Expand Down Expand Up @@ -175,8 +198,10 @@
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(),
Expand All @@ -195,7 +220,8 @@
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);
},
Expand Down Expand Up @@ -239,6 +265,7 @@
source: 'youtube_api' as const,
sourceLookupValue: args.sourceLookupValue,
sourceResolvedAt: now,
sourceRefreshError: undefined,
lastDataRefresh: now,
complianceStatus,
};
Expand Down
11 changes: 5 additions & 6 deletions src/app/(auth)/sign-in/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -27,7 +26,7 @@ export default function SignInPage() {
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);

const handleSubmit = async (e: React.ChangeEvent<HTMLFormElement>) => {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError('');
setLoading(true);
Expand Down
13 changes: 12 additions & 1 deletion src/app/(dashboard)/channel-lookup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -71,6 +78,7 @@ export default function ChannelLookupPage() {
const [isImporting, setIsImporting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<LookupResult | null>(null);
const [sourceLookupValue, setSourceLookupValue] = useState('');
const [importMessage, setImportMessage] = useState<string | null>(null);

const hasLookup = useMemo(() => Boolean(result || error), [result, error]);
Expand All @@ -83,6 +91,7 @@ export default function ChannelLookupPage() {
setIsLoading(true);
setError(null);
setResult(null);
setSourceLookupValue('');
setImportMessage(null);

try {
Expand All @@ -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);
}
Expand All @@ -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,
Expand Down
21 changes: 15 additions & 6 deletions src/app/api/youtube/channel/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() ?? '';
Expand All @@ -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);
Expand All @@ -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 });
}
Expand Down
45 changes: 42 additions & 3 deletions src/lib/youtube.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
Loading