mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-04-10 22:00:43 -04:00
feat: add socrates (#65430)
Co-authored-by: Mrugesh Mohapatra <noreply@mrugesh.dev>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,3 +3,4 @@ export * from './challenge.js';
|
||||
export * from './donate.js';
|
||||
export * from './settings.js';
|
||||
export * from './user.js';
|
||||
export * from './socrates.js';
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
538
api/src/routes/protected/socrates.test.ts
Normal file
538
api/src/routes/protected/socrates.test.ts
Normal file
@@ -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: '<h1>Hello</h1>',
|
||||
hints: [{ text: 'Check your spelling', failed: true }]
|
||||
};
|
||||
|
||||
describe('socratesRoutes', () => {
|
||||
setupServer();
|
||||
|
||||
describe('Authenticated user', () => {
|
||||
let superPut: ReturnType<typeof createSuperRequest>;
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
217
api/src/routes/protected/socrates.ts
Normal file
217
api/src/routes/protected/socrates.ts
Normal file
@@ -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();
|
||||
};
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
21
api/src/schemas/settings/update-socrates.ts
Normal file
21
api/src/schemas/settings/update-socrates.ts
Normal file
@@ -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')
|
||||
})
|
||||
}
|
||||
};
|
||||
49
api/src/schemas/socrates/ask-socrates.ts
Normal file
49
api/src/schemas/socrates/ask-socrates.ts
Normal file
@@ -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
|
||||
})
|
||||
}
|
||||
};
|
||||
@@ -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()),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user