import cookies from 'browser-cookies'; import envData from '../../config/env.json'; import type { ChallengeFile, ChallengeFiles, CompletedChallenge, GenerateExamResponseWithData, SavedChallenge, SavedChallengeFile, User } from '../redux/prop-types'; 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 { 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(path: string): Promise> { const response = await fetch(`${base}${path}`, defaultOptions); return combineDataWithResponse(response); } async function combineDataWithResponse(response: Response) { const data = (await response.json()) as T; return { response, data }; } export function post( path: string, body: unknown ): Promise> { return request('POST', path, body); } function put( path: string, body: unknown ): Promise> { return request('PUT', path, body); } function deleteRequest( path: string, body: unknown ): Promise> { return request('DELETE', path, body); } async function request( method: 'POST' | 'PUT' | 'DELETE', path: string, body: unknown ): Promise> { 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 **/ interface SessionUser { user?: { [username: string]: User }; } type CompleteChallengeFromApi = { files: Array & { key: string }>; } & Omit; type SavedChallengeFromApi = { files: Array & { key: string }>; } & Omit; type ApiSessionResponse = Omit; type ApiUser = { user: { [username: string]: Omit< User, 'completedChallenges' & 'savedChallenges' > & { completedChallenges?: CompleteChallengeFromApi[]; savedChallenges?: SavedChallengeFromApi[]; }; }; result?: string; }; type UserResponse = { user: { [username: string]: User } | Record; result: string | undefined; }; function parseApiResponseToClientUser(data: ApiUser): UserResponse { const userData = data.user?.[data?.result ?? '']; let completedChallenges: CompletedChallenge[] = []; let savedChallenges: SavedChallenge[] = []; if (userData) { completedChallenges = mapFilesToChallengeFiles( userData.completedChallenges ); savedChallenges = mapFilesToChallengeFiles(userData.savedChallenges); } return { user: { [data.result ?? '']: { ...userData, completedChallenges, savedChallenges } }, result: data.result }; } // TODO: this at least needs a few aliases so it's human readable // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export function mapFilesToChallengeFiles( fileContainer: ({ files: (File & { key: string })[] } & Rest)[] = [] ) { return fileContainer.map(({ files, ...rest }) => ({ ...rest, challengeFiles: mapKeyToFileKey(files) })); } function mapKeyToFileKey( files: (K & { key: string })[] ): (Omit & { fileKey: string })[] { return files.map(({ key, ...rest }) => ({ ...rest, fileKey: key })); } export function getSessionUser(): Promise> { const responseWithData: Promise< ResponseWithData > = get('/user/get-session-user'); // TODO: Once DB is migrated, no longer need to parse `files` -> `challengeFiles` etc. return responseWithData.then(({ response, data }) => { const { result, user } = parseApiResponseToClientUser(data); return { response, data: { result, user } }; }); } type UserProfileResponse = { entities: Omit; result: string | undefined; }; export function getUserProfile( username: string ): Promise> { const responseWithData = get<{ entities?: ApiUser; result?: string }>( `/api/users/get-public-profile?username=${username}` ); return responseWithData.then(({ response, data }) => { const { result, user } = parseApiResponseToClientUser({ user: data.entities?.user ?? {}, result: data.result }); return { response, data: { entities: { user }, result } }; }); } interface Cert { certTitle: string; username: string; date: Date; completionTime: string; } export function getShowCert( username: string, certSlug: string ): Promise> { return get(`/certificate/showCert/${username}/${certSlug}`); } export function getUsernameExists( username: string ): Promise> { return get(`/api/users/exists?username=${username}`); } export function getGenerateExam( challengeId: string ): Promise { return get(`/exam/${challengeId}`); } /** POST **/ interface Donation { email: string; amount: number; duration: string; provider: string; subscriptionId: string; customerId: string; startDate: Date; } // 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> { return post('/donate/add-donation', body); } export function postChargeStripe( body: Donation ): Promise> { return post('/donate/charge-stripe', body); } export function postChargeStripeCard( body: Donation ): Promise> { return post('/donate/charge-stripe-card', body); } interface Report { username: string; reportDescription: string; } export function postReportUser(body: Report): Promise> { 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> { return post('/account/delete', {}); } export function postResetProgress(): Promise> { return post('/account/reset-progress', {}); } export function postUserToken(): Promise> { return post('/user/user-token', {}); } export function postMsUsername(body: { msTranscriptUrl: string; }): Promise> { return post('/user/ms-username', body); } export function postSaveChallenge(body: { id: string; files: ChallengeFiles; }): Promise> { return post('/save-challenge', body); } /** PUT **/ interface MyAbout { name: string; location: string; about: string; picture: string; } export function putUpdateMyAbout( values: MyAbout ): Promise> { return put('/update-my-about', { ...values }); } export function putUpdateMyUsername( username: string ): Promise> { return put('/update-my-username', { username }); } export function putUpdateMyProfileUI( profileUI: User['profileUI'] ): Promise> { return put('/update-my-profileui', { profileUI }); } export function putUpdateMySocials( update: Record ): Promise> { return put('/update-my-socials', update); } export function putUpdateMyTheme( update: Record ): Promise> { return put('/update-my-theme', update); } export function putUpdateMyKeyboardShortcuts( update: Record ): Promise> { return put('/update-my-keyboard-shortcuts', update); } export function putUpdateMyHonesty( update: Record ): Promise> { return put('/update-my-honesty', update); } export function putUpdateMyQuincyEmail( update: Record ): Promise> { return put('/update-my-quincy-email', update); } export function putUpdateMyPortfolio( update: Record ): Promise> { return put('/update-my-portfolio', update); } export function putUserAcceptsTerms( quincyEmails: boolean ): Promise> { return put('/update-privacy-terms', { quincyEmails }); } export function putUserUpdateEmail( email: string ): Promise> { return put('/update-my-email', { email }); } export function putVerifyCert( certSlug: string ): Promise> { return put('/certificate/verify', { certSlug }); } /** DELETE **/ export function deleteUserToken(): Promise> { return deleteRequest('/user/user-token', {}); } export function deleteMsUsername(): Promise> { return deleteRequest('/user/ms-username', {}); }