From 2906599befba604e893e6b8717fb8d955737c083 Mon Sep 17 00:00:00 2001 From: Ahmad Abdolsaheb Date: Tue, 7 Apr 2026 16:33:20 +0300 Subject: [PATCH] feat: add socrates (#65430) Co-authored-by: Mrugesh Mohapatra --- api/prisma/schema.prisma | 12 + api/src/app.ts | 1 + api/src/plugins/__fixtures__/user.ts | 1 + api/src/routes/protected/index.ts | 1 + api/src/routes/protected/settings.ts | 28 + api/src/routes/protected/socrates.test.ts | 538 ++++++++++++++++++ api/src/routes/protected/socrates.ts | 217 +++++++ api/src/routes/protected/user.test.ts | 2 + api/src/routes/protected/user.ts | 5 +- api/src/schemas.ts | 2 + api/src/schemas/settings/update-socrates.ts | 21 + api/src/schemas/socrates/ask-socrates.ts | 49 ++ api/src/schemas/user/get-session-user.ts | 1 + api/src/utils/env.ts | 4 + api/turbo.json | 4 + api/vitest.utils.ts | 9 + .../config/growthbook-features-default.json | 43 +- client/i18n/locales/english/translations.json | 18 + client/src/assets/icons/stars.tsx | 31 + .../client-only-routes/show-settings.test.tsx | 3 + .../src/client-only-routes/show-settings.tsx | 4 +- client/src/components/settings/account.tsx | 8 +- .../src/components/settings/misc-settings.tsx | 58 ++ client/src/components/settings/socrates.tsx | 46 ++ client/src/redux/index.js | 2 + client/src/redux/prop-types.ts | 2 + client/src/redux/selectors.js | 1 + client/src/redux/settings/action-types.js | 1 + client/src/redux/settings/actions.js | 7 + client/src/redux/settings/settings-sagas.js | 14 + .../src/templates/Challenges/classic/show.tsx | 1 + .../templates/Challenges/codeally/show.tsx | 2 +- .../components/independent-lower-jaw.css | 52 +- .../components/independent-lower-jaw.test.tsx | 76 ++- .../components/independent-lower-jaw.tsx | 109 +++- client/src/templates/Challenges/exam/show.tsx | 3 +- .../Challenges/fill-in-the-blank/show.tsx | 1 + .../src/templates/Challenges/generic/show.tsx | 1 + .../templates/Challenges/ms-trophy/show.tsx | 3 +- .../Challenges/projects/backend/show.tsx | 3 +- .../Challenges/projects/frontend/show.tsx | 3 +- client/src/templates/Challenges/quiz/show.tsx | 1 + .../Challenges/redux/action-types.js | 3 +- .../src/templates/Challenges/redux/actions.js | 7 + .../Challenges/redux/ask-socrates-saga.js | 129 +++++ .../redux/ask-socrates-saga.test.js | 262 +++++++++ .../src/templates/Challenges/redux/index.js | 46 +- .../templates/Challenges/redux/selectors.js | 1 + .../Challenges/redux/socrates-reducer.test.js | 103 ++++ client/src/utils/ajax.ts | 28 +- sample.env | 4 + 51 files changed, 1915 insertions(+), 56 deletions(-) create mode 100644 api/src/routes/protected/socrates.test.ts create mode 100644 api/src/routes/protected/socrates.ts create mode 100644 api/src/schemas/settings/update-socrates.ts create mode 100644 api/src/schemas/socrates/ask-socrates.ts create mode 100644 client/src/assets/icons/stars.tsx create mode 100644 client/src/components/settings/misc-settings.tsx create mode 100644 client/src/components/settings/socrates.tsx create mode 100644 client/src/templates/Challenges/redux/ask-socrates-saga.js create mode 100644 client/src/templates/Challenges/redux/ask-socrates-saga.test.js create mode 100644 client/src/templates/Challenges/redux/socrates-reducer.test.js diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index a97e8c174f5..28082d5e1aa 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -173,6 +173,7 @@ model user { savedChallenges SavedChallenge[] // Undefined | SavedChallenge[] // Nullable tri-state: null (likely new user), true (subscribed), false (unsubscribed) sendQuincyEmail Boolean? + socrates Boolean? theme String? // Undefined timezone String? // Undefined twitter String? // Null | Undefined @@ -228,6 +229,17 @@ model Donation { @@index([userId], map: "userId_1") } +model SocratesUsage { + id String @id @default(auto()) @map("_id") @db.ObjectId + userId String @db.ObjectId + /// UTC date representing the day of usage (midnight). + date DateTime @db.Date + /// Number of hints used on this day. + count Int @default(0) + + @@unique([userId, date]) +} + model UserToken { id String @id @map("_id") created DateTime @db.Date diff --git a/api/src/app.ts b/api/src/app.ts index 44846c9dea6..cce3fbdd80e 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -191,6 +191,7 @@ export const build = async ( await fastify.register(protectedRoutes.challengeRoutes); await fastify.register(protectedRoutes.donateRoutes); + await fastify.register(protectedRoutes.socratesRoutes); await fastify.register(protectedRoutes.protectedCertificateRoutes); await fastify.register(protectedRoutes.settingRoutes); await fastify.register(protectedRoutes.userRoutes); diff --git a/api/src/plugins/__fixtures__/user.ts b/api/src/plugins/__fixtures__/user.ts index c8eb2ace7f3..5883ad3375a 100644 --- a/api/src/plugins/__fixtures__/user.ts +++ b/api/src/plugins/__fixtures__/user.ts @@ -93,6 +93,7 @@ export const newUser = (email: string) => ({ rand: null, // TODO(Post-MVP): delete from schema (it's not used or required). savedChallenges: [], sendQuincyEmail: null, + socrates: null, theme: 'default', timezone: null, twitter: null, diff --git a/api/src/routes/protected/index.ts b/api/src/routes/protected/index.ts index 01c1fb9208e..17bcaa705fd 100644 --- a/api/src/routes/protected/index.ts +++ b/api/src/routes/protected/index.ts @@ -3,3 +3,4 @@ export * from './challenge.js'; export * from './donate.js'; export * from './settings.js'; export * from './user.js'; +export * from './socrates.js'; diff --git a/api/src/routes/protected/settings.ts b/api/src/routes/protected/settings.ts index 530990db1b0..c2b9acdebbc 100644 --- a/api/src/routes/protected/settings.ts +++ b/api/src/routes/protected/settings.ts @@ -616,6 +616,34 @@ ${isLinkSentWithinLimitTTL}` } ); + fastify.put( + '/update-socrates', + { + schema: schemas.updateSocrates + }, + async (req, reply) => { + const logger = fastify.log.child({ req, res: reply }); + try { + await fastify.prisma.user.update({ + where: { id: req.user?.id }, + data: { + socrates: req.body.socrates + } + }); + + return { + message: 'flash.socrates-updated', + type: 'success' + } as const; + } catch (err) { + logger.error(err); + fastify.Sentry.captureException(err); + void reply.code(500); + return { message: 'flash.wrong-updating', type: 'danger' } as const; + } + } + ); + fastify.put( '/update-my-honesty', { diff --git a/api/src/routes/protected/socrates.test.ts b/api/src/routes/protected/socrates.test.ts new file mode 100644 index 00000000000..e34a1edad33 --- /dev/null +++ b/api/src/routes/protected/socrates.test.ts @@ -0,0 +1,538 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { + describe, + test, + expect, + beforeAll, + beforeEach, + afterEach, + vi +} from 'vitest'; +import { + devLogin, + setupServer, + createSuperRequest, + defaultUserId +} from '../../../vitest.utils.js'; + +const mockedFetch = vi.fn(); +vi.spyOn(globalThis, 'fetch').mockImplementation(mockedFetch); + +const validPayload = { + description: 'Make the text say hello', + userInput: 'Hello world', + seed: '

Hello

', + hints: [{ text: 'Check your spelling', failed: true }] +}; + +describe('socratesRoutes', () => { + setupServer(); + + describe('Authenticated user', () => { + let superPut: ReturnType; + + beforeAll(async () => { + const setCookies = await devLogin(); + superPut = createSuperRequest({ method: 'PUT', setCookies }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('PUT /socrates/get-hint', () => { + test('should return 403 when user has socrates explicitly disabled', async () => { + await fastifyTestInstance.prisma.user.update({ + where: { id: defaultUserId }, + data: { socrates: false } + }); + + const response = + await superPut('/socrates/get-hint').send(validPayload); + + expect(response.status).toBe(403); + expect(response.body).toStrictEqual({ + error: 'socrates-no-access', + type: 'danger', + attempts: 0, + limit: 0 + }); + expect(mockedFetch).not.toHaveBeenCalled(); + }); + + test('should allow access when socrates is null (default)', async () => { + await fastifyTestInstance.prisma.user.update({ + where: { id: defaultUserId }, + data: { socrates: null } + }); + + mockedFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + text: () => + Promise.resolve( + JSON.stringify({ hint: 'Try adding a closing tag.' }) + ) + }); + + const response = + await superPut('/socrates/get-hint').send(validPayload); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('hint'); + }); + + describe('with socrates enabled', () => { + beforeEach(async () => { + await fastifyTestInstance.prisma.user.update({ + where: { id: defaultUserId }, + data: { socrates: true } + }); + await fastifyTestInstance.prisma.socratesUsage.deleteMany({ + where: { userId: defaultUserId } + }); + }); + + test('should return hint on successful Socrates API response', async () => { + mockedFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + text: () => + Promise.resolve( + JSON.stringify({ hint: 'Try adding a closing tag.' }) + ) + }); + + const response = + await superPut('/socrates/get-hint').send(validPayload); + + expect(response.status).toBe(200); + expect(response.body).toStrictEqual({ + hint: 'Try adding a closing tag.', + attempts: 1, + limit: 3 + }); + }); + + test('should pass session userId, not client-supplied userId', async () => { + mockedFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify({ hint: 'A hint.' })) + }); + + await superPut('/socrates/get-hint').send({ + ...validPayload + }); + + const fetchCall = mockedFetch.mock.calls[0]!; + const body = JSON.parse(fetchCall[1].body as string) as { + userId: string; + }; + expect(body.userId).toBe(defaultUserId); + }); + + test('should use session userId even when userId is sent in body', async () => { + mockedFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify({ hint: 'A hint.' })) + }); + + await superPut('/socrates/get-hint').send({ + ...validPayload, + userId: 'attacker-id' + }); + + const fetchCall = mockedFetch.mock.calls[0]!; + const body = JSON.parse(fetchCall[1].body as string) as { + userId: string; + }; + expect(body.userId).toBe(defaultUserId); + expect(body.userId).not.toBe('attacker-id'); + }); + + test('should return 429 when Socrates API rate limits', async () => { + mockedFetch.mockResolvedValueOnce({ + ok: false, + status: 429, + text: () => Promise.resolve('') + }); + + const response = + await superPut('/socrates/get-hint').send(validPayload); + + expect(response.status).toBe(429); + expect(response.body).toStrictEqual({ + error: 'socrates-rate-limit', + type: 'info', + attempts: 0, + limit: 3 + }); + }); + + test('should forward upstream error message on 400', async () => { + mockedFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + text: () => + Promise.resolve( + JSON.stringify({ error: 'Input too short for analysis.' }) + ) + }); + + const response = + await superPut('/socrates/get-hint').send(validPayload); + + expect(response.status).toBe(400); + expect(response.body).toStrictEqual({ + error: 'Input too short for analysis.', + type: 'info', + attempts: 0, + limit: 3 + }); + }); + + test('should use fallback message on 400 with no upstream error', async () => { + mockedFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + text: () => Promise.resolve('') + }); + + const response = + await superPut('/socrates/get-hint').send(validPayload); + + expect(response.status).toBe(400); + expect(response.body).toStrictEqual({ + error: 'socrates-unable-to-generate', + type: 'info', + attempts: 0, + limit: 3 + }); + }); + + test('should return 500 on other Socrates API errors', async () => { + mockedFetch.mockResolvedValueOnce({ + ok: false, + status: 503, + text: () => Promise.resolve('Service Unavailable') + }); + + const response = + await superPut('/socrates/get-hint').send(validPayload); + + expect(response.status).toBe(500); + expect(response.body).toStrictEqual({ + error: 'socrates-unavailable', + type: 'danger', + attempts: 0, + limit: 3 + }); + }); + + test('should return 500 when Socrates API returns invalid JSON', async () => { + mockedFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + text: () => Promise.resolve('not json') + }); + + const response = + await superPut('/socrates/get-hint').send(validPayload); + + expect(response.status).toBe(500); + expect(response.body.type).toBe('danger'); + expect(response.body.attempts).toBe(0); + expect(response.body.limit).toBe(3); + }); + + test('should return 500 when Socrates API returns no hint', async () => { + mockedFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify({ foo: 'bar' })) + }); + + const response = + await superPut('/socrates/get-hint').send(validPayload); + + expect(response.status).toBe(500); + expect(response.body.type).toBe('danger'); + expect(response.body.attempts).toBe(0); + expect(response.body.limit).toBe(3); + }); + + test('should return 500 when fetch throws', async () => { + mockedFetch.mockRejectedValueOnce(new Error('Network error')); + + const response = + await superPut('/socrates/get-hint').send(validPayload); + + expect(response.status).toBe(500); + expect(response.body).toStrictEqual({ + error: 'socrates-unavailable', + type: 'danger', + attempts: 0, + limit: 3 + }); + }); + }); + + describe('daily usage entitlements', () => { + beforeEach(async () => { + await fastifyTestInstance.prisma.user.update({ + where: { id: defaultUserId }, + data: { socrates: true, isDonating: false } + }); + await fastifyTestInstance.prisma.socratesUsage.deleteMany({ + where: { userId: defaultUserId } + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test('should return attempts=1 and limit=3 on first hint for non-donor', async () => { + mockedFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify({ hint: 'A hint.' })) + }); + + const response = + await superPut('/socrates/get-hint').send(validPayload); + + expect(response.status).toBe(200); + expect(response.body.attempts).toBe(1); + expect(response.body.limit).toBe(3); + }); + + test('should increment attempts on each request', async () => { + mockedFetch.mockResolvedValue({ + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify({ hint: 'A hint.' })) + }); + + await superPut('/socrates/get-hint').send(validPayload); + const response = + await superPut('/socrates/get-hint').send(validPayload); + + expect(response.status).toBe(200); + expect(response.body.attempts).toBe(2); + }); + + test('should return 429 when non-donor exceeds 3 hints/day', async () => { + mockedFetch.mockResolvedValue({ + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify({ hint: 'A hint.' })) + }); + + for (let i = 0; i < 3; i++) { + await superPut('/socrates/get-hint').send(validPayload); + } + + const response = + await superPut('/socrates/get-hint').send(validPayload); + + expect(response.status).toBe(429); + expect(response.body.attempts).toBe(3); + expect(response.body.limit).toBe(3); + expect(response.body.error).toBe('socrates-daily-limit'); + expect(mockedFetch).toHaveBeenCalledTimes(3); + }); + + test('should not inflate count beyond limit on repeated 429s', async () => { + mockedFetch.mockResolvedValue({ + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify({ hint: 'A hint.' })) + }); + + // Exhaust the non-donor limit + for (let i = 0; i < 3; i++) { + await superPut('/socrates/get-hint').send(validPayload); + } + + // Make extra requests that should all be 429 + for (let i = 0; i < 5; i++) { + const res = await superPut('/socrates/get-hint').send(validPayload); + expect(res.status).toBe(429); + } + + // Upgrade to donor (limit: 10) and verify access is restored + await fastifyTestInstance.prisma.user.update({ + where: { id: defaultUserId }, + data: { isDonating: true } + }); + + const response = + await superPut('/socrates/get-hint').send(validPayload); + + expect(response.status).toBe(200); + expect(response.body.attempts).toBe(4); + expect(response.body.limit).toBe(10); + }); + + test('should allow 10 hints/day for donors', async () => { + await fastifyTestInstance.prisma.user.update({ + where: { id: defaultUserId }, + data: { isDonating: true } + }); + + mockedFetch.mockResolvedValue({ + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify({ hint: 'A hint.' })) + }); + + let response = + await superPut('/socrates/get-hint').send(validPayload); + expect(response.status).toBe(200); + + for (let i = 1; i < 10; i++) { + response = await superPut('/socrates/get-hint').send(validPayload); + expect(response.status).toBe(200); + } + + expect(response.body.attempts).toBe(10); + expect(response.body.limit).toBe(10); + + response = await superPut('/socrates/get-hint').send(validPayload); + expect(response.status).toBe(429); + }); + + test('should not consume an attempt on upstream API error', async () => { + mockedFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: () => Promise.resolve('Server Error') + }); + + const response = + await superPut('/socrates/get-hint').send(validPayload); + + expect(response.status).toBe(500); + expect(response.body.attempts).toBe(0); + expect(response.body.limit).toBe(3); + }); + + test('should not count yesterday usage against today limit', async () => { + const yesterday = new Date(); + yesterday.setUTCDate(yesterday.getUTCDate() - 1); + const yesterdayUTC = new Date( + Date.UTC( + yesterday.getUTCFullYear(), + yesterday.getUTCMonth(), + yesterday.getUTCDate() + ) + ); + + await fastifyTestInstance.prisma.socratesUsage.create({ + data: { userId: defaultUserId, date: yesterdayUTC, count: 3 } + }); + + mockedFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify({ hint: 'A hint.' })) + }); + + const response = + await superPut('/socrates/get-hint').send(validPayload); + + expect(response.status).toBe(200); + expect(response.body.attempts).toBe(1); + }); + }); + + describe('validation', () => { + beforeEach(async () => { + await fastifyTestInstance.prisma.user.update({ + where: { id: defaultUserId }, + data: { socrates: true } + }); + }); + + test('should return 400 when userInput is empty string', async () => { + const response = await superPut('/socrates/get-hint').send({ + ...validPayload, + userInput: '' + }); + + expect(response.status).toBe(400); + expect(response.body).toStrictEqual({ + error: 'socrates-invalid-request', + type: 'info', + attempts: 0, + limit: 0 + }); + expect(mockedFetch).not.toHaveBeenCalled(); + }); + + test('should accept request without userInput', async () => { + mockedFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + text: () => + Promise.resolve( + JSON.stringify({ hint: 'Try adding a closing tag.' }) + ) + }); + + const { userInput: _unused, ...payloadWithoutUserInput } = + validPayload; + const response = await superPut('/socrates/get-hint').send( + payloadWithoutUserInput + ); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('hint'); + expect(mockedFetch).toHaveBeenCalledTimes(1); + }); + + test('should return 400 when seed is empty', async () => { + const response = await superPut('/socrates/get-hint').send({ + ...validPayload, + seed: '' + }); + + expect(response.status).toBe(400); + expect(mockedFetch).not.toHaveBeenCalled(); + }); + + test('should return 400 when description is empty', async () => { + const response = await superPut('/socrates/get-hint').send({ + ...validPayload, + description: '' + }); + + expect(response.status).toBe(400); + expect(mockedFetch).not.toHaveBeenCalled(); + }); + + test('should return 400 when required fields are missing', async () => { + const response = await superPut('/socrates/get-hint').send({}); + + expect(response.status).toBe(400); + expect(mockedFetch).not.toHaveBeenCalled(); + }); + }); + }); + }); + + describe('Unauthenticated user', () => { + test('should not return a hint for unauthenticated requests', async () => { + const response = await createSuperRequest({ method: 'PUT' })( + '/socrates/get-hint' + ).send(validPayload); + + // Unauthenticated requests fail before reaching the route handler + expect(response.status).not.toBe(200); + expect(response.body).not.toHaveProperty('hint'); + }); + }); +}); diff --git a/api/src/routes/protected/socrates.ts b/api/src/routes/protected/socrates.ts new file mode 100644 index 00000000000..230bfd679ab --- /dev/null +++ b/api/src/routes/protected/socrates.ts @@ -0,0 +1,217 @@ +import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox'; + +import * as schemas from '../../schemas.js'; +import { SOCRATES_API_KEY, SOCRATES_ENDPOINT } from '../../utils/env.js'; + +const DAILY_LIMITS = { donor: 10, nonDonor: 3 } as const; + +function getDailyLimit(isDonating: boolean): number { + return isDonating ? DAILY_LIMITS.donor : DAILY_LIMITS.nonDonor; +} + +/** + * + * @param fastify The Fastify instance. + * @param _options Options passed to the plugin via `fastify.register(plugin, options)`. + * @param done The callback to signal that the plugin is ready. + */ +export const socratesRoutes: FastifyPluginCallbackTypebox = ( + fastify, + _options, + done +) => { + // Socrates plugin + fastify.put( + '/socrates/get-hint', + { + schema: schemas.askSocrates, + errorHandler(error, req, reply) { + if (error.validation) { + void reply.status(400).send({ + error: 'socrates-invalid-request', + type: 'info', + attempts: 0, + limit: 0 + }); + } else { + fastify.errorHandler(error, req, reply); + } + } + }, + async (req, reply) => { + if (!req.user || req.user.socrates === false) { + return reply.status(403).send({ + error: 'socrates-no-access', + type: 'danger', + attempts: 0, + limit: 0 + }); + } + + const limit = getDailyLimit(req.user.isDonating); + const now = new Date(); + const todayUTC = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) + ); + + const existing = await fastify.prisma.socratesUsage.findUnique({ + where: { + userId_date: { userId: req.user.id, date: todayUTC } + } + }); + + if (existing && existing.count >= limit) { + return reply.status(429).send({ + error: 'socrates-daily-limit', + type: 'info', + attempts: limit, + limit + }); + } + + const usage = await fastify.prisma.socratesUsage.upsert({ + where: { + userId_date: { userId: req.user.id, date: todayUTC } + }, + create: { + userId: req.user.id, + date: todayUTC, + count: 1 + }, + update: { + count: { increment: 1 } + } + }); + + const attempts = usage.count; + + const rollbackUsage = async () => { + await fastify.prisma.socratesUsage.update({ + where: { + userId_date: { userId: req.user!.id, date: todayUTC } + }, + data: { count: { decrement: 1 } } + }); + }; + + try { + const response = await fetch(SOCRATES_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': SOCRATES_API_KEY + }, + body: JSON.stringify({ + description: req.body.description, + userInput: req.body.userInput, + seed: req.body.seed, + hints: req.body.hints, + userId: req.user.id + }) + }); + + const responseText = await response.text(); + + if (!response.ok) { + req.log.error( + { + status: response.status, + response: responseText || undefined + }, + 'Socrates API returned an error response.' + ); + + await rollbackUsage(); + + if (response.status === 429) { + return reply.status(429).send({ + error: 'socrates-rate-limit', + type: 'info', + attempts: attempts - 1, + limit + }); + } + + if (response.status === 400) { + let upstreamMessage: string | undefined; + try { + const parsed = responseText + ? (JSON.parse(responseText) as { error?: string }) + : null; + upstreamMessage = parsed?.error; + } catch { + // ignore parse errors + } + return reply.status(400).send({ + error: upstreamMessage || 'socrates-unable-to-generate', + type: 'info', + attempts: attempts - 1, + limit + }); + } + + return reply.status(500).send({ + error: 'socrates-unavailable', + type: 'danger', + attempts: attempts - 1, + limit + }); + } + + let payload: unknown; + try { + payload = responseText ? JSON.parse(responseText) : null; + } catch (error) { + req.log.error({ + err: error, + response: responseText || undefined + }); + await rollbackUsage(); + return reply.status(500).send({ + error: 'socrates-unavailable', + type: 'danger', + attempts: attempts - 1, + limit + }); + } + + if ( + !payload || + typeof payload !== 'object' || + typeof (payload as { hint?: unknown }).hint !== 'string' + ) { + req.log.error( + { + response: payload + }, + 'Socrates API did not return a hint.' + ); + await rollbackUsage(); + return reply.status(500).send({ + error: 'socrates-unavailable', + type: 'danger', + attempts: attempts - 1, + limit + }); + } + + const { hint } = payload as { hint: string }; + + return { hint, attempts, limit } as const; + } catch (error) { + req.log.error( + { err: error }, + 'Failed to fetch hint from Socrates API.' + ); + await rollbackUsage(); + return reply.status(500).send({ + error: 'socrates-unavailable', + type: 'danger', + attempts: attempts - 1, + limit + }); + } + } + ); + done(); +}; diff --git a/api/src/routes/protected/user.test.ts b/api/src/routes/protected/user.test.ts index 7ce42727e1a..95eb5f4c58f 100644 --- a/api/src/routes/protected/user.test.ts +++ b/api/src/routes/protected/user.test.ts @@ -318,6 +318,7 @@ const publicUserData = { portfolio: testUserData.portfolio, profileUI: testUserData.profileUI, savedChallenges: testUserData.savedChallenges, + socrates: true, twitter: 'https://x.com/foobar', bluesky: 'https://bsky.app/profile/foobar', sendQuincyEmail: testUserData.sendQuincyEmail, @@ -1052,6 +1053,7 @@ describe('userRoutes', () => { keyboardShortcuts: false, location: '', name: '', + socrates: true, theme: 'default' }; diff --git a/api/src/routes/protected/user.ts b/api/src/routes/protected/user.ts index 24c933c2ae8..332998f1aaa 100644 --- a/api/src/routes/protected/user.ts +++ b/api/src/routes/protected/user.ts @@ -733,6 +733,7 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = ( progressTimestamps: true, savedChallenges: true, sendQuincyEmail: true, + socrates: true, theme: true, twitter: true, bluesky: true, @@ -785,6 +786,7 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = ( name, theme, experience, + socrates, ...publicUser } = rest; @@ -821,7 +823,8 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = ( userToken: encodedToken, completedSurveys: normalizeSurveys(completedSurveys), experience: experience.map(removeNulls), - msUsername: msUsername?.msUsername + msUsername: msUsername?.msUsername, + socrates: socrates ?? true } }, result: user.username diff --git a/api/src/schemas.ts b/api/src/schemas.ts index 88335b2a29e..c5dadbf8fd8 100644 --- a/api/src/schemas.ts +++ b/api/src/schemas.ts @@ -31,6 +31,7 @@ export { updateMyPortfolio } from './schemas/settings/update-my-portfolio.js'; export { updateMyPrivacyTerms } from './schemas/settings/update-my-privacy-terms.js'; export { updateMyProfileUI } from './schemas/settings/update-my-profile-ui.js'; export { updateMyQuincyEmail } from './schemas/settings/update-my-quincy-email.js'; +export { updateSocrates } from './schemas/settings/update-socrates.js'; export { updateMySocials } from './schemas/settings/update-my-socials.js'; export { updateMyTheme } from './schemas/settings/update-my-theme.js'; export { updateMyUsername } from './schemas/settings/update-my-username.js'; @@ -39,6 +40,7 @@ export { deleteMyAccount, deleteUser } from './schemas/user/delete-my-account.js'; +export { askSocrates } from './schemas/socrates/ask-socrates.js'; export { deleteUserToken } from './schemas/user/delete-user-token.js'; export { getSessionUser } from './schemas/user/get-session-user.js'; export { postMsUsername } from './schemas/user/post-ms-username.js'; diff --git a/api/src/schemas/settings/update-socrates.ts b/api/src/schemas/settings/update-socrates.ts new file mode 100644 index 00000000000..96a4acbe01c --- /dev/null +++ b/api/src/schemas/settings/update-socrates.ts @@ -0,0 +1,21 @@ +import { Type } from '@fastify/type-provider-typebox'; + +export const updateSocrates = { + body: Type.Object({ + socrates: Type.Boolean() + }), + response: { + 200: Type.Object({ + message: Type.Literal('flash.socrates-updated'), + type: Type.Literal('success') + }), + 400: Type.Object({ + message: Type.Literal('flash.wrong-updating'), + type: Type.Literal('danger') + }), + 500: Type.Object({ + message: Type.Literal('flash.wrong-updating'), + type: Type.Literal('danger') + }) + } +}; diff --git a/api/src/schemas/socrates/ask-socrates.ts b/api/src/schemas/socrates/ask-socrates.ts new file mode 100644 index 00000000000..3af6848c00d --- /dev/null +++ b/api/src/schemas/socrates/ask-socrates.ts @@ -0,0 +1,49 @@ +import { Type } from '@fastify/type-provider-typebox'; + +const socratesHint = Type.Object({ + text: Type.String(), + failed: Type.Optional(Type.Boolean()) +}); + +const usageFields = { + attempts: Type.Integer(), + limit: Type.Integer() +}; + +export const askSocrates = { + body: Type.Object( + { + description: Type.String({ minLength: 1, maxLength: 2000 }), + userInput: Type.Optional(Type.String({ minLength: 1, maxLength: 50000 })), + seed: Type.String({ minLength: 1, maxLength: 50000 }), + hints: Type.Array(socratesHint, { maxItems: 200 }) + }, + { additionalProperties: false } + ), + response: { + 200: Type.Object({ + hint: Type.String(), + ...usageFields + }), + 400: Type.Object({ + error: Type.String(), + type: Type.Literal('info'), + ...usageFields + }), + 403: Type.Object({ + error: Type.String(), + type: Type.Literal('danger'), + ...usageFields + }), + 429: Type.Object({ + error: Type.String(), + type: Type.Literal('info'), + ...usageFields + }), + 500: Type.Object({ + error: Type.String(), + type: Type.Literal('danger'), + ...usageFields + }) + } +}; diff --git a/api/src/schemas/user/get-session-user.ts b/api/src/schemas/user/get-session-user.ts index 0bfadc0d2fe..02fbc3ae57c 100644 --- a/api/src/schemas/user/get-session-user.ts +++ b/api/src/schemas/user/get-session-user.ts @@ -126,6 +126,7 @@ export const getSessionUser = { experience: Type.Optional(Type.Array(experience)), profileUI, sendQuincyEmail: Type.Union([Type.Null(), Type.Boolean()]), // // Tri-state: null (likely new user), true (subscribed), false (unsubscribed) + socrates: Type.Optional(Type.Boolean()), theme: Type.String(), twitter: Type.Optional(Type.String()), bluesky: Type.Optional(Type.String()), diff --git a/api/src/utils/env.ts b/api/src/utils/env.ts index f38d3e3389a..81d4c5b8863 100644 --- a/api/src/utils/env.ts +++ b/api/src/utils/env.ts @@ -63,6 +63,8 @@ assert.ok(process.env.JWT_SECRET); assert.ok(process.env.STRIPE_SECRET_KEY); assert.ok(process.env.MONGOHQ_URL); assert.ok(process.env.COOKIE_SECRET); +assert.ok(process.env.SOCRATES_API_KEY); +assert.ok(process.env.SOCRATES_ENDPOINT); const LOG_LEVELS: LogLevel[] = [ 'fatal', @@ -217,6 +219,8 @@ export const GROWTHBOOK_FASTIFY_API_HOST = process.env.GROWTHBOOK_FASTIFY_API_HOST; export const GROWTHBOOK_FASTIFY_CLIENT_KEY = process.env.GROWTHBOOK_FASTIFY_CLIENT_KEY; +export const SOCRATES_API_KEY = process.env.SOCRATES_API_KEY; +export const SOCRATES_ENDPOINT = process.env.SOCRATES_ENDPOINT; function undefinedOrBool(val: string | undefined): undefined | boolean { if (!val) { diff --git a/api/turbo.json b/api/turbo.json index 8d502f53d00..1c01ee22d2e 100644 --- a/api/turbo.json +++ b/api/turbo.json @@ -35,6 +35,8 @@ "SES_REGION", "SES_SECRET", "SHOW_UPCOMING_CHANGES", + "SOCRATES_API_KEY", + "SOCRATES_ENDPOINT", "STRIPE_SECRET_KEY" ] }, @@ -72,6 +74,8 @@ "SES_REGION", "SES_SECRET", "SHOW_UPCOMING_CHANGES", + "SOCRATES_API_KEY", + "SOCRATES_ENDPOINT", "STRIPE_SECRET_KEY" ] } diff --git a/api/vitest.utils.ts b/api/vitest.utils.ts index 0cea14c8980..4da759983c2 100644 --- a/api/vitest.utils.ts +++ b/api/vitest.utils.ts @@ -124,6 +124,15 @@ const indexData: IndexData[] = [ collection: 'MsUsername', indexes: [{ key: { userId: 1, id: 1 }, name: 'userId_1__id_1' }] }, + { + collection: 'SocratesUsage', + indexes: [ + { + key: { userId: 1, date: 1 }, + name: 'userId_date_unique' + } + ] + }, { collection: 'Survey', indexes: [{ key: { userId: 1 }, name: 'userId_1' }] diff --git a/client/config/growthbook-features-default.json b/client/config/growthbook-features-default.json index 0d9583cea8e..e1cd0d73a36 100644 --- a/client/config/growthbook-features-default.json +++ b/client/config/growthbook-features-default.json @@ -22,14 +22,8 @@ "hashAttribute": "id", "seed": "landing-top-skill-focused", "hashVersion": 2, - "variations": [ - false, - true - ], - "weights": [ - 0.5, - 0.5 - ], + "variations": [false, true], + "weights": [0.5, 0.5], "key": "landing-top-skill-focused", "meta": [ { @@ -54,14 +48,8 @@ "hashAttribute": "id", "seed": "replace-20-with-25", "hashVersion": 2, - "variations": [ - false, - true - ], - "weights": [ - 0.5, - 0.5 - ], + "variations": [false, true], + "weights": [0.5, 0.5], "key": "replace-20-with-25", "meta": [ { @@ -86,14 +74,8 @@ "hashAttribute": "id", "seed": "show-modal-randomly", "hashVersion": 2, - "variations": [ - false, - true - ], - "weights": [ - 0.5, - 0.5 - ], + "variations": [false, true], + "weights": [0.5, 0.5], "key": "show-modal-randomly", "meta": [ { @@ -118,14 +100,8 @@ "hashAttribute": "id", "seed": "landing-two-button-cta", "hashVersion": 2, - "variations": [ - false, - true - ], - "weights": [ - 0.5, - 0.5 - ], + "variations": [false, true], + "weights": [0.5, 0.5], "key": "landing-two-button-cta", "meta": [ { @@ -144,5 +120,8 @@ }, "disabled_blocks": { "defaultValue": [] + }, + "show-socrates": { + "defaultValue": false } } diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index fd3096a2938..cc0649b13ac 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -66,6 +66,7 @@ "command-enter": "⌘ + Enter", "ctrl-enter": "Ctrl + Enter", "reset": "Reset", + "ask-socrates": "Ask Socrates (beta)", "reset-step": "Reset This Step", "help": "Help", "get-help": "Get Help", @@ -381,6 +382,10 @@ "confirm": "Confirm New Email", "weekly": "Send me Quincy's weekly email" }, + "socrates": { + "p1": "Socrates", + "p2": "Offers tailored hints based on your input in workshops. You can turn this off at any time." + }, "honesty": { "p1": "Before you can claim a verified certification, you must accept our Academic Honesty Pledge, which reads:", "p2": "\"I understand that plagiarism means copying someone else’s work and presenting the work as if it were my own, without clearly attributing the original author.\"", @@ -533,6 +538,18 @@ "syntax-error": "Your code raised an error before any tests could run. Please fix it and try again.", "indentation-error": "Your code has an indentation error. You may need to add pass on a new line to form a valid block of code.", "sign-in-save": "Sign in to save your progress", + "hints-used-today": "hints used today", + "socrates-not-enabled": "Socrates is not enabled for your account.", + "socrates-check-code-first": "Check your code before asking Socrates for a hint.", + "socrates-code-passes": "Congratulations, your code passes! Press submit and continue to the next challenge.", + "socrates-write-code-first": "Please write some code before asking Socrates for a hint.", + "socrates-generic-error": "Something went wrong while asking Socrates. Please try again.", + "socrates-no-access": "You do not have access to Socrates.", + "socrates-daily-limit": "You have reached the daily hint limit. Please try again tomorrow.", + "socrates-rate-limit": "You have reached the hint limit. Please wait a moment before trying again.", + "socrates-unable-to-generate": "Socrates was unable to generate a hint. Please try again.", + "socrates-unavailable": "Socrates is temporarily unavailable. Please try again later.", + "socrates-invalid-request": "Something went wrong with your request. Please try again.", "download-solution": "Download my solution", "download-results": "Download my results", "percent-complete": "{{percent}}% complete", @@ -1037,6 +1054,7 @@ "updated-themes": "We have updated your theme", "keyboard-shortcut-updated": "We have updated your keyboard shortcuts settings", "subscribe-to-quincy-updated": "We have updated your subscription to Quincy's email", + "socrates-updated": "We have updated your Socrates settings", "portfolio-item-updated": "We have updated your portfolio", "experience-updated": "We have updated your experience", "email-invalid": "Email format is invalid", diff --git a/client/src/assets/icons/stars.tsx b/client/src/assets/icons/stars.tsx new file mode 100644 index 00000000000..f4288e8db6f --- /dev/null +++ b/client/src/assets/icons/stars.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +function Stars( + props: JSX.IntrinsicAttributes & React.SVGProps +): JSX.Element { + return ( + + + + + + + ); +} + +Stars.displayName = 'Stars'; + +export default Stars; diff --git a/client/src/client-only-routes/show-settings.test.tsx b/client/src/client-only-routes/show-settings.test.tsx index 3db2f5195ad..ddb07f334bb 100644 --- a/client/src/client-only-routes/show-settings.test.tsx +++ b/client/src/client-only-routes/show-settings.test.tsx @@ -11,6 +11,9 @@ import { initialState } from '../redux'; const testUsername = 'testuser'; vi.mock('../utils/get-words'); +vi.mock('@growthbook/growthbook-react', () => ({ + useFeature: () => ({ on: false }) +})); const { apiLocation } = envData; diff --git a/client/src/client-only-routes/show-settings.tsx b/client/src/client-only-routes/show-settings.tsx index cc564fde793..8da55580e5c 100644 --- a/client/src/client-only-routes/show-settings.tsx +++ b/client/src/client-only-routes/show-settings.tsx @@ -161,7 +161,8 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element { isHonest, sendQuincyEmail, username, - keyboardShortcuts + keyboardShortcuts, + socrates } = user; const sound = (store.get('fcc-sound') as boolean) ?? false; @@ -199,6 +200,7 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element { resetEditorLayout={resetEditorLayout} toggleKeyboardShortcuts={toggleKeyboardShortcuts} toggleSoundMode={toggleSoundMode} + socrates={socrates} /> diff --git a/client/src/components/settings/account.tsx b/client/src/components/settings/account.tsx index 34cd6ca9a39..a5cce73d202 100644 --- a/client/src/components/settings/account.tsx +++ b/client/src/components/settings/account.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; +import { useFeature } from '@growthbook/growthbook-react'; import { Button, Spacer } from '@freecodecamp/ui'; import { FullWidthRow } from '../helpers'; @@ -7,11 +8,13 @@ import SoundSettings from './sound'; import KeyboardShortcutsSettings from './keyboard-shortcuts'; import ScrollbarWidthSettings from './scrollbar-width'; import SectionHeader from './section-header'; +import SocratesSettings from './socrates'; type MiscSettingsProps = { keyboardShortcuts: boolean; sound: boolean; editorLayout: boolean | null; + socrates: boolean; toggleKeyboardShortcuts: (keyboardShortcuts: boolean) => void; toggleSoundMode: (sound: boolean) => void; resetEditorLayout: () => void; @@ -23,14 +26,17 @@ const MiscSettings = ({ editorLayout, resetEditorLayout, toggleKeyboardShortcuts, - toggleSoundMode + toggleSoundMode, + socrates }: MiscSettingsProps) => { const { t } = useTranslation(); + const showSocratesFlag = useFeature('show-socrates').on; return (
{t('settings.headings.account')} + {showSocratesFlag && } void; + toggleSoundMode: (sound: boolean) => void; + resetEditorLayout: () => void; +}; + +const MiscSettings = ({ + keyboardShortcuts, + sound, + editorLayout, + resetEditorLayout, + toggleKeyboardShortcuts, + toggleSoundMode +}: MiscSettingsProps) => { + const { t } = useTranslation(); + + return ( + <> + + + + + + + + + + + ); +}; + +export default MiscSettings; diff --git a/client/src/components/settings/socrates.tsx b/client/src/components/settings/socrates.tsx new file mode 100644 index 00000000000..13fcc7880fb --- /dev/null +++ b/client/src/components/settings/socrates.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import type { TFunction } from 'i18next'; +import { withTranslation } from 'react-i18next'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import type { Dispatch } from 'redux'; + +import { updateMySocrates as updateMySocratesAction } from '../../redux/settings/actions'; +import ToggleButtonSetting from './toggle-button-setting'; + +const mapStateToProps = () => ({}); +const mapDispatchToProps = (dispatch: Dispatch) => + bindActionCreators({ updateMySocrates: updateMySocratesAction }, dispatch); + +type SocratesProps = { + socrates: boolean; + t: TFunction; + updateMySocrates: (socrates: { socrates: boolean }) => void; +}; + +function SocratesSettings({ + socrates, + t, + updateMySocrates +}: SocratesProps): JSX.Element { + return ( +
+ updateMySocrates({ socrates: !socrates })} + /> +
+ ); +} + +SocratesSettings.displayName = 'SocratesSettings'; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(withTranslation()(SocratesSettings)); diff --git a/client/src/redux/index.js b/client/src/redux/index.js index 6caae3a3419..c0e9358f2bd 100644 --- a/client/src/redux/index.js +++ b/client/src/redux/index.js @@ -487,6 +487,8 @@ export const reducer = handleActions( payload ? spreadThePayloadOnUser(state, payload) : state, [settingsTypes.updateMyQuincyEmailComplete]: (state, { payload }) => payload ? spreadThePayloadOnUser(state, payload) : state, + [settingsTypes.updateMySocratesComplete]: (state, { payload }) => + payload ? spreadThePayloadOnUser(state, payload) : state, [settingsTypes.updateMyPortfolioComplete]: (state, { payload }) => payload ? spreadThePayloadOnUser(state, payload) : state, [settingsTypes.updateMyExperienceComplete]: (state, { payload }) => diff --git a/client/src/redux/prop-types.ts b/client/src/redux/prop-types.ts index d6d88d785a8..9f3ae2891c7 100644 --- a/client/src/redux/prop-types.ts +++ b/client/src/redux/prop-types.ts @@ -451,6 +451,7 @@ export type User = { sound: boolean; theme: UserThemes; keyboardShortcuts: boolean; + socrates: boolean; twitter: string; bluesky: string; username: string; @@ -516,6 +517,7 @@ export type ChallengeMeta = { title?: string; challengeType?: number; helpCategory: string; + description?: string; disableLoopProtectTests: boolean; disableLoopProtectPreview: boolean; saveSubmissionToDB?: boolean; diff --git a/client/src/redux/selectors.js b/client/src/redux/selectors.js index 5b03e62d8ae..f6ad501353a 100644 --- a/client/src/redux/selectors.js +++ b/client/src/redux/selectors.js @@ -27,6 +27,7 @@ export const isDonatingSelector = state => userSelector(state)?.isDonating; export const isOnlineSelector = state => state[MainApp].isOnline; export const isServerOnlineSelector = state => state[MainApp].isServerOnline; export const isSignedInSelector = state => !!userSelector(state); +export const isSocratesOnSelector = state => userSelector(state)?.socrates; export const isDonationModalOpenSelector = state => state[MainApp].showDonationModal; export const isSignoutModalOpenSelector = state => diff --git a/client/src/redux/settings/action-types.js b/client/src/redux/settings/action-types.js index bab4e535647..070550d8aaa 100644 --- a/client/src/redux/settings/action-types.js +++ b/client/src/redux/settings/action-types.js @@ -13,6 +13,7 @@ export const actionTypes = createTypes( ...createAsyncTypes('updateMyKeyboardShortcuts'), ...createAsyncTypes('updateMyHonesty'), ...createAsyncTypes('updateMyQuincyEmail'), + ...createAsyncTypes('updateMySocrates'), ...createAsyncTypes('updateMyPortfolio'), ...createAsyncTypes('updateMyExperience'), ...createAsyncTypes('submitProfileUI'), diff --git a/client/src/redux/settings/actions.js b/client/src/redux/settings/actions.js index fac529b414e..96555173d30 100644 --- a/client/src/redux/settings/actions.js +++ b/client/src/redux/settings/actions.js @@ -73,6 +73,13 @@ export const updateMyQuincyEmailError = createAction( types.updateMyQuincyEmailError ); +export const updateMySocrates = createAction(types.updateMySocrates); +export const updateMySocratesComplete = createAction( + types.updateMySocratesComplete, + checkForSuccessPayload +); +export const updateMySocratesError = createAction(types.updateMySocratesError); + export const updateMyPortfolio = createAction(types.updateMyPortfolio); export const updateMyPortfolioComplete = createAction( types.updateMyPortfolioComplete, diff --git a/client/src/redux/settings/settings-sagas.js b/client/src/redux/settings/settings-sagas.js index c8d466c275f..e76541db3ce 100644 --- a/client/src/redux/settings/settings-sagas.js +++ b/client/src/redux/settings/settings-sagas.js @@ -22,6 +22,7 @@ import { putUpdateMyExperience, putUpdateMyProfileUI, putUpdateMyQuincyEmail, + putUpdateMySocrates, putUpdateMySocials, putUpdateMyUsername, putVerifyCert @@ -46,6 +47,8 @@ import { updateMyExperienceError, updateMyQuincyEmailComplete, updateMyQuincyEmailError, + updateMySocratesComplete, + updateMySocratesError, updateMySocialsComplete, updateMySocialsError, updateMySoundComplete, @@ -162,6 +165,16 @@ function* updateMyQuincyEmailSaga({ payload: update }) { } } +function* updateMySocratesSaga({ payload: update }) { + try { + const { data } = yield call(putUpdateMySocrates, update); + yield put(updateMySocratesComplete({ ...data, payload: update })); + yield put(createFlashMessage({ ...data })); + } catch { + yield put(updateMySocratesError); + } +} + function* updateMyPortfolioSaga({ payload: update }) { try { const { data } = yield call(putUpdateMyPortfolio, update); @@ -250,6 +263,7 @@ export function createSettingsSagas(types) { takeEvery(types.resetMyEditorLayout, resetMyEditorLayoutSaga), takeEvery(types.updateMyKeyboardShortcuts, updateMyKeyboardShortcutsSaga), takeEvery(types.updateMyQuincyEmail, updateMyQuincyEmailSaga), + takeEvery(types.updateMySocrates, updateMySocratesSaga), takeEvery(types.updateMyPortfolio, updateMyPortfolioSaga), takeEvery(types.updateMyExperience, updateMyExperienceSaga), takeLatest(types.submitNewAbout, submitNewAboutSaga), diff --git a/client/src/templates/Challenges/classic/show.tsx b/client/src/templates/Challenges/classic/show.tsx index 121f0659fb8..fec70917eee 100644 --- a/client/src/templates/Challenges/classic/show.tsx +++ b/client/src/templates/Challenges/classic/show.tsx @@ -400,6 +400,7 @@ function ShowClassic({ title, challengeType, helpCategory, + description, ...challengePaths }); challengeMounted(challengeMeta.id); diff --git a/client/src/templates/Challenges/codeally/show.tsx b/client/src/templates/Challenges/codeally/show.tsx index a9343e2ead6..77c46c329bc 100644 --- a/client/src/templates/Challenges/codeally/show.tsx +++ b/client/src/templates/Challenges/codeally/show.tsx @@ -150,7 +150,6 @@ function ShowCodeAlly({ } } } = data; - const blockNameTitle = `${t( `intro:${superBlock}.blocks.${block}.title` )}: ${title}`; @@ -174,6 +173,7 @@ function ShowCodeAlly({ title, challengeType, helpCategory, + description, ...challengePaths }); challengeMounted(challengeMeta.id); diff --git a/client/src/templates/Challenges/components/independent-lower-jaw.css b/client/src/templates/Challenges/components/independent-lower-jaw.css index 766203add7f..d8051175bb9 100644 --- a/client/src/templates/Challenges/components/independent-lower-jaw.css +++ b/client/src/templates/Challenges/components/independent-lower-jaw.css @@ -27,6 +27,7 @@ display: flex; justify-content: space-between; flex-direction: row; + margin-bottom: 10px; } .independent-lower-jaw .hint-container .hint-header svg { @@ -67,9 +68,7 @@ } .independent-lower-jaw .hint-container button { - height: 30px; font-size: 1.5rem; - min-width: 30px; display: flex; justify-content: center; align-items: center; @@ -102,6 +101,7 @@ font-size: 1rem; text-align: center; position: absolute; + width: max-content; top: -60px; z-index: 1; } @@ -113,6 +113,8 @@ transition: opacity 0.5s ease 1s; } +/* .independent-lower-jaw */ + .tooltiptext::after { content: ''; position: absolute; @@ -143,6 +145,52 @@ gap: 10px; } +.socrates-skeleton { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px 0; +} + +.skeleton-line { + height: 16px; + border-radius: 2px; +} + +.skeleton-line-1 { + background-color: var(--tertiary-background); + width: 100%; + animation: pulse-1 1.5s ease-in-out infinite; +} + +.skeleton-line-2 { + background-color: var(--tertiary-background); + width: 85%; + animation: pulse-2 1.5s ease-in-out infinite; +} + +@keyframes pulse-1 { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +@keyframes pulse-2 { + 0% { + opacity: 0.5; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.5; + } +} + .share-button-wrapper a { border: 1px solid var(--quaternary-color); padding: 2px 6px; diff --git a/client/src/templates/Challenges/components/independent-lower-jaw.test.tsx b/client/src/templates/Challenges/components/independent-lower-jaw.test.tsx index 027b362bc59..7cf4035184f 100644 --- a/client/src/templates/Challenges/components/independent-lower-jaw.test.tsx +++ b/client/src/templates/Challenges/components/independent-lower-jaw.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { useStaticQuery } from 'gatsby'; @@ -11,6 +12,11 @@ import { mockCurriculumData } from '../utils/__fixtures__/curriculum-data'; import { render } from '../../../../utils/test-utils'; vi.mock('../../../components/Progress'); + +let showSocratesFlag = true; +vi.mock('@growthbook/growthbook-react', () => ({ + useFeature: () => ({ on: showSocratesFlag }) +})); vi.mock('../utils/fetch-all-curriculum-data', () => ({ useSubmit: () => vi.fn() })); @@ -30,19 +36,30 @@ const baseProps = { openHelpModal: vi.fn(), openResetModal: vi.fn(), executeChallenge: vi.fn(), + submitChallenge: vi.fn(), + askSocrates: vi.fn(), saveChallenge: vi.fn(), tests: passingTests, isSignedIn: true, challengeMeta: baseChallengeMeta, completedPercent: 100, completedChallengeIds: ['id-1', 'test-challenge-id'], - currentBlockIds: ['id-1', 'test-challenge-id'] + currentBlockIds: ['id-1', 'test-challenge-id'], + hasSocratesAccess: false, + socratesHintState: { + hint: null, + isLoading: false, + error: null, + attempts: null, + limit: null + } }; vi.mock('../../../utils/get-words'); describe('', () => { beforeEach(() => { + showSocratesFlag = true; vi.mocked(useStaticQuery).mockReturnValue(mockCurriculumData); }); @@ -83,4 +100,61 @@ describe('', () => { expect(screen.queryByTestId('share-on-x')).not.toBeInTheDocument(); }); + + it('shows socrates button when hasSocratesAccess is true and flag is on', () => { + render( + , + createStore() + ); + + expect(screen.getByText('buttons.ask-socrates')).toBeInTheDocument(); + }); + + it('hides socrates button when show-socrates flag is off', () => { + showSocratesFlag = false; + + render( + , + createStore() + ); + + expect(screen.queryByText('buttons.ask-socrates')).not.toBeInTheDocument(); + }); + + it('hides socrates button when hasSocratesAccess is false', () => { + render( + , + createStore() + ); + + expect(screen.queryByText('buttons.ask-socrates')).not.toBeInTheDocument(); + }); + + it('displays usage counter when attempts and limit are set', async () => { + const failingTests: Test[] = [ + { pass: false, err: 'fail', text: 'test', testString: 'test' } + ]; + + render( + , + createStore() + ); + + // Click the socrates button to open the results panel + await userEvent.click(screen.getByRole('button', { name: /ask-socrates/ })); + + expect(screen.getByText(/2\/3/)).toBeInTheDocument(); + expect(screen.getByText(/learn\.hints-used-today/)).toBeInTheDocument(); + }); }); diff --git a/client/src/templates/Challenges/components/independent-lower-jaw.tsx b/client/src/templates/Challenges/components/independent-lower-jaw.tsx index ee447efb80c..bcc9655ea76 100644 --- a/client/src/templates/Challenges/components/independent-lower-jaw.tsx +++ b/client/src/templates/Challenges/components/independent-lower-jaw.tsx @@ -2,7 +2,9 @@ import React from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { useTranslation } from 'react-i18next'; +import sanitizeHtml from 'sanitize-html'; import { Button, Spacer } from '@freecodecamp/ui'; +import { useFeature } from '@growthbook/growthbook-react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faLightbulb, @@ -15,17 +17,19 @@ import { import Progress from '../../../components/Progress'; import { completedChallengesIdsSelector, - isSignedInSelector + isSignedInSelector, + isSocratesOnSelector } from '../../../redux/selectors'; import { ChallengeMeta, Test } from '../../../redux/prop-types'; import { challengeMetaSelector, challengeTestsSelector, completedPercentageSelector, - currentBlockIdsSelector + currentBlockIdsSelector, + socratesHintStateSelector } from '../redux/selectors'; import { apiLocation } from '../../../../config/env.json'; -import { openModal, executeChallenge } from '../redux/actions'; +import { openModal, executeChallenge, askSocrates } from '../redux/actions'; import { saveChallenge } from '../../../redux/actions'; import Help from '../../../assets/icons/help'; import callGA from '../../../analytics/call-ga'; @@ -33,6 +37,15 @@ import { Share } from '../../../components/share'; import { useSubmit } from '../utils/fetch-all-curriculum-data'; import './independent-lower-jaw.css'; +import Stars from '../../../assets/icons/stars'; + +type SocratesHintState = { + hint: null | string; + isLoading: boolean; + error: null | string; + attempts: null | number; + limit: null | number; +}; const mapStateToProps = createSelector( challengeTestsSelector, @@ -41,26 +54,33 @@ const mapStateToProps = createSelector( completedPercentageSelector, completedChallengesIdsSelector, currentBlockIdsSelector, + socratesHintStateSelector, + isSocratesOnSelector, ( tests: Test[], isSignedIn: boolean, challengeMeta: ChallengeMeta, completedPercent: number, completedChallengeIds: string[], - currentBlockIds: string[] + currentBlockIds: string[], + socratesHintState: SocratesHintState, + hasSocratesAccess: boolean ) => ({ tests, isSignedIn, challengeMeta, completedPercent, completedChallengeIds, - currentBlockIds + currentBlockIds, + socratesHintState, + hasSocratesAccess }) ); const mapDispatchToProps = { openHelpModal: () => openModal('help'), openResetModal: () => openModal('reset'), + askSocrates: () => askSocrates(), executeChallenge, saveChallenge }; @@ -69,6 +89,7 @@ interface IndependentLowerJawProps { openHelpModal: () => void; openResetModal: () => void; executeChallenge: () => void; + askSocrates: () => void; saveChallenge: () => void; tests: Test[]; isSignedIn: boolean; @@ -76,10 +97,13 @@ interface IndependentLowerJawProps { completedPercent: number; completedChallengeIds: string[]; currentBlockIds: string[]; + socratesHintState: SocratesHintState; + hasSocratesAccess: boolean; } export function IndependentLowerJaw({ openHelpModal, openResetModal, + askSocrates, executeChallenge, saveChallenge, tests, @@ -87,13 +111,17 @@ export function IndependentLowerJaw({ challengeMeta, completedPercent, completedChallengeIds, - currentBlockIds + currentBlockIds, + socratesHintState, + hasSocratesAccess }: IndependentLowerJawProps): JSX.Element { const { t } = useTranslation(); + const showSocratesFlag = useFeature('show-socrates').on; const submitChallenge = useSubmit(); const firstFailedTest = tests.find(test => !!test.err); const hint = firstFailedTest?.message; const [showHint, setShowHint] = React.useState(false); + const [showSocratesResults, setShowSocratesResults] = React.useState(false); const [showSubmissionHint, setShowSubmissionHint] = React.useState(true); const signInLinkRef = React.useRef(null); const submitButtonRef = React.useRef(null); @@ -132,6 +160,7 @@ export function IndependentLowerJaw({ const handleCheckButtonClick = () => { setWasCheckButtonClicked(true); + setShowSocratesResults(false); executeChallenge(); }; @@ -141,6 +170,14 @@ export function IndependentLowerJaw({ ? t('buttons.command-enter') : t('buttons.ctrl-enter'); + const askSocratesAttempt = () => { + setShowSocratesResults(true); + setShowHint(false); + setShowSubmissionHint(false); + if (socratesHintState.isLoading) return; + askSocrates(); + }; + return (
{t('buttons.close')}
-
+
+
+ )} + {showSocratesResults && ( +
+
+ + +
+ {socratesHintState.isLoading ? ( +
+
+
+
+ ) : ( +
+ )} + {socratesHintState.attempts !== null && + socratesHintState.limit !== null && ( +
+ {socratesHintState.attempts}/{socratesHintState.limit}{' '} + {t('learn.hints-used-today')} +
+ )}
)} {isChallengeComplete && showSubmissionHint && ( @@ -254,6 +337,16 @@ export function IndependentLowerJaw({ )}
+ {hasSocratesAccess && showSocratesFlag && ( + + )} {showRevertButton ? ( <>