diff --git a/package.json b/package.json index 4c1aa29..76af0bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@poap-xyz/poap-family", - "version": "1.18.0", + "version": "1.19.0", "author": { "name": "POAP", "url": "https://poap.xyz" diff --git a/src/hooks/useEvent.ts b/src/hooks/useEvent.ts index 6018478..83243fb 100644 --- a/src/hooks/useEvent.ts +++ b/src/hooks/useEvent.ts @@ -1,7 +1,7 @@ import { useCallback, useState } from 'react' -import { fetchEvent as loadEvent } from 'loaders/event' import { AbortedError } from 'models/error' import { Drop } from 'models/drop' +import { fetchDrop } from 'loaders/drop' function useEvent(eventId?: number): { loadingEvent: boolean @@ -24,7 +24,7 @@ function useEvent(eventId?: number): { setLoading(true) setError(null) setEvent(null) - loadEvent( + fetchDrop( newEventId ?? eventId, /*includeDescription*/false, controller.signal diff --git a/src/hooks/useEventSearch.ts b/src/hooks/useEventSearch.ts index 3f43f1a..d19145c 100644 --- a/src/hooks/useEventSearch.ts +++ b/src/hooks/useEventSearch.ts @@ -2,7 +2,7 @@ import { useCallback, useState } from 'react' import { SEARCH_LIMIT } from 'models/event' import { Drop } from 'models/drop' import { AbortedError } from 'models/error' -import { searchEvents as loadSearchEvents } from 'loaders/event' +import { searchDrops } from 'loaders/drop' function useEventSearch(query?: string, page: number = 1): { loadingEventSearch: boolean @@ -30,7 +30,7 @@ function useEventSearch(query?: string, page: number = 1): { if (total == null || offset <= total) { controller = new AbortController() setLoading(true) - loadSearchEvents( + searchDrops( newQuery ?? query, controller.signal, offset, diff --git a/src/hooks/useEventsInCommon.ts b/src/hooks/useEventsInCommon.ts index f1c2745..2529334 100644 --- a/src/hooks/useEventsInCommon.ts +++ b/src/hooks/useEventsInCommon.ts @@ -43,7 +43,6 @@ function useEventsInCommon( () => { for (const eventId of eventIds) { if (eventsOwners[eventId] == null) { - console.error('Missing event owners', { eventId }) continue } if ( diff --git a/src/loaders/api.ts b/src/loaders/api.ts index 79225f9..b430675 100644 --- a/src/loaders/api.ts +++ b/src/loaders/api.ts @@ -510,13 +510,10 @@ export async function getInCommonEventsWithEvents( totalEvents ) } else { - resolved = true - inCommonStream.close() - reject(new Error( + console.info( `Received empty list of events ` + `when streaming event '${eventId}' in common` - )) - return + ) } } if ( diff --git a/src/loaders/compass.ts b/src/loaders/compass.ts index e47b479..31d15d2 100644 --- a/src/loaders/compass.ts +++ b/src/loaders/compass.ts @@ -1,7 +1,11 @@ import { AbortedError, HttpError } from 'models/error' import { COMPASS_KEY, COMPASS_URL } from 'models/compass' -export async function requestCompass(query: string, variables: Record, abortSignal: AbortSignal): Promise { +export async function requestCompass( + query: string, + variables: Record, + abortSignal?: AbortSignal, +): Promise { let response: Response try { response = await fetch(COMPASS_URL, { @@ -103,7 +107,7 @@ export async function queryCompass( Model: (data: unknown) => T, query: string, variables: Record, - abortSignal: AbortSignal + abortSignal?: AbortSignal ): Promise { const results = await requestCompass(query, variables, abortSignal) @@ -124,8 +128,8 @@ export async function queryFirstCompass( Model: (data: unknown) => T, query: string, variables: Record, - defaultValue: T, - abortSignal: AbortSignal + defaultValue?: T, + abortSignal?: AbortSignal ): Promise { const FirstModel = (data: unknown): T =>{ if (data == null || !Array.isArray(data)) { @@ -135,7 +139,11 @@ export async function queryFirstCompass( } if (data.length === 0) { - return defaultValue + if (defaultValue) { + return defaultValue + } + + throw new HttpError(`Model ${name} empty results`, { status: 404 }) } return Model(data[0]) @@ -155,7 +163,7 @@ export async function queryManyCompass( Model: (data: unknown) => T, query: string, variables: Record, - abortSignal: AbortSignal + abortSignal?: AbortSignal ): Promise { const ManyModel = (data: unknown): T[] => { if (data == null || !Array.isArray(data)) { @@ -178,7 +186,7 @@ export async function queryAllCompass( offsetKey: string, limit: number, total: number, - abortSignal: AbortSignal + abortSignal?: AbortSignal ): Promise { let results: T[] = [] let pageCount = 0 @@ -219,7 +227,7 @@ export async function queryAggregateCountCompass( name: string, query: string, variables: Record, - abortSignal: AbortSignal + abortSignal?: AbortSignal ): Promise { const AggregateCountModel = (data: unknown): number => { if ( @@ -248,7 +256,10 @@ export async function queryAggregateCountCompass( ) } -export async function countCompass(target: string, abortSignal: AbortSignal): Promise { +export async function countCompass( + target: string, + abortSignal?: AbortSignal, +): Promise { return await queryAggregateCountCompass( `${target}_aggregate`, ` diff --git a/src/loaders/drop.ts b/src/loaders/drop.ts new file mode 100644 index 0000000..199e4ef --- /dev/null +++ b/src/loaders/drop.ts @@ -0,0 +1,198 @@ +import { DEFAULT_COMPASS_LIMIT } from 'models/compass' +import { DEFAULT_DROP_LIMIT, DEFAULT_SEARCH_LIMIT } from 'models/event' +import { Drop, parseDrop } from 'models/drop' +import { queryAggregateCountCompass, queryFirstCompass, queryManyCompass } from 'loaders/compass' +import { HttpError } from 'models/error' + +export async function searchDrops( + query: string, + abortSignal: AbortSignal, + offset: number = 0, + limit: number = Math.min(DEFAULT_SEARCH_LIMIT, DEFAULT_COMPASS_LIMIT), +): Promise<{ + items: Drop[] + total: number + offset: number + limit: number +}> { + const [items, total] = await Promise.all([ + queryManyCompass( + 'search_drops', + (data: unknown) => parseDrop(data, /*includeDescription*/true), + ` + query SearchDrops( + $limit: Int! + $offset: Int! + $query: String! + ) { + search_drops( + limit: $limit + offset: $offset + args: { + search: $query + } + ) { + id + name + description + image_url + city + country + start_date + end_date + expiry_date + drop_image { + gateways { + type + url + } + } + } + } + `, + { + query, + offset, + limit, + }, + abortSignal + ), + queryAggregateCountCompass( + 'search_drops_aggregate', + ` + query SearchDropsCount($query: String!) { + search_drops_aggregate(args: { search: $query }) { + aggregate { + count + } + } + } + `, + { + query, + }, + abortSignal + ), + ]) + return { + items, + total, + offset, + limit, + } +} + +export async function fetchDropsOrErrors( + dropIds: number[], + limit: number = Math.min(DEFAULT_DROP_LIMIT, DEFAULT_COMPASS_LIMIT), +): Promise<[ + Record, + Record +]> { + const eventsMap: Record = {} + const errorsMap: Record = {} + + for (let i = 0; i < dropIds.length; i += limit) { + const ids = dropIds.slice(i, i + limit) + + if (ids.length === 0) { + break + } + + try { + const drops = await queryManyCompass( + 'drops', + (data: unknown): Drop => parseDrop(data, /*includeDescription*/false), + ` + query FetchDrops($dropIds: [Int!]) { + drops(where: { id: { _in: $dropIds } }) { + id + name + description + image_url + city + country + start_date + end_date + expiry_date + drop_image { + gateways { + type + url + } + } + } + } + `, + { + dropIds: ids, + }, + ) + + for (const id of ids) { + const drop = drops.find((drop) => drop.id === id) + + if (!drop) { + errorsMap[id] = new HttpError(`Drop '${id}' not found`, { + status: 404, + }) + } else { + eventsMap[id] = drop + } + } + } catch (err: unknown) { + for (const id of ids) { + errorsMap[id] = err instanceof Error + ? err + : new Error(`Failed to fetch drop '${id}'`, { cause: err }) + } + } + } + + return [eventsMap, errorsMap] +} + +export async function fetchDrop( + dropId: number, + includeDescription: boolean, + abortSignal?: AbortSignal, +): Promise { + try { + return await queryFirstCompass( + `drops`, + (data: unknown): Drop => parseDrop(data, includeDescription), + ` + query FetchDrop($dropId: Int!) { + drops(limit: 1, where: { id: { _eq: $dropId } }) { + id + name + description + image_url + city + country + start_date + end_date + expiry_date + drop_image { + gateways { + type + url + } + } + } + } + `, + { + dropId, + }, + undefined, + abortSignal, + ) + } catch (err: unknown) { + if (err instanceof HttpError && err.status === 404) { + return null + } + + throw err + } +} diff --git a/src/loaders/event.ts b/src/loaders/event.ts index e52b90c..0c0e923 100644 --- a/src/loaders/event.ts +++ b/src/loaders/event.ts @@ -1,287 +1,10 @@ import { filterInvalidOwners } from 'models/address' -import { DEFAULT_SEARCH_LIMIT, parseEventIds } from 'models/event' -import { Drop, parseDrop } from 'models/drop' -import { POAP_API_URL, POAP_API_KEY } from 'models/poap' -import { AbortedError, HttpError } from 'models/error' +import { parseEventIds } from 'models/event' +import { Drop } from 'models/drop' +import { HttpError } from 'models/error' import { getEventAndOwners, getEventMetrics, getEvents } from 'loaders/api' import { fetchPOAPs } from 'loaders/poap' - -export async function searchEvents( - query: string, - abortSignal: AbortSignal, - offset: number = 0, - limit: number = DEFAULT_SEARCH_LIMIT, -): Promise<{ - items: Drop[] - total: number - offset: number - limit: number -}> { - let response: Response - try { - response = await fetch( - `${POAP_API_URL}/paginated-events?name=${encodeURIComponent(query)}` + - `&sort_field=start_date` + - `&sort_dir=desc` + - `&offset=${offset}` + - `&limit=${limit}`, - { - signal: abortSignal instanceof AbortSignal ? abortSignal : null, - headers: { - 'x-api-key': POAP_API_KEY, - }, - } - ) - } catch (err: unknown) { - if (err instanceof Error && err.name === 'AbortError') { - throw new AbortedError( - `Search drops for "${query}" aborted`, - { cause: err } - ) - } - throw new Error( - `Cannot search drops for "${query}": ` + - `response was not success (network error)`, - { cause: err } - ) - } - - if (response.status !== 200) { - let message: string | undefined - try { - const data = await response.json() - if ( - data != null && - typeof data === 'object' && - 'message' in data && - data.message != null && - typeof data.message === 'string' - ) { - message = data.message - } - } catch (err: unknown) { - console.error(err) - } - - if (message) { - throw new HttpError( - `Search events was not success ` + - `(status ${response.status}): ${message}`, - { status: response.status } - ) - } else { - throw new HttpError( - `Search events was not success ` + - `(status ${response.status})`, - { status: response.status } - ) - } - } - - const body: unknown = await response.json() - - if ( - body == null || - typeof body !== 'object' || - !('items' in body) || - body.items == null || - !Array.isArray(body.items) || - !('total' in body) || - body.total == null || - typeof body.total !== 'number' || - !('offset' in body) || - body.offset == null || - typeof body.offset !== 'number' || - !('limit' in body) || - body.limit == null || - typeof body.limit !== 'number' - ) { - throw new Error(`Search events response malformed`) - } - - return { - items: body.items.map((item) => parseDrop(item, /*includeDescription*/false)), - total: body.total, - offset: body.offset, - limit: body.limit, - } -} - -export async function fetchEventsOrErrors(eventIds: number[], limit: number = 100): Promise<[Record, Record]> { - const eventsMap: Record = {} - const errorsMap: Record = {} - - for (let i = 0; i < eventIds.length; i += limit) { - const ids = eventIds.slice(i, i + limit) - - if (ids.length === 0) { - break - } - - let response: Response - try { - response = await fetch( - `${POAP_API_URL}/paginated-events` + - `?event_ids=${ids.map((eventId) => encodeURIComponent(eventId)).join(',')}` + - `&limit=${limit}`, - { - headers: { - 'x-api-key': POAP_API_KEY, - }, - } - ) - } catch (err: unknown) { - for (const id of ids) { - errorsMap[id] = new Error( - `Cannot fetch drop ${id}: response was not success (network error)`, - { cause: err } - ) - } - - continue - } - - if (response.status !== 200) { - let message: string | undefined - try { - const data = await response.json() - if ( - data != null && - typeof data === 'object' && - 'message' in data && - data.message != null && - typeof data.message === 'string' - ) { - message = data.message - } - } catch (err: unknown) { - console.error(err) - } - - for (const id of ids) { - if (message) { - errorsMap[id] = new HttpError( - `Cannot fetch drop ${id}: ` + - `response was not success (status ${response.status}): ${message}`, - { status: response.status } - ) - } else { - errorsMap[id] = new HttpError( - `Cannot fetch drop ${id}: ` + - `response was not success (status ${response.status})`, - { status: response.status } - ) - } - } - - continue - } - - try { - const data: unknown = await response.json() - if ( - data != null && - typeof data === 'object' && - 'items' in data && - data.items != null && - Array.isArray(data.items) - ) { - for (const item of data.items) { - const event = parseDrop(item, /*includeDescription*/false) - eventsMap[event.id] = event - } - - for (const id of ids) { - if (!(id in eventsMap)) { - errorsMap[id] = new HttpError( - `Drop '${id}' not found on response`, - { status: 404 } - ) - } - } - } else { - for (const id of ids) { - if (!(id in eventsMap)) { - errorsMap[id] = new Error(`Malformed drop response '${id}'`) - } - } - } - } catch (err: unknown) { - console.error(err) - - for (const id of ids) { - if (!(id in eventsMap)) { - errorsMap[id] = new Error(`Malformed drop '${id}': ${err}`) - } - } - } - } - - return [eventsMap, errorsMap] -} - -export async function fetchEvent(eventId: number, includeDescription: boolean, abortSignal?: AbortSignal): Promise { - let response: Response - try { - response = await fetch(`${POAP_API_URL}/events/id/${eventId}`, { - signal: abortSignal instanceof AbortSignal ? abortSignal : null, - headers: { - 'x-api-key': POAP_API_KEY, - }, - }) - } catch (err: unknown) { - if (err instanceof Error && err.name === 'AbortError') { - throw new AbortedError(`Fetch drop ${eventId} aborted`, { cause: err }) - } - throw new Error( - `Cannot fetch drop ${eventId}: response was not success (network error)`, - { cause: err } - ) - } - - if (response.status === 404) { - return null - } - - if (response.status === 400) { - let message: string = 'Unknown error' - try { - const data = await response.json() - if ( - data != null && - typeof data === 'object' && - 'message' in data && - data.message != null && - typeof data.message === 'string' - ) { - message = data.message - } - } catch (err: unknown) { - console.error(err) - } - - throw new HttpError( - `Fetch drop '${eventId}' response was not success: ${message}`, - { status: 400 } - ) - } - - if (response.status !== 200) { - throw new HttpError( - `Fetch drop '${eventId}' response was not success ` + - `(status ${response.status})`, - { status: response.status } - ) - } - - const body: unknown = await response.json() - - if (typeof body !== 'object') { - throw new Error(`Malformed drop (type ${typeof body})`) - } - - return parseDrop(body, includeDescription) -} +import { fetchDrop, fetchDropsOrErrors } from 'loaders/drop' export async function eventLoader({ params, request }) { const force = new URL(request.url).searchParams.get('force') === 'true' @@ -306,7 +29,7 @@ export async function eventLoader({ params, request }) { console.error(err) } - const event = await fetchEvent(params.eventId, /*includeDescription*/true) + const event = await fetchDrop(params.eventId, /*includeDescription*/true) if (!event) { throw new Response('', { @@ -401,7 +124,7 @@ export async function eventsLoader({ params, request }) { events = {} } - const [freshEvents, errors] = await fetchEventsOrErrors( + const [freshEvents, errors] = await fetchDropsOrErrors( notFoundEventIds ?? eventIds ) @@ -413,7 +136,7 @@ export async function eventsLoader({ params, request }) { ([, error]) => error instanceof HttpError && error.status === 404 ) .map(([rawEventId]) => - fetchEvent(parseInt(rawEventId), /*includeDescription*/false) + fetchDrop(parseInt(rawEventId), /*includeDescription*/false) ) ) diff --git a/src/models/event.ts b/src/models/event.ts index e8ffdc7..815470e 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -3,6 +3,7 @@ import { Drop } from 'models/drop' export const SEARCH_LIMIT = 10 +export const DEFAULT_DROP_LIMIT = 100 export const DEFAULT_SEARCH_LIMIT = 10 export function parseEventIds(rawIds: string): number[] {