/ src / lib / utils / rpgf / rpgf.ts
rpgf.ts
  1  import { browser } from '$app/environment';
  2  import { get } from 'svelte/store';
  3  import getOptionalEnvVar from '../get-optional-env-var/public';
  4  import stripTrailingSlash from '../strip-trailing-slash';
  5  import { refreshAccessToken, rpgfAccessJwtStore } from './siwe';
  6  import { z } from 'zod';
  7  import network from '$lib/stores/wallet/network';
  8  import { error } from '@sveltejs/kit';
  9  import walletStore from '$lib/stores/wallet/wallet.store';
 10  import {
 11    roundSchema,
 12    slugAvailableResponseSchema,
 13    type CreateRoundDto,
 14    type PatchRoundDto,
 15    type Round,
 16  } from './types/round';
 17  import {
 18    applicationCategorySchema,
 19    applicationFormSchema,
 20    applicationSchema,
 21    applicationVersionSchema,
 22    listingApplicationSchema,
 23    type Application,
 24    type ApplicationCategory,
 25    type ApplicationForm,
 26    type ApplicationReviewDto,
 27    type ApplicationVersion,
 28    type CreateApplicationCategoryDto,
 29    type CreateApplicationDto,
 30    type CreateApplicationFormDto,
 31    type ListingApplication,
 32    type UpdateApplicationDto,
 33  } from './types/application';
 34  import {
 35    ballotSchema,
 36    wrappedBallotSchema,
 37    type Ballot,
 38    type SubmitBallotDto,
 39    type WrappedBallot,
 40  } from './types/ballot';
 41  import { userSchema, type RpgfUser } from './types/user';
 42  import { auditLogSchema, type AuditLog } from './types/auditLog';
 43  import {
 44    kycRequestSchema,
 45    type CreateKycRequestForApplicationDto,
 46    type KycRequest,
 47  } from './types/kyc';
 48  import { customDatasetSchema, type CustomDataset } from './types/customDataset';
 49  import { signBallot } from './sign-ballot';
 50  
 51  const rpgfApiUrl = getOptionalEnvVar(
 52    'PUBLIC_DRIPS_RPGF_URL',
 53    true,
 54    'RPGF functionality doesnt work.',
 55  );
 56  const rpgfInternalApiUrl = getOptionalEnvVar(
 57    'PUBLIC_INTERNAL_DRIPS_RPGF_URL',
 58    true,
 59    'RPGF functionality doesnt work.',
 60  );
 61  
 62  export async function rpgfServerCall(
 63    path: string,
 64    method: string = 'GET',
 65    body: unknown = null,
 66    headers: Record<string, string> = {},
 67    f = fetch,
 68    contentType:
 69      | 'application/json'
 70      | 'text/csv'
 71      | 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' = 'application/json',
 72  ) {
 73    if (!rpgfApiUrl || !rpgfInternalApiUrl) {
 74      throw new Error('Required environment variables are not set');
 75    }
 76  
 77    const baseUrl = stripTrailingSlash(browser ? rpgfApiUrl : rpgfInternalApiUrl);
 78  
 79    const res = await f(`${baseUrl}/api${path}`, {
 80      method,
 81      credentials: 'include',
 82      headers: {
 83        ...headers,
 84        'Content-Type': contentType,
 85      },
 86      body: body
 87        ? contentType === 'application/json'
 88          ? JSON.stringify(body)
 89          : (body as BodyInit)
 90        : null,
 91    });
 92  
 93    return res;
 94  }
 95  
 96  export async function authenticatedRpgfServerCall(
 97    path: string,
 98    method: string = 'GET',
 99    body: unknown = null,
100    f = fetch,
101    attemptRefresh: boolean = true,
102    disableErrorHandling = false,
103    contentType:
104      | 'application/json'
105      | 'text/csv'
106      | 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' = 'application/json',
107  ) {
108    const accessToken = get(walletStore).connected ? get(rpgfAccessJwtStore) : null;
109  
110    const res = await rpgfServerCall(
111      path,
112      method,
113      body,
114      {
115        Authorization: accessToken ? `Bearer ${accessToken}` : '',
116      },
117      f,
118      contentType,
119    );
120  
121    if (res.status === 401 && accessToken && attemptRefresh) {
122      // Try to refresh the access token
123      const { success } = await refreshAccessToken();
124  
125      if (success) {
126        return authenticatedRpgfServerCall(path, method, body, f, false);
127      }
128    }
129  
130    if (res.status === 400 || res.status === 401) {
131      const errorBody = await res.text();
132  
133      let message;
134      if (errorBody) {
135        message = errorBody;
136      } else if (res.status === 400) {
137        message = 'Bad Request';
138      } else {
139        message = 'Unauthorized';
140      }
141  
142      throw error(res.status, message);
143    }
144  
145    if (disableErrorHandling) {
146      return res;
147    }
148  
149    if (res.status === 500) {
150      // Server error, throw a generic error
151      throw error(500, 'Unexpected server error occurred.');
152    }
153  
154    return res;
155  }
156  
157  export async function getRounds(f = fetch, own = false): Promise<Round[]> {
158    const res = await authenticatedRpgfServerCall(
159      `/rounds${own ? '/own' : ''}?chainId=${network.chainId}`,
160      'GET',
161      undefined,
162      f,
163    );
164  
165    const parsed = roundSchema.array().parse(await res.json());
166    return parsed;
167  }
168  
169  export async function createRound(f = fetch, draft: CreateRoundDto): Promise<Round> {
170    const res = await authenticatedRpgfServerCall(`/rounds`, 'PUT', draft, f);
171  
172    const parsed = roundSchema.parse(await res.json());
173    return parsed;
174  }
175  
176  export async function updateRound(f = fetch, id: string, draft: PatchRoundDto): Promise<Round> {
177    // strip empty fields
178    const strippedDraft = Object.fromEntries(
179      Object.entries(draft).filter(([_, value]) => value !== undefined && value !== ''),
180    ) as PatchRoundDto;
181  
182    const res = await authenticatedRpgfServerCall(`/rounds/${id}`, 'PATCH', strippedDraft, f);
183  
184    const parsed = roundSchema.parse(await res.json());
185    return parsed;
186  }
187  
188  export async function deleteRound(f = fetch, id: string): Promise<Response> {
189    return await authenticatedRpgfServerCall(`/rounds/${id}`, 'DELETE', undefined, f);
190  }
191  
192  export async function publishRound(f = fetch, id: string): Promise<Round> {
193    const res = await authenticatedRpgfServerCall(`/rounds/${id}/publish`, 'POST', undefined, f);
194  
195    const parsed = roundSchema.parse(await res.json());
196    return parsed;
197  }
198  
199  export async function checkSlugAvailability(f = fetch, slug: string): Promise<boolean> {
200    const res = await authenticatedRpgfServerCall(`/rounds/check-slug/${slug}`, 'GET', undefined, f);
201  
202    const { available } = slugAvailableResponseSchema.parse(await res.json());
203    return available;
204  }
205  
206  export async function getRound(f = fetch, slug: string): Promise<Round | null> {
207    const res = await authenticatedRpgfServerCall(
208      `/rounds/${slug}?chainId=${network.chainId}`,
209      'GET',
210      undefined,
211      f,
212    );
213    const body = await res.json();
214  
215    if (res.status === 404) {
216      return null;
217    }
218  
219    return roundSchema.parse(body);
220  }
221  
222  export async function submitApplication(
223    f = fetch,
224    roundId: string,
225    application: CreateApplicationDto,
226  ): Promise<Application> {
227    const res = await authenticatedRpgfServerCall(
228      `/rounds/${roundId}/applications`,
229      'PUT',
230      application,
231      f,
232    );
233  
234    return applicationSchema.parse(await res.json());
235  }
236  
237  export async function updateApplication(
238    f = fetch,
239    roundId: string,
240    applicationId: string,
241    application: UpdateApplicationDto,
242  ): Promise<Application> {
243    const res = await authenticatedRpgfServerCall(
244      `/rounds/${roundId}/applications/${applicationId}`,
245      'POST',
246      application,
247      f,
248    );
249  
250    return applicationSchema.parse(await res.json());
251  }
252  
253  export async function getApplications(
254    f = fetch,
255    roundId: string,
256    limit: number = 1000,
257    offset: number = 0,
258    sortBy: string = 'createdAt:desc',
259    filterByUserId: string | null = null,
260    filterByStatus: 'approved' | 'rejected' | 'pending' | null = null,
261    filterByCategoryId: string | null = null,
262  ): Promise<ListingApplication[]> {
263    const res = await authenticatedRpgfServerCall(
264      `/rounds/${roundId}/applications?sort=${sortBy}&limit=${limit}&offset=${offset}${
265        filterByUserId ? `&submitterUserId=${filterByUserId}` : ''
266      }${filterByStatus ? `&state=${filterByStatus}` : ''}${
267        filterByCategoryId ? `&categoryId=${filterByCategoryId}` : ''
268      }`,
269      'GET',
270      undefined,
271      f,
272    );
273  
274    return listingApplicationSchema.array().parse(await res.json());
275  }
276  
277  export async function getApplicationsCsv(
278    f = fetch,
279    roundSlug: string,
280    onlyApproved = false,
281  ): Promise<string> {
282    const res = await authenticatedRpgfServerCall(
283      `/rounds/${roundSlug}/applications?format=csv${onlyApproved ? '&state=approved' : ''}`,
284      'GET',
285      undefined,
286      f,
287    );
288  
289    if (!res.ok) {
290      throw new Error(`${res.status} - Failed to fetch applications CSV: ${res.statusText}`);
291    }
292  
293    return await res.text();
294  }
295  
296  export async function getApplicationsXlsx(
297    f = fetch,
298    roundSlug: string,
299    onlyApproved = false,
300  ): Promise<Blob> {
301    const res = await authenticatedRpgfServerCall(
302      `/rounds/${roundSlug}/applications?format=xlsx${onlyApproved ? '&state=approved' : ''}`,
303      'GET',
304      undefined,
305      f,
306    );
307  
308    if (!res.ok) {
309      throw new Error(`${res.status} - Failed to fetch applications XLSX: ${res.statusText}`);
310    }
311  
312    return await res.blob();
313  }
314  
315  export async function getApplication(
316    f = fetch,
317    roundId: string,
318    applicationId: string,
319  ): Promise<Application> {
320    const res = await authenticatedRpgfServerCall(
321      `/rounds/${roundId}/applications/${applicationId}`,
322      'GET',
323      undefined,
324      f,
325    );
326  
327    return applicationSchema.parse(await res.json());
328  }
329  
330  export async function getApplicationHistory(
331    f = fetch,
332    roundId: string,
333    applicationId: string,
334  ): Promise<ApplicationVersion[]> {
335    const res = await authenticatedRpgfServerCall(
336      `/rounds/${roundId}/applications/${applicationId}/history`,
337      'GET',
338      undefined,
339      f,
340    );
341  
342    return applicationVersionSchema.array().parse(await res.json());
343  }
344  
345  export async function submitApplicationReview(
346    f = fetch,
347    roundId: string,
348    decisions: ApplicationReviewDto,
349  ): Promise<void> {
350    await authenticatedRpgfServerCall(`/rounds/${roundId}/applications/review`, 'POST', decisions, f);
351  }
352  
353  export async function castBallot(
354    f = fetch,
355    roundSlug: string,
356    ballot: Ballot,
357  ): Promise<WrappedBallot> {
358    const { signature, chainId } = await signBallot(ballot);
359  
360    const res = await authenticatedRpgfServerCall(
361      `/rounds/${roundSlug}/ballots`,
362      'PUT',
363      {
364        ballot,
365        signature,
366        chainId,
367      } satisfies SubmitBallotDto,
368      f,
369    );
370  
371    const parsed = wrappedBallotSchema.parse(await res.json());
372    return parsed;
373  }
374  
375  type SpreadsheetFormat = 'csv' | 'xlsx';
376  type SpreadsheetBody = string | ArrayBuffer;
377  
378  const SPREADSHEET_CONTENT_TYPE: Record<
379    SpreadsheetFormat,
380    Parameters<typeof authenticatedRpgfServerCall>[6]
381  > = {
382    csv: 'text/csv',
383    xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
384  };
385  
386  export async function parseBallotSpreadsheet(
387    f = fetch,
388    roundSlug: string,
389    data: SpreadsheetBody,
390    format: SpreadsheetFormat,
391  ): Promise<Ballot> {
392    const res = await authenticatedRpgfServerCall(
393      `/rounds/${roundSlug}/ballots/parse-spreadsheet?format=${format}`,
394      'POST',
395      data,
396      f,
397      true,
398      false,
399      SPREADSHEET_CONTENT_TYPE[format],
400    );
401  
402    return z.object({ ballot: ballotSchema }).parse(await res.json()).ballot;
403  }
404  
405  async function submitSpreadsheetBallot(
406    f = fetch,
407    roundSlug: string,
408    data: SpreadsheetBody,
409    format: SpreadsheetFormat,
410    addressOverride?: string,
411  ): Promise<WrappedBallot> {
412    const ballot = await parseBallotSpreadsheet(f, roundSlug, data, format);
413    const { signature, chainId } = await signBallot(ballot);
414  
415    const query = new URLSearchParams({
416      format,
417      signature,
418      chainId: chainId.toString(),
419    });
420  
421    if (addressOverride) {
422      query.set('addressOverride', addressOverride);
423    }
424  
425    const res = await authenticatedRpgfServerCall(
426      `/rounds/${roundSlug}/ballots/spreadsheet?${query.toString()}`,
427      'POST',
428      data,
429      f,
430      true,
431      false,
432      SPREADSHEET_CONTENT_TYPE[format],
433    );
434  
435    const parsed = wrappedBallotSchema.parse(await res.json());
436    return parsed;
437  }
438  
439  export async function castBallotAsCsv(
440    f = fetch,
441    roundSlug: string,
442    data: string,
443    addressOverride?: string,
444  ): Promise<WrappedBallot> {
445    return submitSpreadsheetBallot(f, roundSlug, data, 'csv', addressOverride);
446  }
447  
448  export async function castBallotAsXlsx(
449    f = fetch,
450    roundSlug: string,
451    data: ArrayBuffer,
452    addressOverride?: string,
453  ): Promise<WrappedBallot> {
454    return submitSpreadsheetBallot(f, roundSlug, data, 'xlsx', addressOverride);
455  }
456  
457  async function importResultsSpreadsheet(
458    f = fetch,
459    roundId: string,
460    data: SpreadsheetBody,
461    format: SpreadsheetFormat,
462  ): Promise<void> {
463    await authenticatedRpgfServerCall(
464      `/rounds/${roundId}/results/import?format=${format}`,
465      'POST',
466      data,
467      f,
468      true,
469      false,
470      SPREADSHEET_CONTENT_TYPE[format],
471    );
472  }
473  
474  export async function importResultsAsCsv(f = fetch, roundId: string, data: string): Promise<void> {
475    return importResultsSpreadsheet(f, roundId, data, 'csv');
476  }
477  
478  export async function importResultsAsXlsx(
479    f = fetch,
480    roundId: string,
481    data: ArrayBuffer,
482  ): Promise<void> {
483    return importResultsSpreadsheet(f, roundId, data, 'xlsx');
484  }
485  
486  export async function getOwnBallot(f = fetch, roundSlug: string): Promise<WrappedBallot | null> {
487    const res = await authenticatedRpgfServerCall(
488      `/rounds/${roundSlug}/ballots/own`,
489      'GET',
490      undefined,
491      f,
492    );
493  
494    if (res.status === 404) {
495      return null;
496    }
497  
498    const parsed = wrappedBallotSchema.parse(await res.json());
499    return parsed;
500  }
501  
502  export async function getBallots(f = fetch, roundSlug: string): Promise<WrappedBallot[]> {
503    const res = await authenticatedRpgfServerCall(
504      // TODO(rpgf) pagination
505      `/rounds/${roundSlug}/ballots?limit=1000`,
506      'GET',
507      undefined,
508      f,
509    );
510  
511    const parsed = wrappedBallotSchema.array().parse(await res.json());
512    return parsed;
513  }
514  
515  export async function getBallotsCsv(f = fetch, roundSlug: string): Promise<string> {
516    const res = await authenticatedRpgfServerCall(
517      `/rounds/${roundSlug}/ballots?format=csv&limit=1000`,
518      'GET',
519      undefined,
520      f,
521    );
522  
523    if (!res.ok) {
524      throw new Error(`${res.status} - Failed to fetch ballots CSV: ${res.statusText}`);
525    }
526  
527    return await res.text();
528  }
529  
530  export async function getBallotStats(
531    f = fetch,
532    roundSlug: string,
533  ): Promise<{
534    numberOfVoters: number;
535    numberOfBallots: number;
536  }> {
537    const res = await authenticatedRpgfServerCall(
538      `/rounds/${roundSlug}/ballots/stats`,
539      'GET',
540      undefined,
541      f,
542    );
543  
544    if (!res.ok) {
545      throw new Error(`${res.status} - Failed to fetch ballot stats: ${res.statusText}`);
546    }
547  
548    const data = await res.json();
549  
550    return z
551      .object({
552        numberOfVoters: z.number(),
553        numberOfBallots: z.number(),
554      })
555      .parse(data);
556  }
557  
558  export async function recalculateResults(
559    f = fetch,
560    roundId: string,
561    method: 'avg' | 'median' | 'sum',
562  ): Promise<void> {
563    await authenticatedRpgfServerCall(
564      `/rounds/${roundId}/results/recalculate?method=${method}`,
565      'POST',
566      undefined,
567      f,
568    );
569  
570    return;
571  }
572  
573  export async function publishResults(f = fetch, roundId: string): Promise<void> {
574    await authenticatedRpgfServerCall(`/rounds/${roundId}/results/publish`, 'POST', undefined, f);
575  
576    return;
577  }
578  
579  export async function getDripListWeightsForRound(
580    f = fetch,
581    roundId: string,
582  ): Promise<Record<string, number>> {
583    const res = await authenticatedRpgfServerCall(
584      `/rounds/${roundId}/results/drip-list-weights`,
585      'GET',
586      undefined,
587      f,
588    );
589  
590    const parsed = z.record(z.string(), z.number()).parse(await res.json());
591    return parsed;
592  }
593  
594  export async function linkDripListsToRound(
595    f = fetch,
596    roundId: string,
597    dripListAccountIds: string[],
598  ): Promise<void> {
599    await authenticatedRpgfServerCall(
600      `/rounds/${roundId}/drip-lists`,
601      'PATCH',
602      {
603        dripListAccountIds,
604      },
605      f,
606    );
607  
608    return;
609  }
610  
611  export async function getOwnUserData(f = fetch): Promise<{
612    walletAddress: string;
613    id: string;
614    whitelisted: boolean;
615  } | null> {
616    const res = await authenticatedRpgfServerCall(
617      `/users/me?chainId=${network.chainId}`,
618      'GET',
619      undefined,
620      f,
621    );
622  
623    if (res.status === 401) {
624      return null;
625    }
626  
627    return z
628      .object({
629        walletAddress: z.string(),
630        id: z.string(),
631        whitelisted: z.boolean(),
632      })
633      .parse(await res.json());
634  }
635  
636  export async function getRoundAdmins(f = fetch, roundId: string): Promise<RpgfUser[]> {
637    const res = await authenticatedRpgfServerCall(`/rounds/${roundId}/admins`, 'GET', undefined, f);
638  
639    return userSchema.array().parse(await res.json());
640  }
641  
642  export async function setRoundAdmins(
643    f = fetch,
644    roundId: string,
645    walletAddresses: string[],
646  ): Promise<RpgfUser[]> {
647    const res = await authenticatedRpgfServerCall(
648      `/rounds/${roundId}/admins`,
649      'PUT',
650      {
651        walletAddresses,
652      },
653      f,
654    );
655  
656    return userSchema.array().parse(await res.json());
657  }
658  
659  export async function getRoundVoters(f = fetch, roundId: string): Promise<RpgfUser[]> {
660    const res = await authenticatedRpgfServerCall(`/rounds/${roundId}/voters`, 'GET', undefined, f);
661  
662    return userSchema.array().parse(await res.json());
663  }
664  
665  export async function setRoundVoters(
666    f = fetch,
667    roundId: string,
668    walletAddresses: string[],
669  ): Promise<RpgfUser[]> {
670    const res = await authenticatedRpgfServerCall(
671      `/rounds/${roundId}/voters`,
672      'PUT',
673      {
674        walletAddresses,
675      },
676      f,
677    );
678  
679    return userSchema.array().parse(await res.json());
680  }
681  
682  export async function getApplicationCategories(
683    f = fetch,
684    roundId: string,
685  ): Promise<ApplicationCategory[]> {
686    const res = await authenticatedRpgfServerCall(
687      `/rounds/${roundId}/application-categories`,
688      'GET',
689      undefined,
690      f,
691    );
692  
693    return applicationCategorySchema.array().parse(await res.json());
694  }
695  
696  export async function createApplicationCategory(
697    f = fetch,
698    roundId: string,
699    dto: CreateApplicationCategoryDto,
700  ): Promise<ApplicationCategory> {
701    const res = await authenticatedRpgfServerCall(
702      `/rounds/${roundId}/application-categories`,
703      'PUT',
704      dto,
705      f,
706    );
707  
708    return applicationCategorySchema.parse(await res.json());
709  }
710  
711  export async function deleteApplicationCategory(
712    f = fetch,
713    roundId: string,
714    categoryId: string,
715  ): Promise<void> {
716    await authenticatedRpgfServerCall(
717      `/rounds/${roundId}/application-categories/${categoryId}`,
718      'DELETE',
719      undefined,
720      f,
721    );
722  
723    return;
724  }
725  
726  export async function getApplicationForms(f = fetch, roundId: string): Promise<ApplicationForm[]> {
727    const res = await authenticatedRpgfServerCall(
728      `/rounds/${roundId}/application-forms`,
729      'GET',
730      undefined,
731      f,
732    );
733  
734    return applicationFormSchema.array().parse(await res.json());
735  }
736  
737  export async function getApplicationForm(
738    f = fetch,
739    roundId: string,
740    formId: string,
741  ): Promise<ApplicationForm | null> {
742    const res = await authenticatedRpgfServerCall(
743      `/rounds/${roundId}/application-forms/${formId}`,
744      'GET',
745      undefined,
746      f,
747    );
748  
749    if (res.status === 404) {
750      return null;
751    }
752  
753    return applicationFormSchema.parse(await res.json());
754  }
755  
756  export async function createApplicationForm(
757    f = fetch,
758    roundId: string,
759    dto: CreateApplicationFormDto,
760  ): Promise<ApplicationForm> {
761    const res = await authenticatedRpgfServerCall(
762      `/rounds/${roundId}/application-forms`,
763      'PUT',
764      dto,
765      f,
766    );
767  
768    return applicationFormSchema.parse(await res.json());
769  }
770  
771  export async function deleteApplicationForm(
772    f = fetch,
773    roundId: string,
774    formId: string,
775  ): Promise<void> {
776    await authenticatedRpgfServerCall(
777      `/rounds/${roundId}/application-forms/${formId}`,
778      'DELETE',
779      undefined,
780      f,
781    );
782  
783    return;
784  }
785  
786  export async function updateApplicationForm(
787    f = fetch,
788    roundId: string,
789    formId: string,
790    dto: CreateApplicationFormDto,
791  ): Promise<ApplicationForm> {
792    const res = await authenticatedRpgfServerCall(
793      `/rounds/${roundId}/application-forms/${formId}`,
794      'PATCH',
795      dto,
796      f,
797    );
798  
799    return applicationFormSchema.parse(await res.json());
800  }
801  
802  export async function getAuditLog(
803    f = fetch,
804    roundId: string,
805  ): Promise<{
806    logs: AuditLog[];
807  }> {
808    const res = await authenticatedRpgfServerCall(
809      // No pagination for now, fetch up to 1000 entries
810      `/rounds/${roundId}/audit-logs?limit=1000`,
811      'GET',
812      undefined,
813      f,
814    );
815  
816    return z
817      .object({
818        logs: auditLogSchema.array(),
819      })
820      .parse(await res.json());
821  }
822  
823  export async function createKycRequestForApplication(
824    f = fetch,
825    applicationId: string,
826    dto: CreateKycRequestForApplicationDto,
827  ): Promise<KycRequest> {
828    const res = await authenticatedRpgfServerCall(
829      `/kyc/applications/${applicationId}/request`,
830      'POST',
831      dto,
832      f,
833    );
834  
835    return kycRequestSchema.parse(await res.json());
836  }
837  
838  export async function getKycRequestForApplication(
839    f = fetch,
840    applicationId: string,
841  ): Promise<KycRequest | null> {
842    const res = await authenticatedRpgfServerCall(
843      `/kyc/applications/${applicationId}/request`,
844      'GET',
845      undefined,
846      f,
847    );
848  
849    if (res.status === 404) {
850      return null;
851    }
852  
853    return kycRequestSchema.parse(await res.json());
854  }
855  
856  export async function getKycRequestsForRound(f = fetch, roundId: string): Promise<KycRequest[]> {
857    const res = await authenticatedRpgfServerCall(
858      `/kyc/rounds/${roundId}/requests`,
859      'GET',
860      undefined,
861      f,
862    );
863  
864    return kycRequestSchema.array().parse(await res.json());
865  }
866  
867  export async function linkExistingKycRequestToApplication(
868    f = fetch,
869    applicationId: string,
870    kycRequestId: string,
871  ): Promise<void> {
872    await authenticatedRpgfServerCall(
873      `/kyc/applications/${applicationId}/link-existing`,
874      'POST',
875      { kycRequestId },
876      f,
877    );
878  
879    return;
880  }
881  
882  export async function getCustomDatasetsForRound(
883    f = fetch,
884    roundId: string,
885  ): Promise<CustomDataset[]> {
886    const res = await authenticatedRpgfServerCall(
887      `/rounds/${roundId}/custom-datasets`,
888      'GET',
889      undefined,
890      f,
891    );
892  
893    return customDatasetSchema.array().parse(await res.json());
894  }
895  
896  export async function createCustomDataset(
897    f = fetch,
898    roundId: string,
899    name: string,
900  ): Promise<CustomDataset> {
901    const res = await authenticatedRpgfServerCall(
902      `/rounds/${roundId}/custom-datasets`,
903      'PUT',
904      { name, isPublic: false },
905      f,
906    );
907  
908    return customDatasetSchema.parse(await res.json());
909  }
910  
911  export async function updateCustomDataset(
912    f = fetch,
913    roundId: string,
914    datasetId: string,
915    name: string,
916    isPublic: boolean,
917  ): Promise<CustomDataset> {
918    const res = await authenticatedRpgfServerCall(
919      `/rounds/${roundId}/custom-datasets/${datasetId}`,
920      'PATCH',
921      { name, isPublic },
922      f,
923    );
924  
925    return customDatasetSchema.parse(await res.json());
926  }
927  
928  export async function deleteCustomDataset(
929    f = fetch,
930    roundId: string,
931    datasetId: string,
932  ): Promise<void> {
933    await authenticatedRpgfServerCall(
934      `/rounds/${roundId}/custom-datasets/${datasetId}`,
935      'DELETE',
936      undefined,
937      f,
938    );
939  
940    return;
941  }
942  
943  export async function uploadCustomDatasetCsv(
944    f = fetch,
945    roundId: string,
946    datasetId: string,
947    csvData: string,
948  ): Promise<CustomDataset | { error: string }> {
949    const res = await authenticatedRpgfServerCall(
950      `/rounds/${roundId}/custom-datasets/${datasetId}/upload`,
951      'POST',
952      csvData,
953      f,
954      true,
955      true,
956      'text/csv',
957    );
958  
959    return customDatasetSchema
960      .or(
961        z.object({
962          error: z.string(),
963        }),
964      )
965      .parse(await res.json());
966  }