mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-06-03 10:01:47 -04:00
563 lines
14 KiB
TypeScript
563 lines
14 KiB
TypeScript
import cookies from 'browser-cookies';
|
|
import { createApi, fetchBaseQuery, retry } from '@reduxjs/toolkit/query/react';
|
|
|
|
import envData from '../../config/env.json';
|
|
import type {
|
|
ChallengeFile,
|
|
ChallengeFiles,
|
|
CompletedChallenge,
|
|
ExamTokenResponse,
|
|
GenerateExamResponseWithData,
|
|
SavedChallenge,
|
|
SavedChallengeFile,
|
|
SurveyResults,
|
|
User
|
|
} from '../redux/prop-types';
|
|
import { DonationDuration } from '@freecodecamp/shared/config/donation-settings';
|
|
|
|
const { apiLocation } = envData;
|
|
|
|
const base = apiLocation;
|
|
|
|
const defaultOptions: RequestInit = {
|
|
credentials: 'include'
|
|
};
|
|
|
|
// csrf_token is passed to the client as a cookie. The client must send
|
|
// this back as a header.
|
|
function getCSRFToken() {
|
|
const token =
|
|
typeof window !== 'undefined' ? cookies.get('csrf_token') : null;
|
|
return token ?? '';
|
|
}
|
|
|
|
export interface ResponseWithData<T> {
|
|
response: Response;
|
|
data: T;
|
|
}
|
|
|
|
// TODO: Might want to handle flash messages as close to the request as possible
|
|
// to make use of the Response object (message, status, etc)
|
|
async function get<T>(
|
|
path: string,
|
|
signal?: AbortSignal
|
|
): Promise<ResponseWithData<T>> {
|
|
const response = await fetch(`${base}${path}`, {
|
|
...defaultOptions,
|
|
headers: { 'CSRF-Token': getCSRFToken() },
|
|
signal
|
|
});
|
|
|
|
return combineDataWithResponse(response);
|
|
}
|
|
|
|
async function combineDataWithResponse<T>(response: Response) {
|
|
const data = (await response.json()) as T;
|
|
return { response, data };
|
|
}
|
|
|
|
export function post<T = void>(
|
|
path: string,
|
|
body: unknown
|
|
): Promise<ResponseWithData<T>> {
|
|
return request('POST', path, body);
|
|
}
|
|
|
|
function put<T = void>(
|
|
path: string,
|
|
body: unknown
|
|
): Promise<ResponseWithData<T>> {
|
|
return request('PUT', path, body);
|
|
}
|
|
|
|
function deleteRequest<T = void>(
|
|
path: string,
|
|
body: unknown
|
|
): Promise<ResponseWithData<T>> {
|
|
return request('DELETE', path, body);
|
|
}
|
|
|
|
async function request<T>(
|
|
method: 'POST' | 'PUT' | 'DELETE',
|
|
path: string,
|
|
body: unknown
|
|
): Promise<ResponseWithData<T>> {
|
|
const options: RequestInit = {
|
|
...defaultOptions,
|
|
method,
|
|
headers: {
|
|
'CSRF-Token': getCSRFToken(),
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(body)
|
|
};
|
|
|
|
const response = await fetch(`${base}${path}`, options);
|
|
return combineDataWithResponse(response);
|
|
}
|
|
|
|
/** GET **/
|
|
|
|
type CompleteChallengeFromApi = {
|
|
files: Array<Omit<ChallengeFile, 'fileKey'> & { key: string }>;
|
|
} & Omit<CompletedChallenge, 'challengeFiles'>;
|
|
|
|
type SavedChallengeFromApi = {
|
|
files: Array<Omit<SavedChallengeFile, 'fileKey'> & { key: string }>;
|
|
} & Omit<SavedChallenge, 'challengeFiles'>;
|
|
|
|
type ApiUser = Omit<User, 'completedChallenges' | 'savedChallenges'> & {
|
|
completedChallenges?: CompleteChallengeFromApi[];
|
|
savedChallenges?: SavedChallengeFromApi[];
|
|
};
|
|
|
|
type ApiUserResponse = {
|
|
user: {
|
|
[username: string]: ApiUser;
|
|
};
|
|
result?: string;
|
|
};
|
|
|
|
function parseApiResponseToClientUser(data: ApiUserResponse): User | null {
|
|
const userData = data.user?.[data?.result ?? ''];
|
|
let completedChallenges: CompletedChallenge[] = [];
|
|
let savedChallenges: SavedChallenge[] = [];
|
|
if (userData) {
|
|
completedChallenges = mapFilesToChallengeFiles(
|
|
userData.completedChallenges
|
|
);
|
|
savedChallenges = mapFilesToChallengeFiles(userData.savedChallenges);
|
|
}
|
|
return data.result
|
|
? { ...userData, completedChallenges, savedChallenges }
|
|
: null;
|
|
}
|
|
|
|
// TODO: this at least needs a few aliases so it's human readable
|
|
export function mapFilesToChallengeFiles<File, Rest>(
|
|
fileContainer: ({ files: (File & { key: string })[] } & Rest)[] = []
|
|
) {
|
|
return fileContainer.map(({ files, ...rest }) => ({
|
|
...rest,
|
|
challengeFiles: mapKeyToFileKey(files)
|
|
}));
|
|
}
|
|
|
|
function mapKeyToFileKey<K>(
|
|
files: (K & { key: string })[]
|
|
): (Omit<K, 'key'> & { fileKey: string })[] {
|
|
return files.map(({ key, ...rest }) => ({ ...rest, fileKey: key }));
|
|
}
|
|
|
|
export function getSessionUser(
|
|
signal?: AbortSignal
|
|
): Promise<ResponseWithData<User | null>> {
|
|
const responseWithData: Promise<ResponseWithData<ApiUserResponse>> = get(
|
|
'/user/session-user',
|
|
signal
|
|
);
|
|
// TODO: Once DB is migrated, no longer need to parse `files` -> `challengeFiles` etc.
|
|
return responseWithData.then(({ response, data }) => {
|
|
const user = parseApiResponseToClientUser(data);
|
|
return {
|
|
response,
|
|
data: user
|
|
};
|
|
});
|
|
}
|
|
|
|
type UserProfileResponse = {
|
|
entities: Omit<ApiUserResponse, 'result'>;
|
|
result: string | undefined;
|
|
};
|
|
export function getUserProfile(
|
|
username: string
|
|
): Promise<ResponseWithData<User | null>> {
|
|
const responseWithData = get<UserProfileResponse>(
|
|
`/users/get-public-profile?username=${username}`
|
|
);
|
|
return responseWithData.then(({ response, data }) => {
|
|
const user = parseApiResponseToClientUser({
|
|
user: data.entities?.user ?? {},
|
|
result: data.result
|
|
});
|
|
return {
|
|
response,
|
|
data: user
|
|
};
|
|
});
|
|
}
|
|
|
|
interface Cert {
|
|
certTitle: string;
|
|
username: string;
|
|
date: Date;
|
|
completionTime: string;
|
|
}
|
|
export function getShowCert(
|
|
username: string,
|
|
certSlug: string
|
|
): Promise<ResponseWithData<Cert>> {
|
|
return get(`/certificate/showCert/${username}/${certSlug}`);
|
|
}
|
|
|
|
export function getUsernameExists(
|
|
username: string
|
|
): Promise<ResponseWithData<boolean>> {
|
|
return get(`/users/exists?username=${username}`);
|
|
}
|
|
|
|
export function getGenerateExam(
|
|
challengeId: string
|
|
): Promise<GenerateExamResponseWithData> {
|
|
return get(`/exam/${challengeId}`);
|
|
}
|
|
|
|
export interface Exam {
|
|
id: string;
|
|
config: {
|
|
name: string;
|
|
};
|
|
}
|
|
|
|
export type Attempt = {
|
|
id: string;
|
|
examId: string;
|
|
// ISO 8601 string
|
|
startTime: string;
|
|
questionSets: unknown[];
|
|
} & (
|
|
| {
|
|
result: null;
|
|
status:
|
|
| 'InProgress'
|
|
| 'Expired'
|
|
| 'PendingModeration'
|
|
| 'Denied'
|
|
| 'AwaitingChallenges';
|
|
}
|
|
| {
|
|
status: 'Approved';
|
|
result: {
|
|
passingPercent: number;
|
|
score: number;
|
|
};
|
|
}
|
|
);
|
|
|
|
export function getExams(): Promise<ResponseWithData<{ exams: Exam[] }>> {
|
|
return get('/user/exam-environment/exams');
|
|
}
|
|
|
|
export function getExamAttempts(): Promise<ResponseWithData<Attempt[]>> {
|
|
return get('/user/exam-environment/exam/attempts');
|
|
}
|
|
|
|
/** POST **/
|
|
|
|
interface Donation {
|
|
email: string;
|
|
amount: number;
|
|
duration: string;
|
|
provider: string;
|
|
subscriptionId: string;
|
|
customerId: string;
|
|
startDate: Date;
|
|
}
|
|
|
|
interface SocratesHintPayload {
|
|
userInput: string;
|
|
seed: string;
|
|
description: string;
|
|
hints: Array<{ text: string; failed?: boolean }>;
|
|
}
|
|
|
|
interface SocratesHintResponse {
|
|
hint?: string;
|
|
error?: string;
|
|
type?: string;
|
|
attempts?: number;
|
|
limit?: number;
|
|
}
|
|
// TODO: Verify if the body has and needs this Donation type. The api seems to
|
|
// just need the body to exist, but doesn't seem to use the properties.
|
|
export function addDonation(body: Donation): Promise<ResponseWithData<void>> {
|
|
return post('/donate/add-donation', body);
|
|
}
|
|
|
|
export function updateStripeCard() {
|
|
return put('/donate/update-stripe-card', {});
|
|
}
|
|
|
|
export function postChargeStripe(
|
|
body: Donation
|
|
): Promise<ResponseWithData<void>> {
|
|
return post('/donate/charge-stripe', body);
|
|
}
|
|
|
|
export function postChargeStripeCard(
|
|
body: Donation
|
|
): Promise<ResponseWithData<void>> {
|
|
return post('/donate/charge-stripe-card', body);
|
|
}
|
|
|
|
export function generateExamToken(): Promise<
|
|
ResponseWithData<ExamTokenResponse>
|
|
> {
|
|
return post('/user/exam-environment/token', {});
|
|
}
|
|
export function getSocratesHint(
|
|
body: SocratesHintPayload
|
|
): Promise<ResponseWithData<SocratesHintResponse>> {
|
|
return put('/socrates/get-hint', body);
|
|
}
|
|
|
|
type PaymentIntentResponse = Promise<
|
|
ResponseWithData<
|
|
| {
|
|
clientSecret?: never;
|
|
subscriptionId?: never;
|
|
error: string;
|
|
}
|
|
| {
|
|
clientSecret: string;
|
|
subscriptionId: string;
|
|
error?: never;
|
|
}
|
|
>
|
|
>;
|
|
|
|
export function createStripePaymentIntent(body: {
|
|
email: string | undefined;
|
|
name: string | undefined;
|
|
amount: number;
|
|
duration: DonationDuration;
|
|
}): PaymentIntentResponse {
|
|
return post('/donate/create-stripe-payment-intent', body);
|
|
}
|
|
|
|
interface Report {
|
|
username: string;
|
|
reportDescription: string;
|
|
}
|
|
export function postReportUser(body: Report): Promise<ResponseWithData<void>> {
|
|
return post('/user/report-user', body);
|
|
}
|
|
|
|
// Both are called without a payload in danger-zone-saga,
|
|
// which suggests both are sent without any body
|
|
// TODO: Convert to DELETE
|
|
export function postDeleteAccount(): Promise<ResponseWithData<void>> {
|
|
return post('/account/delete', {});
|
|
}
|
|
|
|
export function postResetProgress(): Promise<ResponseWithData<void>> {
|
|
return post('/account/reset-progress', {});
|
|
}
|
|
|
|
export function deleteResetModule(body: {
|
|
blockIds: string[];
|
|
}): Promise<ResponseWithData<{ removedChallengeIds: string[] }>> {
|
|
return deleteRequest('/account/reset-module', body);
|
|
}
|
|
|
|
export function postUserToken(): Promise<ResponseWithData<void>> {
|
|
return post('/user/user-token', {});
|
|
}
|
|
|
|
export function postMsUsername(body: {
|
|
msTranscriptUrl: string;
|
|
}): Promise<ResponseWithData<void>> {
|
|
return post('/user/ms-username', body);
|
|
}
|
|
|
|
export function postSaveChallenge(body: {
|
|
id: string;
|
|
files: ChallengeFiles;
|
|
}): Promise<ResponseWithData<void>> {
|
|
return post('/encoded/save-challenge', body);
|
|
}
|
|
|
|
export function postSubmitSurvey(body: {
|
|
surveyResults: SurveyResults;
|
|
}): Promise<ResponseWithData<void>> {
|
|
return post('/user/submit-survey', body);
|
|
}
|
|
|
|
/** PUT **/
|
|
|
|
interface MyAbout {
|
|
name: string;
|
|
location: string;
|
|
about: string;
|
|
picture: string;
|
|
}
|
|
export function putUpdateMyAbout(
|
|
values: MyAbout
|
|
): Promise<ResponseWithData<void>> {
|
|
return put('/update-my-about', { ...values });
|
|
}
|
|
|
|
export function putUpdateMyUsername(
|
|
username: string
|
|
): Promise<ResponseWithData<void>> {
|
|
return put('/update-my-username', { username });
|
|
}
|
|
|
|
export function putUpdateMyProfileUI(
|
|
profileUI: User['profileUI']
|
|
): Promise<ResponseWithData<void>> {
|
|
return put('/update-my-profileui', { profileUI });
|
|
}
|
|
|
|
export function putUpdateMySocials(
|
|
update: Record<string, string>
|
|
): Promise<ResponseWithData<void>> {
|
|
return put('/update-my-socials', update);
|
|
}
|
|
|
|
export function putUpdateMyKeyboardShortcuts(
|
|
update: Record<string, string>
|
|
): Promise<ResponseWithData<void>> {
|
|
return put('/update-my-keyboard-shortcuts', update);
|
|
}
|
|
|
|
export function putUpdateMyClassroomMode(
|
|
update: Record<string, boolean>
|
|
): Promise<ResponseWithData<void>> {
|
|
return put('/update-my-classroom-mode', update);
|
|
}
|
|
|
|
export function putUpdateMyHonesty(
|
|
update: Record<string, string>
|
|
): Promise<ResponseWithData<void>> {
|
|
return put('/update-my-honesty', update);
|
|
}
|
|
|
|
export function putUpdateMyQuincyEmail(update: {
|
|
sendQuincyEmail: boolean;
|
|
}): Promise<ResponseWithData<void>> {
|
|
return put('/update-my-quincy-email', update);
|
|
}
|
|
|
|
export function putUpdateMySocrates(update: {
|
|
socrates: boolean;
|
|
}): Promise<ResponseWithData<void>> {
|
|
return put('/update-socrates', { socrates: update.socrates });
|
|
}
|
|
|
|
export function putUpdateMyPortfolio(
|
|
update: Record<string, string>
|
|
): Promise<ResponseWithData<void>> {
|
|
return put('/update-my-portfolio', update);
|
|
}
|
|
|
|
export function putUpdateMyExperience(
|
|
update: Record<string, string>
|
|
): Promise<ResponseWithData<void>> {
|
|
return put('/update-my-experience', update);
|
|
}
|
|
|
|
export function putUserUpdateEmail(
|
|
email: string
|
|
): Promise<ResponseWithData<void>> {
|
|
return put('/update-my-email', { email });
|
|
}
|
|
|
|
export function putVerifyCert(
|
|
certSlug: string
|
|
): Promise<ResponseWithData<void>> {
|
|
return put('/certificate/verify', { certSlug });
|
|
}
|
|
|
|
/** DELETE **/
|
|
export function deleteUserToken(): Promise<ResponseWithData<void>> {
|
|
return deleteRequest('/user/user-token', {});
|
|
}
|
|
|
|
export function deleteMsUsername(): Promise<ResponseWithData<void>> {
|
|
return deleteRequest('/user/ms-username', {});
|
|
}
|
|
|
|
/** RTK */
|
|
|
|
export interface ExamEnvironmentChallenge {
|
|
id: string;
|
|
examId: string;
|
|
challengeId: string;
|
|
}
|
|
|
|
export type GetExamsResponse = Array<{
|
|
id: string;
|
|
config: {
|
|
name: string;
|
|
note: string;
|
|
totalTimeInS: number;
|
|
retakeTimeInS: number;
|
|
passingPercent: number;
|
|
};
|
|
canTake: boolean;
|
|
prerequisites: string[];
|
|
}>;
|
|
|
|
export const examAttempts = createApi({
|
|
reducerPath: 'exam-attempts',
|
|
baseQuery: retry(
|
|
fetchBaseQuery({
|
|
baseUrl: apiLocation,
|
|
prepareHeaders(headers) {
|
|
headers.set('CSRF-Token', getCSRFToken());
|
|
return headers;
|
|
},
|
|
credentials: 'include'
|
|
}),
|
|
// Retry in the case this is the initial request - csrf is not set yet, and initial returns 403
|
|
{ maxRetries: 2 }
|
|
),
|
|
endpoints: build => ({
|
|
getExamAttemptsByExamId: build.mutation<Attempt[], string>({
|
|
query: examId => `/user/exam-environment/exams/${examId}/attempts`
|
|
}),
|
|
getExamIdsByChallengeId: build.query<ExamEnvironmentChallenge[], string>({
|
|
query: challengeId =>
|
|
`/exam-environment/exam-challenge?challengeId=${challengeId}`
|
|
}),
|
|
getExams: build.query<GetExamsResponse, void>({
|
|
query: () => '/user/exam-environment/exams'
|
|
})
|
|
})
|
|
});
|
|
|
|
export const examEnvironmentAuthorizationTokenApi = createApi({
|
|
reducerPath: 'exam-environment-authorization-token',
|
|
baseQuery: retry(
|
|
fetchBaseQuery({
|
|
baseUrl: apiLocation,
|
|
prepareHeaders(headers) {
|
|
headers.set('CSRF-Token', getCSRFToken());
|
|
return headers;
|
|
},
|
|
credentials: 'include'
|
|
}),
|
|
// Retry in the case this is the initial request - csrf is not set yet, and initial returns 403
|
|
{ maxRetries: 2 }
|
|
),
|
|
endpoints: build => ({
|
|
postGenerateExamEnvironmentAuthorizationToken: build.mutation<
|
|
ExamTokenResponse,
|
|
void
|
|
>({
|
|
query: () => ({
|
|
url: `/user/exam-environment/token`,
|
|
method: 'POST'
|
|
})
|
|
}),
|
|
getExamEnvironmentAuthorizationToken: build.query<ExamTokenResponse, void>({
|
|
query: () => ({
|
|
url: `/user/exam-environment/token`,
|
|
method: 'GET'
|
|
})
|
|
})
|
|
})
|
|
});
|