chore: import upstream snapshot with attribution
i18n - Build Validation / Validate i18n Builds (24) (push) Has been cancelled
CI - Node.js / Lint (24) (push) Has been cancelled
CI - Node.js / Build (24) (push) Has been cancelled
CI - Node.js / Test (24) (push) Has been cancelled
CI - Node.js / Test - Upcoming Changes (24) (push) Has been cancelled
CI - Node.js / Test - i18n (italian, 24) (push) Has been cancelled
CI - Node.js / Test - i18n (portuguese, 24) (push) Has been cancelled
CD - Docker - GHCR Images / Build and Push Images (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 11:55:53 +08:00
commit dde272c4b8
19405 changed files with 2730632 additions and 0 deletions
+217
View File
@@ -0,0 +1,217 @@
import {
describe,
test,
expect,
beforeAll,
afterEach,
afterAll,
vi
} from 'vitest';
import Fastify, { FastifyInstance } from 'fastify';
import db from '../../db/prisma.js';
import { createUserInput } from '../../utils/create-user.js';
import { checkCanConnectToDb } from '../../../vitest.utils.js';
import { findOrCreateUser } from './auth-helpers.js';
import { assignVariantBucket } from '../../utils/drip-campaign.js';
import growthBook from '../../plugins/growth-book.js';
import {
GROWTHBOOK_FASTIFY_API_HOST,
GROWTHBOOK_FASTIFY_CLIENT_KEY
} from '../../utils/env.js';
async function setupServer() {
const fastify = Fastify();
await fastify.register(db);
await checkCanConnectToDb(fastify.prisma);
await fastify.register(growthBook, {
apiHost: GROWTHBOOK_FASTIFY_API_HOST,
clientKey: GROWTHBOOK_FASTIFY_CLIENT_KEY
});
return fastify;
}
describe('findOrCreateUser', () => {
let fastify: FastifyInstance;
const email = 'test@user.com';
beforeAll(async () => {
fastify = await setupServer();
});
afterAll(async () => {
await fastify.prisma.$runCommandRaw({ dropDatabase: 1 });
await fastify.close();
});
afterEach(async () => {
await fastify.prisma.user.deleteMany({ where: { email } });
await fastify.prisma.dripCampaign.deleteMany({ where: { email } });
vi.restoreAllMocks();
});
test('should log an error and capture an exception if there are multiple users with the same email', async () => {
const user1 = await fastify.prisma.user.create({
data: createUserInput(email)
});
const user2 = await fastify.prisma.user.create({
data: createUserInput(email)
});
const userIds = [user1.id, user2.id];
const logError = vi.spyOn(fastify.log, 'error');
const captureException = vi.fn();
const count = vi.fn();
// @ts-expect-error - Only mocks part of the Sentry object.
fastify.Sentry = { captureException, metrics: { count } };
await findOrCreateUser(fastify, email);
expect(logError).toHaveBeenCalledWith(
{ audit: true, userIds, email },
'Multiple user records found'
);
expect(captureException).toHaveBeenCalledWith(
new Error('Multiple user records found for: ' + userIds.join(', '))
);
expect(count).toHaveBeenCalledWith('user.duplicate_email_detected', 1);
});
test('should NOT log an error or capture an exception if there is only one user with the email', async () => {
await fastify.prisma.user.create({ data: createUserInput(email) });
const logError = vi.spyOn(fastify.log, 'error');
const captureException = vi.fn();
// @ts-expect-error - Only mocks part of the Sentry object.
fastify.Sentry = { captureException };
await findOrCreateUser(fastify, email);
expect(logError).not.toHaveBeenCalled();
expect(captureException).not.toHaveBeenCalled();
});
test('should NOT log an error or capture an exception if there are no users with the email', async () => {
const logError = vi.spyOn(fastify.log, 'error');
const captureException = vi.fn();
const count = vi.fn();
// @ts-expect-error - Only mocks part of the Sentry object.
fastify.Sentry = { captureException, metrics: { count } };
await findOrCreateUser(fastify, email);
expect(logError).not.toHaveBeenCalled();
expect(captureException).not.toHaveBeenCalled();
expect(count).toHaveBeenCalledWith('user.created', 1);
});
describe('drip campaign logic', () => {
test('should create a drip campaign record when a new user is created and feature flag is enabled', async () => {
vi.spyOn(fastify.gb, 'isOn').mockImplementationOnce(() => true);
const count = vi.fn();
// @ts-expect-error - Only mocks part of the Sentry object.
fastify.Sentry = { ...fastify.Sentry, metrics: { count } };
const user = await findOrCreateUser(fastify, email);
const dripCampaign = await fastify.prisma.dripCampaign.findFirst({
where: { userId: user.id }
});
expect(dripCampaign).toBeDefined();
expect(dripCampaign?.userId).toBe(user.id);
expect(dripCampaign?.email).toBe(email);
expect(['A', 'B']).toContain(dripCampaign?.variant);
expect(count).toHaveBeenCalledWith(
'growthbook.signup_flag_evaluated',
1,
{
attributes: { flag: 'drip-campaign', result: 'success' }
}
);
});
test('should assign a consistent variant based on userId', async () => {
vi.spyOn(fastify.gb, 'isOn').mockImplementationOnce(() => true);
const user = await findOrCreateUser(fastify, email);
const expectedVariant = assignVariantBucket(user.id);
const dripCampaign = await fastify.prisma.dripCampaign.findFirst({
where: { userId: user.id }
});
expect(dripCampaign?.variant).toBe(expectedVariant);
});
test('should not create a drip campaign record when feature flag is disabled', async () => {
vi.spyOn(fastify.gb, 'isOn').mockImplementationOnce(() => false);
const user = await findOrCreateUser(fastify, email);
const dripCampaign = await fastify.prisma.dripCampaign.findFirst({
where: { userId: user.id }
});
expect(dripCampaign).toBeNull();
});
test('should not prevent user creation if drip campaign record creation fails', async () => {
vi.spyOn(fastify.gb, 'isOn').mockImplementationOnce(() => true);
const captureException = vi.fn();
const count = vi.fn();
// @ts-expect-error - Only mocks part of the Sentry object.
fastify.Sentry = { captureException, metrics: { count } };
const originalCreate = fastify.prisma.dripCampaign.create;
fastify.prisma.dripCampaign.create = vi
.fn()
.mockRejectedValueOnce(new Error('Database error'));
const logError = vi.spyOn(fastify.log, 'error');
const user = await findOrCreateUser(fastify, email);
expect(user).toBeDefined();
expect(user.id).toBeTruthy();
const dbError: unknown = expect.objectContaining({
message: 'Database error'
});
expect(logError).toHaveBeenCalledWith(
{ err: dbError, userId: user.id },
'Failed to create drip campaign record for user'
);
expect(captureException).toHaveBeenCalledOnce();
expect(count).toHaveBeenCalledWith(
'growthbook.signup_flag_evaluated',
1,
{
attributes: { flag: 'drip-campaign', result: 'failed' }
}
);
fastify.prisma.dripCampaign.create = originalCreate;
});
test('should not create drip campaign for existing users', async () => {
vi.spyOn(fastify.gb, 'isOn').mockImplementationOnce(() => true);
// Create user first
await fastify.prisma.user.create({ data: createUserInput(email) });
// Call findOrCreateUser for existing user
await findOrCreateUser(fastify, email);
// Verify no drip campaign record was created
const dripCampaigns = await fastify.prisma.dripCampaign.findMany({
where: { email }
});
expect(dripCampaigns).toHaveLength(0);
});
});
});
+76
View File
@@ -0,0 +1,76 @@
import { FastifyInstance } from 'fastify';
import { createUserInput } from '../../utils/create-user.js';
import { assignVariantBucket } from '../../utils/drip-campaign.js';
/**
* Finds an existing user with the given email or creates a new user if none exists.
* @param fastify - The Fastify instance.
* @param email - The email of the user.
* @returns The existing or newly created user.
*/
export const findOrCreateUser = async (
fastify: FastifyInstance,
email: string
): Promise<{ id: string; acceptedPrivacyTerms: boolean }> => {
// TODO: handle the case where there are multiple users with the same email.
// e.g. use findMany and throw an error if more than one is found.
const existingUser = await fastify.prisma.user.findMany({
where: { email },
select: { id: true, acceptedPrivacyTerms: true }
});
if (existingUser.length > 1) {
const userIds = existingUser.map(user => user.id);
fastify.log.error(
{ audit: true, userIds, email },
'Multiple user records found'
);
fastify.Sentry?.captureException(
new Error('Multiple user records found for: ' + userIds.join(', '))
);
fastify.Sentry?.metrics?.count('user.duplicate_email_detected', 1);
}
if (existingUser[0]) {
return existingUser[0];
}
// Create new user
const newUser = await fastify.prisma.user.create({
data: createUserInput(email),
select: { id: true, acceptedPrivacyTerms: true }
});
fastify.Sentry?.metrics?.count('user.created', 1);
// Create drip campaign record if feature flag is enabled
if (fastify.gb.isOn('drip-campaign')) {
try {
const variant = assignVariantBucket(newUser.id);
await fastify.prisma.dripCampaign.create({
data: {
userId: newUser.id,
email,
variant
}
});
fastify.log.info(
{ userId: newUser.id, variant },
'Drip campaign record created for user'
);
fastify.Sentry?.metrics?.count('growthbook.signup_flag_evaluated', 1, {
attributes: { flag: 'drip-campaign', result: 'success' }
});
} catch (err) {
fastify.Sentry?.captureException(err);
fastify.log.error(
{ err, userId: newUser.id },
'Failed to create drip campaign record for user'
);
fastify.Sentry?.metrics?.count('growthbook.signup_flag_evaluated', 1, {
attributes: { flag: 'drip-campaign', result: 'failed' }
});
}
}
return newUser;
};
@@ -0,0 +1,50 @@
import { describe, test, expect } from 'vitest';
import { getFallbackFullStackDate } from './certificate-utils.js';
const fullStackChallenges = [
{
completedDate: 1585210952511,
id: '5a553ca864b52e1d8bceea14'
},
{
completedDate: 1585210952511,
id: '561add10cb82ac38a17513bc'
},
{
completedDate: 1588665778679,
id: '561acd10cb82ac38a17513bc'
},
{
completedDate: 1685210952511,
id: '561abd10cb81ac38a17513bc'
},
{
completedDate: 1585210952511,
id: '561add10cb82ac38a17523bc'
},
{
completedDate: 1588665778679,
id: '561add10cb82ac38a17213bc'
}
];
describe('helper functions', () => {
describe('getFallbackFullStackDate', () => {
test('should return the date of the latest completed challenge', () => {
expect(getFallbackFullStackDate(fullStackChallenges, 123)).toBe(
1685210952511
);
});
test('should fall back to completedDate if no certifications are provided', () => {
expect(getFallbackFullStackDate([], 123)).toBe(123);
});
test('should fall back to completedDate if none of the certifications have been completed', () => {
expect(
getFallbackFullStackDate([{ completedDate: 567, id: 'abc' }], 123)
).toBe(123);
});
});
});
@@ -0,0 +1,48 @@
import { Prisma } from '@prisma/client';
import {
certSlugTypeMap,
certToIdMap,
Certification
} from '@freecodecamp/shared/config/certification-settings';
import { normalizeDate } from '../../utils/normalize.js';
const fullStackCertificateIds = [
certToIdMap[Certification.RespWebDesign],
certToIdMap[Certification.JsAlgoDataStruct],
certToIdMap[Certification.FrontEndDevLibs],
certToIdMap[Certification.DataVis],
certToIdMap[Certification.BackEndDevApis],
certToIdMap[Certification.LegacyInfoSecQa]
];
/**
* Checks if the given certification slug is known.
*
* @param certSlug - The certification slug to check.
* @returns True if the certification slug is known, otherwise false.
*/
export function isKnownCertSlug(certSlug: string): certSlug is Certification {
return certSlug in certSlugTypeMap;
}
/**
* Retrieves the completion date for the full stack certification, if it exists.
*
* @param completedChallenges - The array of completed challenges.
* @param completedDate - The fallback completed date.
* @returns The latest certification date or the completed date if no certification is found.
*/
export function getFallbackFullStackDate(
completedChallenges: { id: string; completedDate: Prisma.JsonValue }[],
completedDate: Prisma.JsonValue
): number {
const latestCertDate = completedChallenges
.filter(chal => fullStackCertificateIds.includes(chal.id))
.map(chal => ({
...chal,
completedDate: normalizeDate(chal.completedDate)
}))
.sort((a, b) => b.completedDate - a.completedDate)[0]?.completedDate;
return latestCertDate ?? normalizeDate(completedDate);
}
@@ -0,0 +1,251 @@
import { describe, test, expect, afterEach, vi } from 'vitest';
import type {
PartiallyCompletedChallenge,
CompletedChallenge
} from '@prisma/client';
import { createFetchMock } from '../../../vitest.utils.js';
import {
canSubmitCodeRoadCertProject,
verifyTrophyWithMicrosoft,
decodeFiles,
decodeBase64,
encodeBase64
} from './challenge-helpers.js';
const id = 'abc';
const partiallyCompletedChallenges: PartiallyCompletedChallenge[] = [
{
id,
completedDate: 1
}
];
const completedChallenges: CompletedChallenge[] = [
{
id,
completedDate: 1,
challengeType: 1,
files: [],
githubLink: null,
solution: null,
isManuallyApproved: false,
examResults: null
}
];
describe('Challenge Helpers', () => {
describe('canSubmitCodeRoadCertProject', () => {
test('returns true if the user has completed the required challenges or partially completed them', () => {
expect(
canSubmitCodeRoadCertProject(id, {
partiallyCompletedChallenges,
completedChallenges
})
).toBe(true);
expect(
canSubmitCodeRoadCertProject(id, {
partiallyCompletedChallenges: [],
completedChallenges
})
).toBe(true);
expect(
canSubmitCodeRoadCertProject(id, {
partiallyCompletedChallenges,
completedChallenges: []
})
).toBe(true);
});
test('returns false if the user has not completed the required challenges', () => {
expect(
canSubmitCodeRoadCertProject(id, {
partiallyCompletedChallenges: [],
completedChallenges: []
})
).toBe(false);
});
test('returns false if the id is undefined', () => {
expect(
canSubmitCodeRoadCertProject(undefined, {
partiallyCompletedChallenges,
completedChallenges
})
).toBe(false);
});
});
describe('verifyTrophyWithMicrosoft', () => {
const userId = 'abc123';
const msUsername = 'ANRandom';
const msTrophyId = 'learn.wwl.get-started-c-sharp-part-3.trophy';
const verifyData = { msUsername, msTrophyId };
const achievementsUrl = `https://learn.microsoft.com/api/achievements/user/${userId}`;
afterEach(() => vi.clearAllMocks());
test("handles failure to reach Microsoft's profile api", async () => {
const notOk = createFetchMock({ ok: false });
vi.spyOn(globalThis, 'fetch').mockImplementation(notOk);
const verification = await verifyTrophyWithMicrosoft(verifyData);
expect(verification).toEqual({
type: 'error',
message: 'flash.ms.profile.err',
variables: {
msUsername
}
});
});
test("handles failure to reach Microsoft's achievements api", async () => {
const fetchProfile = createFetchMock({ body: { userId } });
const fetchAchievements = createFetchMock({ ok: false });
vi.spyOn(globalThis, 'fetch')
.mockImplementationOnce(fetchProfile)
.mockImplementationOnce(fetchAchievements);
const verification = await verifyTrophyWithMicrosoft(verifyData);
expect(verification).toEqual({
type: 'error',
message: 'flash.ms.trophy.err-3'
});
});
test('handles the case where the user has no achievements', async () => {
const fetchProfile = createFetchMock({ body: { userId } });
const fetchAchievements = createFetchMock({ body: { achievements: [] } });
vi.spyOn(globalThis, 'fetch')
.mockImplementationOnce(fetchProfile)
.mockImplementationOnce(fetchAchievements);
const verification = await verifyTrophyWithMicrosoft(verifyData);
expect(verification).toEqual({
type: 'error',
message: 'flash.ms.trophy.err-6'
});
});
test("handles failure to find the trophy in the user's achievements", async () => {
const fetchProfile = createFetchMock({ body: { userId } });
const fetchAchievements = createFetchMock({
body: { achievements: [{ typeId: 'fake-id' }] }
});
vi.spyOn(globalThis, 'fetch')
.mockImplementationOnce(fetchProfile)
.mockImplementationOnce(fetchAchievements);
const verification = await verifyTrophyWithMicrosoft(verifyData);
expect(verification).toEqual({
type: 'error',
message: 'flash.ms.trophy.err-4',
variables: {
msUsername
}
});
});
test('returns msUserAchievementsApiUrl on success', async () => {
const fetchProfile = createFetchMock({ body: { userId } });
const fetchAchievements = createFetchMock({
body: { achievements: [{ typeId: msTrophyId }] }
});
vi.spyOn(globalThis, 'fetch')
.mockImplementationOnce(fetchProfile)
.mockImplementationOnce(fetchAchievements);
const verification = await verifyTrophyWithMicrosoft(verifyData);
expect(verification).toEqual({
type: 'success',
msUserAchievementsApiUrl: achievementsUrl
});
});
});
describe('decodeFiles', () => {
test('decodes base64 encoded file contents', () => {
const encodedFiles = [
{
contents: btoa('console.log("Hello, world!");')
},
{
contents: btoa('<h1>Hello, world!</h1>')
}
];
const decodedFiles = decodeFiles(encodedFiles);
expect(decodedFiles).toEqual([
{
contents: 'console.log("Hello, world!");'
},
{
contents: '<h1>Hello, world!</h1>'
}
]);
});
test('leaves all other file properties unchanged', () => {
const encodedFiles = [
{
contents: btoa('console.log("Hello, world!");'),
ext: '.js',
history: [],
key: 'file1',
name: 'hello.js'
}
];
const decodedFiles = decodeFiles(encodedFiles);
expect(decodedFiles).toEqual([
{
contents: 'console.log("Hello, world!");',
ext: '.js',
history: [],
key: 'file1',
name: 'hello.js'
}
]);
});
test('can handle unicode characters', () => {
const encodedFiles = [
{
contents: encodeBase64('console.log("Hello, ✅🚀!");')
}
];
const decodedFiles = decodeFiles(encodedFiles);
expect(decodedFiles).toEqual([
{
contents: 'console.log("Hello, ✅🚀!");'
}
]);
});
});
describe('decodeBase64', () => {
test('decodes a base64 encoded string', () => {
const encoded = encodeBase64('Hello, world!');
const decoded = decodeBase64(encoded);
expect(decoded).toBe('Hello, world!');
});
test('can handle unicode characters', () => {
const original = 'Hello, ✅🚀!';
const encoded = encodeBase64(original);
const decoded = decodeBase64(encoded);
expect(decoded).toBe(original);
});
});
});
+175
View File
@@ -0,0 +1,175 @@
/**
* Confirm that a user can submit a CodeRoad project.
*
* @param id The id of the project.
* @param param The challenges the user has completed.
* @param param.partiallyCompletedChallenges The partially completed challenges.
* @param param.completedChallenges The completed challenges.
* @returns A boolean indicating if the user can submit the project.
*/
export const canSubmitCodeRoadCertProject = (
id: string | undefined,
{
partiallyCompletedChallenges,
completedChallenges
}: {
partiallyCompletedChallenges: { id: string }[];
completedChallenges: { id: string }[];
}
) => {
if (partiallyCompletedChallenges.some(c => c.id === id)) return true;
if (completedChallenges.some(c => c.id === id)) return true;
return false;
};
type MSProfileError = {
type: 'error';
message: 'flash.ms.profile.err';
variables: { msUsername: string };
};
type MSProfileSuccess = {
type: 'success';
userId: string;
};
async function getMSProfile(msUsername: string) {
const error: MSProfileError = {
type: 'error',
message: 'flash.ms.profile.err',
variables: {
msUsername
}
};
const msProfileApi = `https://learn.microsoft.com/api/profiles/${msUsername}`;
const msProfileApiRes = await fetch(msProfileApi);
if (!msProfileApiRes.ok) return error;
const { userId } = (await msProfileApiRes.json()) as {
userId: string;
};
const success: MSProfileSuccess = {
type: 'success',
userId
};
return userId ? success : error;
}
type AchievementsError = {
type: 'error';
message: 'flash.ms.trophy.err-3';
};
type NoAchievementsError = {
type: 'error';
message: 'flash.ms.trophy.err-6';
};
type NoTrophyError = {
type: 'error';
message: 'flash.ms.trophy.err-4';
variables: { msUsername: string };
};
type Validated = {
type: 'success';
msUserAchievementsApiUrl: string;
};
/**
* Handles all communication with the Microsoft Learn APIs.
*
* @param requestData The data needed by the Microsoft Learn APIs.
* @param requestData.msUsername The Microsoft username used to get the profile.
* @param requestData.msTrophyId The Microsoft trophy ID to verify.
* @returns An object with 'type' of success|error and information about the success or failure.
*/
export async function verifyTrophyWithMicrosoft({
msUsername,
msTrophyId
}: {
msUsername: string;
msTrophyId: string;
}) {
const msProfile = await getMSProfile(msUsername);
if (msProfile.type === 'error') return msProfile;
const msUserAchievementsApiUrl = `https://learn.microsoft.com/api/achievements/user/${msProfile.userId}`;
const msUserAchievementsApiRes = await fetch(msUserAchievementsApiUrl);
if (!msUserAchievementsApiRes.ok) {
return {
type: 'error',
message: 'flash.ms.trophy.err-3'
} as AchievementsError;
}
const { achievements } = (await msUserAchievementsApiRes.json()) as {
achievements?: { typeId: string }[];
};
if (!achievements?.length)
return {
type: 'error',
message: 'flash.ms.trophy.err-6'
} as NoAchievementsError;
// TODO: handle the case where there are achievements, but the `typeId` is not
// a property of the achievements. This suggests that Microsoft has changed
// their API and, to aid debugging, we should report a different error
// message.
const earnedTrophy = achievements?.some(a => a.typeId === msTrophyId);
if (earnedTrophy) {
return {
type: 'success',
msUserAchievementsApiUrl
} as Validated;
} else {
return {
type: 'error',
message: 'flash.ms.trophy.err-4',
variables: {
msUsername
}
} as NoTrophyError;
}
}
/**
* Generic helper to decode an array of base64 encoded file objects.
*
* @param files Array of file-like objects each having a base64 encoded `contents` string.
* @returns The same array shape with `contents` decoded.
*/
export function decodeFiles<T extends { contents: string }>(files: T[]): T[] {
return files.map(file => ({
...file,
contents: decodeBase64(file.contents)
}));
}
/**
* Decodes a base64 encoded string into a UTF-8 string.
*
* @param str The base64 encoded string to decode.
* @returns The decoded UTF-8 string.
*/
export function decodeBase64(str: string): string {
return Buffer.from(str, 'base64').toString('utf-8');
}
/**
* Encodes a UTF-8 string into a base64 encoded string.
*
* @param str The UTF-8 string to encode.
* @returns The base64 encoded string.
*/
export function encodeBase64(str: string): string {
return Buffer.from(str, 'utf8').toString('base64');
}
+12
View File
@@ -0,0 +1,12 @@
import { isProfane } from 'no-profanity';
import { blocklistedUsernames } from '@freecodecamp/shared/config/constants';
/**
* Checks if a username is restricted (i.e. It's profane or reserved).
* @param username - The username to check.
* @returns True if the username is restricted, false otherwise.
*/
export const isRestricted = (username: string): boolean => {
return isProfane(username) || blocklistedUsernames.includes(username);
};
+61
View File
@@ -0,0 +1,61 @@
import { pick, omit } from 'lodash-es';
// user flags that the api-server returns as false if they're missing in the
// user document. Since Prisma returns null for missing fields, we need to
// normalize them to false.
// TODO(Post-MVP): remove this when the database is normalized.
const nullableFlags = [
'is2018DataVisCert',
'is2018FullStackCert',
'isA2EnglishCert',
'isApisMicroservicesCert',
'isBackEndCert',
'isCheater',
'isCollegeAlgebraPyCertV8',
'isDataAnalysisPyCertV7',
'isDataVisCert',
// isDonating doesn't need fixing because it's not nullable
'isFoundationalCSharpCertV8',
'isFrontEndCert',
'isFullStackCert',
'isFrontEndLibsCert',
'isJavascriptCertV9',
'isClassroomAccount',
'isHonest',
'isInfosecCertV7',
'isInfosecQaCert',
'isJsAlgoDataStructCert',
'isJsAlgoDataStructCertV8',
'isMachineLearningPyCertV7',
'isPythonCertV9',
'isQaCertV7',
'isRelationalDatabaseCertV8',
'isRelationalDatabaseCertV9',
'isRespWebDesignCert',
'isRespWebDesignCertV9',
'isSciCompPyCertV7',
'isFrontEndLibsCertV9',
'isBackEndDevApisCertV9',
'isFullStackDeveloperCertV9',
'isB1EnglishCert',
'isA2SpanishCert',
'isA2ChineseCert',
'isA1ChineseCert',
// isUpcomingPythonCertV8 exists in the db, but is not returned by the api-server
// TODO(Post-MVP): delete it from the db?
'keyboardShortcuts'
] as const;
type NullableFlags = (typeof nullableFlags)[number];
/**
* Splits a user object into two objects: one with nullable flags and one without.
*
* @param user - The user object to split.
* @returns A tuple where the first element is an object with nullable flags and the second element is an object with the remaining properties.
*/
export function splitUser<U extends Record<NullableFlags, unknown>>(
user: U
): [Pick<U, NullableFlags>, Omit<U, NullableFlags>] {
return [pick(user, nullableFlags), omit(user, nullableFlags)];
}