diff --git a/.xata/migrations/.ledger b/.xata/migrations/.ledger index d4aaeb1..8893e78 100644 --- a/.xata/migrations/.ledger +++ b/.xata/migrations/.ledger @@ -15,3 +15,9 @@ mig_cll7h3v3d17cd4sn3q60_deca122c mig_cll7kiv3d17cd4sn3qdg_1d8d01c9 mig_cll7kp2ifsth3gbude0g_4d9ec4f1 mig_cll7ksaifsth3gbude2g_4f044676 +mig_cm34vkpq6978ggbn3vug_5d52fdf6 +mig_cm34vphq6978ggbn4000_70251885 +mig_cm34vshq6978ggbn4010_757966e2 +mig_cm3500fe1um8tedvc2fg_3ce35d63 +mig_cm3505ve1um8tedvc2gg_5b23f048 +mig_cm350a7e1um8tedvc2hg_9ff667b3 diff --git a/.xata/migrations/mig_cm34vkpq6978ggbn3vug_5d52fdf6.json b/.xata/migrations/mig_cm34vkpq6978ggbn3vug_5d52fdf6.json new file mode 100644 index 0000000..d6a6e79 --- /dev/null +++ b/.xata/migrations/mig_cm34vkpq6978ggbn3vug_5d52fdf6.json @@ -0,0 +1,12 @@ +{ + "id": "mig_cm34vkpq6978ggbn3vug", + "parentID": "mig_cll7ksaifsth3gbude2g", + "checksum": "1:5d52fdf657b549157718e1034424567986c2dca4789963fb267a04233e1d3a49", + "operations": [ + { + "removeTable": { + "table": "Backlog-Score" + } + } + ] +} diff --git a/.xata/migrations/mig_cm34vphq6978ggbn4000_70251885.json b/.xata/migrations/mig_cm34vphq6978ggbn4000_70251885.json new file mode 100644 index 0000000..f30f09b --- /dev/null +++ b/.xata/migrations/mig_cm34vphq6978ggbn4000_70251885.json @@ -0,0 +1,12 @@ +{ + "id": "mig_cm34vphq6978ggbn4000", + "parentID": "mig_cm34vkpq6978ggbn3vug", + "checksum": "1:702518851c8273236f41652bdd32368b31c0c5f5c01015297160a964966ab795", + "operations": [ + { + "addTable": { + "table": "SubmissionsMVP" + } + } + ] +} diff --git a/.xata/migrations/mig_cm34vshq6978ggbn4010_757966e2.json b/.xata/migrations/mig_cm34vshq6978ggbn4010_757966e2.json new file mode 100644 index 0000000..868e6d4 --- /dev/null +++ b/.xata/migrations/mig_cm34vshq6978ggbn4010_757966e2.json @@ -0,0 +1,16 @@ +{ + "id": "mig_cm34vshq6978ggbn4010", + "parentID": "mig_cm34vphq6978ggbn4000", + "checksum": "1:757966e2960f70642055f9193194b45397a8a7d560d4f2d9f16a5b9a0c8f26d5", + "operations": [ + { + "addColumn": { + "column": { + "name": "email", + "type": "string" + }, + "table": "SubmissionsMVP" + } + } + ] +} diff --git a/.xata/migrations/mig_cm3500fe1um8tedvc2fg_3ce35d63.json b/.xata/migrations/mig_cm3500fe1um8tedvc2fg_3ce35d63.json new file mode 100644 index 0000000..d272757 --- /dev/null +++ b/.xata/migrations/mig_cm3500fe1um8tedvc2fg_3ce35d63.json @@ -0,0 +1,16 @@ +{ + "id": "mig_cm3500fe1um8tedvc2fg", + "parentID": "mig_cm34vshq6978ggbn4010", + "checksum": "1:3ce35d631d9bcba7efc112a792b4211a2950659afaadbc6a022be4db518b03a1", + "operations": [ + { + "addColumn": { + "column": { + "name": "code", + "type": "text" + }, + "table": "SubmissionsMVP" + } + } + ] +} diff --git a/.xata/migrations/mig_cm3505ve1um8tedvc2gg_5b23f048.json b/.xata/migrations/mig_cm3505ve1um8tedvc2gg_5b23f048.json new file mode 100644 index 0000000..1aae346 --- /dev/null +++ b/.xata/migrations/mig_cm3505ve1um8tedvc2gg_5b23f048.json @@ -0,0 +1,16 @@ +{ + "id": "mig_cm3505ve1um8tedvc2gg", + "parentID": "mig_cm3500fe1um8tedvc2fg", + "checksum": "1:5b23f048ebe20e787b277b8e71291d5ef7a0373d7915a2f917f04d155b9e76b7", + "operations": [ + { + "addColumn": { + "column": { + "name": "challengeName", + "type": "string" + }, + "table": "SubmissionsMVP" + } + } + ] +} diff --git a/.xata/migrations/mig_cm350a7e1um8tedvc2hg_9ff667b3.json b/.xata/migrations/mig_cm350a7e1um8tedvc2hg_9ff667b3.json new file mode 100644 index 0000000..23cd94c --- /dev/null +++ b/.xata/migrations/mig_cm350a7e1um8tedvc2hg_9ff667b3.json @@ -0,0 +1,16 @@ +{ + "id": "mig_cm350a7e1um8tedvc2hg", + "parentID": "mig_cm3505ve1um8tedvc2gg", + "checksum": "1:9ff667b37df60fbaee668dbe3008d5491be0f9a2af6d6a4e5b5f6b272bc93866", + "operations": [ + { + "addColumn": { + "column": { + "name": "dateTime", + "type": "string" + }, + "table": "SubmissionsMVP" + } + } + ] +} diff --git a/app/api/feedback/result/result-email-template.tsx b/app/api/code/score/result-email-template.tsx similarity index 100% rename from app/api/feedback/result/result-email-template.tsx rename to app/api/code/score/result-email-template.tsx diff --git a/app/api/code/score/route.ts b/app/api/code/score/route.ts index 1d3af9d..2790a9b 100644 --- a/app/api/code/score/route.ts +++ b/app/api/code/score/route.ts @@ -1,7 +1,8 @@ import LandingPageChallengeCode from "@/components/landing/test-challenges/challenge-code"; -import axios from "axios"; +import { xata } from "@/lib/xata_client"; import { NextResponse } from "next/server"; import OpenAI from "openai"; +import sendScoreResultEmail from "./submitEmailHandler"; const promptHeader = "Compare code B against code A. Using as few tokens as possible, output a percentage of similaritiy in semantics and intent."; @@ -30,33 +31,49 @@ function extractPercentageScore(inputString: string): number { return 0; } -export async function POST(req: Request) { - const body = await req.json(); - console.log("ENTERED SCORE: ", body); - const { - code, - dateTime, - email, - challenge, - }: { code: string; dateTime: string; email: string; challenge: string } = - body; - const recommendedSolution = LandingPageChallengeCode(challenge); - let score = "0"; - if (!recommendedSolution) { - return NextResponse.json( - { message: `The challenge ${challenge} wasn't recognized.` }, - { status: 400 } - ); - } - if (process.env.IS_PRODUCTION === "true") { - const prompt = - promptHeader + - "\nCode A:\n" + - recommendedSolution + - "\nCode B:\n" + - code; - const result = await openAiCall(prompt); - console.log(` +function deleteSubmissionFromDB(id: string) { + return xata.db.SubmissionsMVP.delete(id); +} + +/* +Cron job endpoint that runs every minute. +*/ +export async function POST() { + const record = await xata.db.SubmissionsMVP.getFirst(); + if (record) { + if ( + !record.challengeName || + !record.code || + !record.dateTime || + !record.email + ) { + deleteSubmissionFromDB(record.id); + return NextResponse.json( + { message: "Record is incomplete and has been deleted." }, + { status: 400 } + ); + } + const challenge = record.challengeName; + const code = record.code; + const dateTime = record.dateTime; + const email = record.email; + const recommendedSolution = LandingPageChallengeCode(challenge); + let score = "0"; + if (!recommendedSolution) { + return NextResponse.json( + { message: `The challenge ${challenge} wasn't recognized.` }, + { status: 400 } + ); + } + if (process.env.IS_PRODUCTION === "true") { + const prompt = + promptHeader + + "\nCode A:\n" + + recommendedSolution + + "\nCode B:\n" + + code; + const result = await openAiCall(prompt); + console.log(` ===Debugging OpenAI=== - Prompt generated: @@ -65,39 +82,25 @@ export async function POST(req: Request) { - Result: ${result} `); - if (!result) { - //add this to the db for retrying later - return NextResponse.json( - { - message: `Error occured. Adding this to the DB for processing later.`, - }, - { status: 500 } - ); - } - const rawPercentageScore = extractPercentageScore(result); - console.log(` + if (!result) { + return NextResponse.json( + { + message: `Result was null. Needs to be retried.`, + }, + { status: 500 } + ); + } + const rawPercentageScore = extractPercentageScore(result); + console.log(` - Percentage score extracted ${rawPercentageScore} `); - score = String(rawPercentageScore); - } else { - score = "58"; + score = String(rawPercentageScore); + } else { + score = "58"; + } + await sendScoreResultEmail(score, challenge, code, dateTime, email); + await deleteSubmissionFromDB(record.id); } - // Extract the host and protocol from the incoming request - const url = new URL(req.url); - const baseUrl = `${url.protocol}//${url.host}`; - console.log( - "ABOUT TO SEND RESULT ", - score, - "to", - `${baseUrl}/api/feedback/result` - ); - axios.post(`${baseUrl}/api/feedback/result`, { - score, - challenge, - code, - dateTime, - email, - }); return NextResponse.json({ message: "Success" }, { status: 200 }); } diff --git a/app/api/code/score/submitEmailHandler.ts b/app/api/code/score/submitEmailHandler.ts new file mode 100644 index 0000000..3a19001 --- /dev/null +++ b/app/api/code/score/submitEmailHandler.ts @@ -0,0 +1,37 @@ +import nodemailer from "nodemailer"; +import ScoreEmail from "../../code/score/result-email-template"; +import { render } from "@react-email/render"; + +export default async function sendScoreResultEmail( + score: string, + challenge: string, + code: string, + dateTime: string, + email: string +) { + const transporter = nodemailer.createTransport({ + port: 465, + host: "smtp.gmail.com", + auth: { + user: process.env.EMAIL, + pass: process.env.PASSWORD, + }, + secure: true, + }); + const emailHtml = render(ScoreEmail({ score, challenge, code, dateTime })); + const options = { + from: `"Tailspin Team" "`, + to: email, + subject: `The results are in!`, + html: emailHtml, + }; + const sendMessage = async (options: { + from: string | undefined; + to: string; + subject: string; + html: string; + }) => { + await transporter.sendMail(options); + }; + await sendMessage(options); +} diff --git a/app/api/code/submit/route.ts b/app/api/code/submit/route.ts index 8f4dc8c..7bd1039 100644 --- a/app/api/code/submit/route.ts +++ b/app/api/code/submit/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from "next/server"; import { xata } from "@/lib/xata_client"; -import axios from "axios"; import { validateHTML } from "./submit-helpers"; export async function GET() { @@ -19,25 +18,25 @@ export async function GET() { /* This is the entry point after code gets submitted. At the moment, this endpoint and its children endpoints are not protected. In the future, these routes need to be protected from DOS or spam attacks. -Warning: -This endpoint can get confusing because we've decoupled the functionalities we require into their own endpoints. The benefits of this approach is a decoupled service and easier debugging efforts. The downside is that it might be harder to reason with its correctness because of added complexity. Moreover, another downside is that every endpoint will need to be protected against DOS and spam attacks as mentioned above, which is a small overhead to the entire process. - -Flow: -User makes request -↓ User's wait ends in this step. -Increment the submit counter (api) -↓ -Make the call to the OpenAI wrapper (api) -↓ -On a successful OpenAI call, call the email generator (api) +This endpoint creates a record of the submission. Every minute, a cron job kicks off and processes a single submission at a time. */ export async function POST(req: Request) { try { const body = await req.json(); - console.log("ENTERED SUBMIT: ", body); - const { code } = body; - if (!validateHTML(code)) { + const { + challenge, + code, + dateTime, + email, + }: { + challenge: string; + code: string; + dateTime: string; + email: string; + } = body; + const cleanedCode = validateHTML(code); + if (cleanedCode.length === 0) { return NextResponse.json( { message: @@ -47,7 +46,7 @@ export async function POST(req: Request) { ); } - const { email } = body; + //Update the last submission time for this email. For rate limiting purposes. const record = await xata.db.EmailSubmitRateLimiting.filter({ email: email, }).getFirst(); @@ -76,16 +75,15 @@ export async function POST(req: Request) { lastSubmission: new Date().toISOString(), }); } - // Extract the host and protocol from the incoming request - const url = new URL(req.url); - const baseUrl = `${url.protocol}//${url.host}`; - console.log("BASE URL: ", baseUrl); - // Use the base URL for Axios requests - axios.put(`${baseUrl}/api/increment/submit`, {}); - axios.post(`${baseUrl}/api/code/score`, body); + await xata.db.SubmissionsMVP.create({ + email: email, + code: cleanedCode, + challengeName: challenge, + dateTime: dateTime, + }); - return NextResponse.json({ message: "Accepted" }, { status: 202 }); + return NextResponse.json({ message: "Created" }, { status: 201 }); } catch (error) { console.error(error); return NextResponse.json( diff --git a/app/api/code/submit/submit-helpers.ts b/app/api/code/submit/submit-helpers.ts index 3727e81..939b427 100644 --- a/app/api/code/submit/submit-helpers.ts +++ b/app/api/code/submit/submit-helpers.ts @@ -6,11 +6,11 @@ const window = new JSDOM("").window; const DOMPurify = createDOMPurify(window); // Sanitizer function -export const validateHTML = (html: string): boolean => { +export const validateHTML = (html: string): string => { html = html.replace(/\n/g, " ").replace(/\s+/g, " ").trim(); html = html.replace(//g, ""); if (!isHtml(html)) { - return false; + return ""; } const cleanHTML = DOMPurify.sanitize(html, { @@ -39,8 +39,12 @@ export const validateHTML = (html: string): boolean => { }); if (cleanHTML !== html) { - return false; + return ""; } - return isHtml(cleanHTML); + if (isHtml(cleanHTML)) { + return cleanHTML; + } else { + return ""; + } }; diff --git a/app/api/feedback/result/route.ts b/app/api/feedback/result/route.ts deleted file mode 100644 index 98d6ae1..0000000 --- a/app/api/feedback/result/route.ts +++ /dev/null @@ -1,48 +0,0 @@ -import ScoreEmail from "./result-email-template"; -import { render } from "@react-email/render"; -import { NextResponse } from "next/server"; -import nodemailer from "nodemailer"; - -export async function POST(req: Request) { - try { - const body = await req.json(); - console.log("ENTERED SEND EMAIL: ", body); - const { - score, - challenge, - code, - dateTime, - email, - }: { - score: string; - challenge: string; - code: string; - dateTime: string; - email: string; - } = body; - const transporter = nodemailer.createTransport({ - service: "gmail", - auth: { - user: process.env.EMAIL, - pass: process.env.PASSWORD, - }, - }); - const emailHtml = render( - ScoreEmail({ score, challenge, code, dateTime }) - ); - const options = { - from: `"Tailspin Team" "`, - to: email, - subject: `The results are in!`, - html: emailHtml, - }; - transporter.sendMail(options); - return NextResponse.json({ message: "Success" }, { status: 200 }); - } catch (error) { - console.log(error); - return NextResponse.json( - { message: "Internal server error" }, - { status: 500 } - ); - } -} diff --git a/app/api/test/route.ts b/app/api/test/route.ts new file mode 100644 index 0000000..ef8257d --- /dev/null +++ b/app/api/test/route.ts @@ -0,0 +1,60 @@ +// import FeedbackEmail from "@/components/landing/feedback/feedback-email"; +// import { render } from "@react-email/render"; +import { NextResponse } from "next/server"; +// import nodemailer from "nodemailer"; + +// export async function POST(req: Request) { +// try { +// const transporter = nodemailer.createTransport({ +// port: 465, +// host: "smtp.gmail.com", +// auth: { +// user: process.env.EMAIL, +// pass: process.env.PASSWORD, +// }, +// secure: true, +// }); +// const body = await req.json(); +// const { +// score, +// challenge, +// code, +// dateTime, +// email, +// }: { +// score: string; +// challenge: string; +// code: string; +// dateTime: string; +// email: string; +// } = body; +// const emailHtml = render( +// ScoreEmail({ score, challenge, code, dateTime }) +// ); +// const options = { +// from: `"Tailspin Team" "`, +// to: email, +// subject: `The results are in!`, +// html: emailHtml, +// }; +// const sendMessage = async (options: { +// from: string | undefined; +// to: string; +// subject: string; +// html: string; +// }) => { +// await transporter.sendMail(options); +// }; +// await sendMessage(options); +// return NextResponse.json({ message: "Success" }, { status: 200 }); +// } catch (error) { +// console.log(error); +// return NextResponse.json( +// { message: "Internal server error" }, +// { status: 500 } +// ); +// } +// } +export async function POST(req: Request) { + return NextResponse.json({ message: "Hello World" }, { status: 200 }); +} diff --git a/app/layout.tsx b/app/layout.tsx index afd66cc..074a45b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -19,16 +19,16 @@ export const metadata: Metadata = { openGraph: { title: "Tailspin", description: "Competitive TailwindCSS", - url: "https://nextjs.org", //TODO + url: "https://tailspin.vercel.app", siteName: "Tailspin", images: [ { - url: "https://nextjs.org/og.png", //TODO + url: "https://raw.githubusercontent.com/zacharyLYH/Tailspin/main/public/logo-asset.png", width: 800, height: 600, }, { - url: "https://nextjs.org/og-alt.png", //TODO + url: "https://raw.githubusercontent.com/zacharyLYH/Tailspin/main/public/logo-asset.png", width: 1800, height: 1600, alt: "My custom alt", @@ -50,7 +50,6 @@ export const metadata: Metadata = { "max-snippet": -1, }, }, - metadataBase: new URL("https://acme.com"), }; export default function RootLayout({ diff --git a/public/logo-asset.png b/public/logo-asset.png new file mode 100644 index 0000000..871cecd Binary files /dev/null and b/public/logo-asset.png differ diff --git a/xata.ts b/xata.ts index c4b758d..2c55622 100644 --- a/xata.ts +++ b/xata.ts @@ -1,35 +1,35 @@ // Generated by Xata Codegen 0.26.9. Please do not edit. import { buildClient } from "@xata.io/client"; import type { - BaseClientOptions, - SchemaInference, - XataRecord, + BaseClientOptions, + SchemaInference, + XataRecord, } from "@xata.io/client"; const tables = [ - { - name: "Site", - columns: [ - { name: "field_name", type: "string" }, - { name: "field_value", type: "string" }, - ], - }, - { - name: "Backlog-Score", - columns: [ - { name: "code", type: "string" }, - { name: "email", type: "string" }, - { name: "retry_count", type: "int", defaultValue: "0" }, - { name: "challenge", type: "string" }, - ], - }, - { - name: "EmailSubmitRateLimiting", - columns: [ - { name: "email", type: "string", unique: true }, - { name: "lastSubmission", type: "datetime", defaultValue: "now" }, - ], - }, + { + name: "Site", + columns: [ + { name: "field_name", type: "string" }, + { name: "field_value", type: "string" }, + ], + }, + { + name: "EmailSubmitRateLimiting", + columns: [ + { name: "email", type: "string", unique: true }, + { name: "lastSubmission", type: "datetime", defaultValue: "now" }, + ], + }, + { + name: "SubmissionsMVP", + columns: [ + { name: "email", type: "string" }, + { name: "code", type: "text" }, + { name: "challengeName", type: "string" }, + { name: "dateTime", type: "string" }, + ], + }, ] as const; export type SchemaTables = typeof tables; @@ -38,37 +38,37 @@ export type InferredTypes = SchemaInference; export type Site = InferredTypes["Site"]; export type SiteRecord = Site & XataRecord; -export type BacklogScore = InferredTypes["Backlog-Score"]; -export type BacklogScoreRecord = BacklogScore & XataRecord; - export type EmailSubmitRateLimiting = InferredTypes["EmailSubmitRateLimiting"]; export type EmailSubmitRateLimitingRecord = EmailSubmitRateLimiting & - XataRecord; + XataRecord; + +export type SubmissionsMVP = InferredTypes["SubmissionsMVP"]; +export type SubmissionsMVPRecord = SubmissionsMVP & XataRecord; export type DatabaseSchema = { - Site: SiteRecord; - "Backlog-Score": BacklogScoreRecord; - EmailSubmitRateLimiting: EmailSubmitRateLimitingRecord; + Site: SiteRecord; + EmailSubmitRateLimiting: EmailSubmitRateLimitingRecord; + SubmissionsMVP: SubmissionsMVPRecord; }; const DatabaseClient = buildClient(); const defaultOptions = { - databaseURL: - "https://tailspin-s-workspace-g57gt8.ap-southeast-2.xata.sh/db/Tailspin", + databaseURL: + "https://tailspin-s-workspace-g57gt8.ap-southeast-2.xata.sh/db/Tailspin", }; export class XataClient extends DatabaseClient { - constructor(options?: BaseClientOptions) { - super({ ...defaultOptions, ...options }, tables); - } + constructor(options?: BaseClientOptions) { + super({ ...defaultOptions, ...options }, tables); + } } let instance: XataClient | undefined = undefined; export const getXataClient = () => { - if (instance) return instance; + if (instance) return instance; - instance = new XataClient(); - return instance; + instance = new XataClient(); + return instance; };