feat(api): daily challenge api (#61346)

Co-authored-by: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com>
This commit is contained in:
Tom
2025-07-17 04:34:46 -05:00
committed by GitHub
parent 3bcd6bbaf1
commit 29cd2d227d
13 changed files with 678 additions and 22 deletions

View File

@@ -21,6 +21,7 @@
"ajv": "8.12.0",
"ajv-formats": "2.1.1",
"date-fns": "4.1.0",
"date-fns-tz": "3.2.0",
"dotenv": "16.4.5",
"fast-uri": "2.3.0",
"fastify": "5.2.0",

View File

@@ -491,6 +491,33 @@ type SurveyResponse {
// ----------------------
model DailyCodingChallenges {
id String @id @default(auto()) @map("_id") @db.ObjectId
challengeNumber Int
date DateTime
title String
description String
javascript DailyCodingChallengeApiLanguage
python DailyCodingChallengeApiLanguage
}
type DailyCodingChallengeApiLanguage {
tests DailyCodingChallengeApiLanguageTests[]
challengeFiles DailyCodingChallengeApiLanguageChallengeFiles[]
}
type DailyCodingChallengeApiLanguageTests {
text String
testString String
}
type DailyCodingChallengeApiLanguageChallengeFiles {
contents String
fileKey String
}
// ----------------------
model ExamEnvironmentExamModeration {
id String @id @default(auto()) @map("_id") @db.ObjectId
/// Whether or not the item is approved

View File

@@ -52,6 +52,7 @@ import {
examEnvironmentOpenRoutes,
examEnvironmentValidatedTokenRoutes
} from './exam-environment/routes/exam-environment';
import { dailyCodingChallengeRoutes } from './daily-coding-challenge/routes/daily-coding-challenge';
type FastifyInstanceWithTypeProvider = FastifyInstance<
RawServerDefault,
@@ -231,6 +232,7 @@ export const build = async (
void fastify.register(publicRoutes.deprecatedEndpoints);
void fastify.register(publicRoutes.statusRoute);
void fastify.register(publicRoutes.unsubscribeDeprecated);
void fastify.register(dailyCodingChallengeRoutes);
return fastify;
};

View File

@@ -0,0 +1 @@
Endpoints to get daily coding challenge info. Daily challenge submission still lives in the main part of the API.

View File

@@ -0,0 +1,280 @@
import { addDays } from 'date-fns';
import { setupServer, superRequest } from '../../../jest.utils';
import { getNowUsCentral, getUtcMidnight } from '../utils/helpers';
function dateToDateParam(date: Date): string {
return date.toISOString().split('T')[0] as string;
}
const todayUsCentral = getNowUsCentral();
const todayUtcMidnight = getUtcMidnight(todayUsCentral);
const todayDateParam = dateToDateParam(todayUtcMidnight);
const yesterdayUsCentral = addDays(todayUsCentral, -1);
const yesterdayUtcMidnight = getUtcMidnight(yesterdayUsCentral);
const twoDaysAgoUsCentral = addDays(todayUsCentral, -2);
const twoDaysAgoUtcMidnight = getUtcMidnight(twoDaysAgoUsCentral);
const twoDaysAgoDateParam = dateToDateParam(twoDaysAgoUtcMidnight);
const tomorrowUsCentral = addDays(todayUsCentral, 1);
const tomorrowUtcMidnight = getUtcMidnight(tomorrowUsCentral);
const tomorrowDateParam = dateToDateParam(tomorrowUtcMidnight);
const yesterdaysChallenge = {
id: '111111111111111111111111',
challengeNumber: 1,
date: yesterdayUtcMidnight,
title: "Yesterday's Challenge",
description: "Yesterday's Description",
javascript: {
tests: [{ text: 'JS Test Yesterday', testString: 'jsTestYesterday()' }],
challengeFiles: [{ contents: 'JS Files Yesterday', fileKey: 'scriptjs' }]
},
python: {
tests: [{ text: 'Py Test Yesterday', testString: 'py_test_yesterday()' }],
challengeFiles: [{ contents: 'Py Files Yesterday', fileKey: 'mainpy' }]
}
};
const todaysChallenge = {
id: '222222222222222222222222',
challengeNumber: 2,
date: todayUtcMidnight,
title: "Today's Challenge",
description: "Today's Description",
javascript: {
tests: [{ text: 'JS Test Today', testString: 'jsTestToday()' }],
challengeFiles: [{ contents: 'JS Files Today', fileKey: 'scriptjs' }]
},
python: {
tests: [{ text: 'Py Test Today', testString: 'py_test_today()' }],
challengeFiles: [{ contents: 'Py Files Today', fileKey: 'mainpy' }]
}
};
const tomorrowsChallenge = {
id: '333333333333333333333333',
challengeNumber: 3,
date: tomorrowUtcMidnight,
title: "Tomorrow's Challenge",
description: "Tomorrow's Description",
javascript: {
tests: [{ text: 'JS Test Tomorrow', testString: 'jsTestTomorrow()' }],
challengeFiles: [{ contents: 'JS Files Tomorrow', fileKey: 'scriptjs' }]
},
python: {
tests: [{ text: 'Py Test Tomorrow', testString: 'py_test_tomorrow()' }],
challengeFiles: [{ contents: 'Py Files Tomorrow', fileKey: 'mainpy' }]
}
};
const mockChallenges = [
tomorrowsChallenge,
todaysChallenge,
yesterdaysChallenge
];
describe('/daily-coding-challenge', () => {
setupServer();
describe('GET /daily-coding-challenge/date/:date', () => {
beforeEach(async () => {
await fastifyTestInstance.prisma.dailyCodingChallenges.createMany({
data: mockChallenges
});
});
afterEach(async () => {
await fastifyTestInstance.prisma.dailyCodingChallenges.deleteMany();
});
it('should return 400 for an invalid date format', async () => {
const res = await superRequest(
'/daily-coding-challenge/date/invalid-format',
{
method: 'GET'
}
).send({});
expect(res.status).toBe(400);
expect(res.body).toEqual({
type: 'error',
message: 'Invalid date format. Please use YYYY-MM-DD.'
});
});
it('should return 404 for a date without a challenge', async () => {
const res = await superRequest(
`/daily-coding-challenge/date/${twoDaysAgoDateParam}`,
{
method: 'GET'
}
).send({});
expect(res.status).toBe(404);
expect(res.body).toEqual({
type: 'error',
message: 'Challenge not found.'
});
});
it('should return a challenge for a valid date', async () => {
const res = await superRequest(
`/daily-coding-challenge/date/${todayDateParam}`,
{
method: 'GET'
}
).send({});
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
...todaysChallenge,
date: todaysChallenge.date.toISOString()
});
});
it('should not return a challenge for a future date relative to US Central', async () => {
const res = await superRequest(
`/daily-coding-challenge/date/${tomorrowDateParam}`,
{
method: 'GET'
}
).send({});
expect(res.body).toEqual({
type: 'error',
message: 'Challenge not found.'
});
});
});
describe('GET /daily-coding-challenge/today', () => {
beforeEach(async () => {
await fastifyTestInstance.prisma.dailyCodingChallenges.createMany({
data: mockChallenges
});
});
afterEach(async () => {
await fastifyTestInstance.prisma.dailyCodingChallenges.deleteMany();
});
it("should return today's challenge", async () => {
const res = await superRequest('/daily-coding-challenge/today', {
method: 'GET'
}).send({});
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
...todaysChallenge,
date: todaysChallenge.date.toISOString()
});
});
it('should return 404 when no challenge exists for today', async () => {
await fastifyTestInstance.prisma.dailyCodingChallenges.deleteMany();
const res = await superRequest('/daily-coding-challenge/today', {
method: 'GET'
}).send({});
expect(res.status).toBe(404);
expect(res.body).toEqual({
type: 'error',
message: 'Challenge not found.'
});
});
});
describe('GET /daily-coding-challenge/all', () => {
beforeEach(async () => {
await fastifyTestInstance.prisma.dailyCodingChallenges.createMany({
data: mockChallenges
});
});
afterEach(async () => {
await fastifyTestInstance.prisma.dailyCodingChallenges.deleteMany();
});
it('should return { _id, date, challengeNumber, title } for all challenges up to today US Central', async () => {
const res = await superRequest('/daily-coding-challenge/all', {
method: 'GET'
}).send({});
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
// Should include yesterday's and today's challenges, but not tomorrow's
const expectedResponse = [
{
id: todaysChallenge.id,
challengeNumber: todaysChallenge.challengeNumber,
date: todaysChallenge.date.toISOString(),
title: todaysChallenge.title
},
{
id: yesterdaysChallenge.id,
challengeNumber: yesterdaysChallenge.challengeNumber,
date: yesterdaysChallenge.date.toISOString(),
title: yesterdaysChallenge.title
}
];
expect(res.body).toHaveLength(2);
expect(res.body).toEqual(expectedResponse);
});
it('should return 404 when no challenges exist', async () => {
await fastifyTestInstance.prisma.dailyCodingChallenges.deleteMany();
const res = await superRequest('/daily-coding-challenge/all', {
method: 'GET'
}).send({});
expect(res.status).toBe(404);
expect(res.body).toEqual({
type: 'error',
message: 'No challenges found.'
});
});
});
describe('GET /daily-coding-challenge/newest', () => {
beforeEach(async () => {
await fastifyTestInstance.prisma.dailyCodingChallenges.createMany({
data: [yesterdaysChallenge, todaysChallenge, tomorrowsChallenge]
});
});
afterEach(async () => {
await fastifyTestInstance.prisma.dailyCodingChallenges.deleteMany();
});
it('should return { date } of the newest challenge in the database', async () => {
const res = await superRequest('/daily-coding-challenge/newest', {
method: 'GET'
}).send({});
expect(res.status).toBe(200);
expect(res.body).toEqual({
date: tomorrowsChallenge.date.toISOString()
});
});
it('should return 404 when no challenges exist', async () => {
await fastifyTestInstance.prisma.dailyCodingChallenges.deleteMany();
const res = await superRequest('/daily-coding-challenge/newest', {
method: 'GET'
}).send({});
expect(res.status).toBe(404);
expect(res.body).toEqual({
type: 'error',
message: 'No challenges found.'
});
});
});
});

View File

@@ -0,0 +1,205 @@
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import * as schemas from '../schemas';
import {
getNowUsCentral,
getUtcMidnight,
dateStringToUtcMidnight
} from '../utils/helpers';
/**
* Plugin containing public GET routes for the daily coding challenges.
* Note that they are only for getting challenge info, challenges are still
* submitted via the main challenge completion routes.
*
* @param fastify The Fastify instance.
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
* @param done Callback to signal that the logic has completed.
*/
export const dailyCodingChallengeRoutes: FastifyPluginCallbackTypebox = (
fastify,
_options,
done
) => {
fastify.get(
'/daily-coding-challenge/date/:date',
{
schema: schemas.dailyCodingChallenge.date
},
async (req, reply) => {
const logger = fastify.log.child({ req, res: reply });
logger.info('Received request for daily coding challenge', {
date: req.params.date
});
const { date } = req.params;
try {
const parsedDate = dateStringToUtcMidnight(date);
if (!parsedDate) {
logger.warn('Invalid date format requested', { date });
return reply.status(400).send({
type: 'error',
message: 'Invalid date format. Please use YYYY-MM-DD.'
});
}
const challenge = await fastify.prisma.dailyCodingChallenges.findFirst({
where: {
date: parsedDate
}
});
// don't return challenges > today US Central
if (!challenge || challenge.date > getUtcMidnight(getNowUsCentral())) {
logger.warn('Challenge not found for date', { date: parsedDate });
return reply
.status(404)
.send({ type: 'error', message: 'Challenge not found.' });
}
return reply.send({
...challenge,
date: challenge.date.toISOString()
});
} catch (error) {
logger.error(error, 'Failed to get daily coding challenge.');
await reply
.status(500)
.send({ type: 'error', message: 'Internal server error.' });
}
}
);
fastify.get(
'/daily-coding-challenge/today',
{
schema: schemas.dailyCodingChallenge.today
},
async (req, reply) => {
const logger = fastify.log.child({ req, res: reply });
logger.info("Received request for today's daily coding challenge");
const today = getUtcMidnight(getNowUsCentral());
try {
const todaysChallenge =
await fastify.prisma.dailyCodingChallenges.findFirst({
where: {
date: today
}
});
if (!todaysChallenge) {
logger.warn('Challenge not found for today', { date: today });
return reply
.status(404)
.send({ type: 'error', message: 'Challenge not found.' });
}
return reply.send({
...todaysChallenge,
date: todaysChallenge.date.toISOString()
});
} catch (error) {
logger.error(error, "Failed to get today's daily coding challenge.");
await reply
.status(500)
.send({ type: 'error', message: 'Internal server error.' });
}
}
);
fastify.get(
'/daily-coding-challenge/all',
{
schema: schemas.dailyCodingChallenge.all
},
async (req, reply) => {
const logger = fastify.log.child({ req, res: reply });
logger.info('Received request for all daily coding challenges');
const today = getUtcMidnight(getNowUsCentral());
try {
const allChallenges =
await fastify.prisma.dailyCodingChallenges.findMany({
// only where date <= today US Central
where: {
date: {
lte: today
}
},
orderBy: {
date: 'desc'
},
select: {
id: true,
challengeNumber: true,
date: true,
title: true
}
});
if (!allChallenges || allChallenges.length === 0) {
logger.warn('No challenges found.', { date: today });
return reply
.status(404)
.send({ type: 'error', message: 'No challenges found.' });
}
const response = allChallenges.map(challenge => ({
...challenge,
date: challenge.date.toISOString()
}));
return reply.send(response);
} catch (error) {
logger.error(error, 'Failed to get all daily coding challenges.');
await reply
.status(500)
.send({ type: 'error', message: 'Internal server error.' });
}
}
);
fastify.get(
'/daily-coding-challenge/newest',
{
schema: schemas.dailyCodingChallenge.newest
},
async (req, reply) => {
const logger = fastify.log.child({ req, res: reply });
logger.info('Received request for newest daily coding challenge');
try {
const newestChallenge =
await fastify.prisma.dailyCodingChallenges.findFirst({
orderBy: {
date: 'desc'
},
select: {
date: true
}
});
if (!newestChallenge) {
logger.warn('No challenges found.');
return reply
.status(404)
.send({ type: 'error', message: 'No challenges found.' });
}
return reply.send({ date: newestChallenge.date.toISOString() });
} catch (error) {
logger.error(error, 'Failed to get newest daily coding challenge.');
await reply
.status(500)
.send({ type: 'error', message: 'Internal server error.' });
}
}
);
done();
};

View File

@@ -0,0 +1,105 @@
import { Type } from '@fastify/type-provider-typebox';
const challengeLanguage = Type.Object({
tests: Type.Array(
Type.Object({
text: Type.String(),
testString: Type.String()
})
),
challengeFiles: Type.Array(
Type.Object({
contents: Type.String(),
fileKey: Type.String()
})
)
});
const singleChallenge = Type.Object({
id: Type.String({ format: 'objectid', maxLength: 24, minLength: 24 }),
date: Type.String({ format: 'date-time' }),
challengeNumber: Type.Number(),
title: Type.String(),
description: Type.String(),
javascript: challengeLanguage,
python: challengeLanguage
});
const date = {
params: Type.Object({
date: Type.String({ format: 'date' })
}),
response: {
200: singleChallenge,
400: Type.Object({
type: Type.Literal('error'),
message: Type.Literal('Invalid date format. Please use YYYY-MM-DD.')
}),
404: Type.Object({
type: Type.Literal('error'),
message: Type.Literal('Challenge not found.')
}),
500: Type.Object({
type: Type.Literal('error'),
message: Type.Literal('Internal server error.')
})
}
};
const today = {
response: {
200: singleChallenge,
404: Type.Object({
type: Type.Literal('error'),
message: Type.Literal('Challenge not found.')
}),
500: Type.Object({
type: Type.Literal('error'),
message: Type.Literal('Internal server error.')
})
}
};
const all = {
response: {
200: Type.Array(
Type.Object({
id: Type.String(),
date: Type.String({ format: 'date-time' }),
challengeNumber: Type.Number(),
title: Type.String()
})
),
404: Type.Object({
type: Type.Literal('error'),
message: Type.Literal('No challenges found.')
}),
500: Type.Object({
type: Type.Literal('error'),
message: Type.Literal('Internal server error.')
})
}
};
const newest = {
response: {
200: Type.Object({
date: Type.String({ format: 'date-time' })
}),
404: Type.Object({
type: Type.Literal('error'),
message: Type.Literal('No challenges found.')
}),
500: Type.Object({
type: Type.Literal('error'),
message: Type.Literal('Internal server error.')
})
}
};
export const dailyCodingChallenge = {
date,
today,
newest,
all
};

View File

@@ -0,0 +1 @@
export { dailyCodingChallenge } from './daily-coding-challenge';

View File

@@ -0,0 +1,39 @@
import { toZonedTime } from 'date-fns-tz';
/**
* @returns Now US Central time.
*/
export function getNowUsCentral() {
return toZonedTime(new Date(), 'America/Chicago');
}
/**
* Returns a Date object set to UTC midnight of the given date.
* @param date - Date Object.
* @returns UTC midnight of the given date.
*/
export function getUtcMidnight(date: Date): Date {
return new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
);
}
/**
* Parses a date string in the format "YYYY-MM-DD" and returns a Date object set to UTC midnight.
* Returns null if the input is not in the correct format.
* @param dateStr - Date string in "YYYY-MM-DD" format.
* @returns Date object set to UTC midnight or null if invalid.
*/
export function dateStringToUtcMidnight(dateStr: string): Date | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
return null;
}
const [year, month, day] = dateStr.split('-').map(Number) as [
number,
number,
number
];
return new Date(Date.UTC(year, month - 1, day));
}