diff --git a/.xata/migrations/.ledger b/.xata/migrations/.ledger index cbb3b54..d4aaeb1 100644 --- a/.xata/migrations/.ledger +++ b/.xata/migrations/.ledger @@ -4,3 +4,14 @@ mig_ckeabvjgnj3bm0ispvgg_1ce47743 mig_ckevkeengvghfvu4alkg_ddcffced mig_ckevkhungvghfvu4alsg_742fbcbb mig_ckevkpungvghfvu4am50_1511cbec +mig_clhe670vdfloffdfg9s0_4744dcb2 +mig_clhe6b6r7je6m59gktsg_fd744927 +mig_clhe6cur7je6m59gkttg_107f96be +mig_clhe6g8vdfloffdfg9t0_8f3eb990 +mig_clhe6l6r7je6m59gktug_5ae5be9b +mig_clhe6s6r7je6m59gktvg_1c6c9cf0 +mig_cll7guf3d17cd4sn3q50_f99b28e0 +mig_cll7h3v3d17cd4sn3q60_deca122c +mig_cll7kiv3d17cd4sn3qdg_1d8d01c9 +mig_cll7kp2ifsth3gbude0g_4d9ec4f1 +mig_cll7ksaifsth3gbude2g_4f044676 diff --git a/.xata/migrations/mig_clhe670vdfloffdfg9s0_4744dcb2.json b/.xata/migrations/mig_clhe670vdfloffdfg9s0_4744dcb2.json new file mode 100644 index 0000000..95efa3e --- /dev/null +++ b/.xata/migrations/mig_clhe670vdfloffdfg9s0_4744dcb2.json @@ -0,0 +1,12 @@ +{ + "id": "mig_clhe670vdfloffdfg9s0", + "parentID": "mig_ckevkpungvghfvu4am50", + "checksum": "1:4744dcb2d75e9cba5183e24a6a49447ea0282ba342d7a4fe86afb0dafefc0f79", + "operations": [ + { + "addTable": { + "table": "Backlog-Score" + } + } + ] +} diff --git a/.xata/migrations/mig_clhe6b6r7je6m59gktsg_fd744927.json b/.xata/migrations/mig_clhe6b6r7je6m59gktsg_fd744927.json new file mode 100644 index 0000000..0be8141 --- /dev/null +++ b/.xata/migrations/mig_clhe6b6r7je6m59gktsg_fd744927.json @@ -0,0 +1,16 @@ +{ + "id": "mig_clhe6b6r7je6m59gktsg", + "parentID": "mig_clhe670vdfloffdfg9s0", + "checksum": "1:fd7449272458d918d7cf0f07f08e98650176ae39f2847539e63cfb43b996ba24", + "operations": [ + { + "addColumn": { + "column": { + "name": "code", + "type": "string" + }, + "table": "Backlog-Score" + } + } + ] +} diff --git a/.xata/migrations/mig_clhe6cur7je6m59gkttg_107f96be.json b/.xata/migrations/mig_clhe6cur7je6m59gkttg_107f96be.json new file mode 100644 index 0000000..a04eb05 --- /dev/null +++ b/.xata/migrations/mig_clhe6cur7je6m59gkttg_107f96be.json @@ -0,0 +1,16 @@ +{ + "id": "mig_clhe6cur7je6m59gkttg", + "parentID": "mig_clhe6b6r7je6m59gktsg", + "checksum": "1:107f96be15c59f116769877a3ede887da639354f69e3dfa5e3080aa4f35f1908", + "operations": [ + { + "addColumn": { + "column": { + "name": "email", + "type": "string" + }, + "table": "Backlog-Score" + } + } + ] +} diff --git a/.xata/migrations/mig_clhe6g8vdfloffdfg9t0_8f3eb990.json b/.xata/migrations/mig_clhe6g8vdfloffdfg9t0_8f3eb990.json new file mode 100644 index 0000000..64a730d --- /dev/null +++ b/.xata/migrations/mig_clhe6g8vdfloffdfg9t0_8f3eb990.json @@ -0,0 +1,17 @@ +{ + "id": "mig_clhe6g8vdfloffdfg9t0", + "parentID": "mig_clhe6cur7je6m59gkttg", + "checksum": "1:8f3eb990da0dfd0ca576d59a6aaeba6f55e0d00709bd250e7f208f44dfbdabab", + "operations": [ + { + "addColumn": { + "column": { + "name": "retry", + "type": "int", + "defaultValue": "0" + }, + "table": "Backlog-Score" + } + } + ] +} diff --git a/.xata/migrations/mig_clhe6l6r7je6m59gktug_5ae5be9b.json b/.xata/migrations/mig_clhe6l6r7je6m59gktug_5ae5be9b.json new file mode 100644 index 0000000..88d52e8 --- /dev/null +++ b/.xata/migrations/mig_clhe6l6r7je6m59gktug_5ae5be9b.json @@ -0,0 +1,14 @@ +{ + "id": "mig_clhe6l6r7je6m59gktug", + "parentID": "mig_clhe6g8vdfloffdfg9t0", + "checksum": "1:5ae5be9b8b8d79df03cd1c6d5b6f3dd47d5ae1926ed3f526dc10456a5f2c1cc9", + "operations": [ + { + "renameColumn": { + "newName": "retry_count", + "oldName": "retry", + "table": "Backlog-Score" + } + } + ] +} diff --git a/.xata/migrations/mig_clhe6s6r7je6m59gktvg_1c6c9cf0.json b/.xata/migrations/mig_clhe6s6r7je6m59gktvg_1c6c9cf0.json new file mode 100644 index 0000000..9005063 --- /dev/null +++ b/.xata/migrations/mig_clhe6s6r7je6m59gktvg_1c6c9cf0.json @@ -0,0 +1,16 @@ +{ + "id": "mig_clhe6s6r7je6m59gktvg", + "parentID": "mig_clhe6l6r7je6m59gktug", + "checksum": "1:1c6c9cf0790fe6efe3454c3edc25cc228681f0520c0cc2a233ba7515adad5929", + "operations": [ + { + "addColumn": { + "column": { + "name": "challenge", + "type": "string" + }, + "table": "Backlog-Score" + } + } + ] +} diff --git a/.xata/migrations/mig_cll7guf3d17cd4sn3q50_f99b28e0.json b/.xata/migrations/mig_cll7guf3d17cd4sn3q50_f99b28e0.json new file mode 100644 index 0000000..ea53d22 --- /dev/null +++ b/.xata/migrations/mig_cll7guf3d17cd4sn3q50_f99b28e0.json @@ -0,0 +1,12 @@ +{ + "id": "mig_cll7guf3d17cd4sn3q50", + "parentID": "mig_clhe6s6r7je6m59gktvg", + "checksum": "1:f99b28e07a41df81e9f84ed761484daf31c3820f0fb251d12c037af42107346a", + "operations": [ + { + "addTable": { + "table": "EmailSubmitRateLimiting" + } + } + ] +} diff --git a/.xata/migrations/mig_cll7h3v3d17cd4sn3q60_deca122c.json b/.xata/migrations/mig_cll7h3v3d17cd4sn3q60_deca122c.json new file mode 100644 index 0000000..11f49e9 --- /dev/null +++ b/.xata/migrations/mig_cll7h3v3d17cd4sn3q60_deca122c.json @@ -0,0 +1,17 @@ +{ + "id": "mig_cll7h3v3d17cd4sn3q60", + "parentID": "mig_cll7guf3d17cd4sn3q50", + "checksum": "1:deca122c44d63a03805dd49378cb9b9fc470d2826fab6f9f5c018191bd139023", + "operations": [ + { + "addColumn": { + "column": { + "name": "email", + "type": "string", + "unique": true + }, + "table": "EmailSubmitRateLimiting" + } + } + ] +} diff --git a/.xata/migrations/mig_cll7kiv3d17cd4sn3qdg_1d8d01c9.json b/.xata/migrations/mig_cll7kiv3d17cd4sn3qdg_1d8d01c9.json new file mode 100644 index 0000000..01527a7 --- /dev/null +++ b/.xata/migrations/mig_cll7kiv3d17cd4sn3qdg_1d8d01c9.json @@ -0,0 +1,16 @@ +{ + "id": "mig_cll7kiv3d17cd4sn3qdg", + "parentID": "mig_cll7h3v3d17cd4sn3q60", + "checksum": "1:1d8d01c975b06096e6ceba8841f904dbe4158868fdca69371272e619f5c6db09", + "operations": [ + { + "addColumn": { + "column": { + "name": "lastSubmission", + "type": "datetime" + }, + "table": "EmailSubmitRateLimiting" + } + } + ] +} diff --git a/.xata/migrations/mig_cll7kp2ifsth3gbude0g_4d9ec4f1.json b/.xata/migrations/mig_cll7kp2ifsth3gbude0g_4d9ec4f1.json new file mode 100644 index 0000000..d1261b9 --- /dev/null +++ b/.xata/migrations/mig_cll7kp2ifsth3gbude0g_4d9ec4f1.json @@ -0,0 +1,13 @@ +{ + "id": "mig_cll7kp2ifsth3gbude0g", + "parentID": "mig_cll7kiv3d17cd4sn3qdg", + "checksum": "1:4d9ec4f1bc9566499f066e2d50b4f4b2403d7fbd52b38c6a7cfbbdec56ca4e01", + "operations": [ + { + "removeColumn": { + "column": "lastSubmission", + "table": "EmailSubmitRateLimiting" + } + } + ] +} diff --git a/.xata/migrations/mig_cll7ksaifsth3gbude2g_4f044676.json b/.xata/migrations/mig_cll7ksaifsth3gbude2g_4f044676.json new file mode 100644 index 0000000..f1c8d4c --- /dev/null +++ b/.xata/migrations/mig_cll7ksaifsth3gbude2g_4f044676.json @@ -0,0 +1,17 @@ +{ + "id": "mig_cll7ksaifsth3gbude2g", + "parentID": "mig_cll7kp2ifsth3gbude0g", + "checksum": "1:4f0446762ff26866fb83e1415120923426212e10433b786ea582050c42f8811c", + "operations": [ + { + "addColumn": { + "column": { + "name": "lastSubmission", + "type": "datetime", + "defaultValue": "now" + }, + "table": "EmailSubmitRateLimiting" + } + } + ] +} diff --git a/app/api/code/submit/route.ts b/app/api/code/submit/route.ts index 0e38706..3d30ed6 100644 --- a/app/api/code/submit/route.ts +++ b/app/api/code/submit/route.ts @@ -35,6 +35,7 @@ On a successful OpenAI call, call the email generator (api) export async function POST(req: Request) { try { const body = await req.json(); + const { code } = body; if (!validateHTML(code)) { return NextResponse.json( @@ -44,6 +45,35 @@ export async function POST(req: Request) { }, { status: 400 } ); + + const { email } = body; + const record = await xata.db.EmailSubmitRateLimiting.filter({ + email: email, + }).getFirst(); + if (record) { + if (record.lastSubmission && process.env.IS_PRODUCTION === "true") { + //Time between submissions has to be at least 1 minute + const lastSubmission = new Date(record.lastSubmission); + const currentTime = new Date(); + if ( + currentTime.getTime() - lastSubmission.getTime() < + 60 * 1000 * 1 + ) { + return NextResponse.json( + { message: "Time between submissions too small!" }, + { status: 400 } + ); + } else { + await xata.db.EmailSubmitRateLimiting.update(record.id, { + lastSubmission: new Date().toISOString(), + }); + } + } + } else { + await xata.db.EmailSubmitRateLimiting.create({ + email: email, + lastSubmission: new Date().toISOString(), + }); } // Extract the host and protocol from the incoming request const url = new URL(req.url); diff --git a/client-side-queries/rq-queries/code-submit.ts b/client-side-queries/rq-queries/code-submit.ts index ae404ae..feb13cb 100644 --- a/client-side-queries/rq-queries/code-submit.ts +++ b/client-side-queries/rq-queries/code-submit.ts @@ -13,19 +13,22 @@ export function getCodeSubmitCount() { return resp; } -interface UsePostSubmitCountProps { +interface UseCodeSubmissionProps { code: string; dateTime: string; email: string; challenge: string; } -export async function postSubmitCount({ +export async function codeSubmission({ code, dateTime, email, challenge, -}: UsePostSubmitCountProps) { +}: UseCodeSubmissionProps) { + if (!code || !dateTime || !email || !challenge) { + throw new Error("Something wasn't passed into codeSubmission."); + } const resp = await axios.post("/api/code/submit", { code: code, dateTime: dateTime, diff --git a/components/core/code-area-actions/submit-button.tsx b/components/core/code-area-actions/submit-button.tsx index ff943c8..ed0354c 100644 --- a/components/core/code-area-actions/submit-button.tsx +++ b/components/core/code-area-actions/submit-button.tsx @@ -14,9 +14,10 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { useResetFeState } from "@/lib/reset-fe-state"; -import { postSubmitCount } from "@/client-side-queries/rq-queries/code-submit"; +import { codeSubmission } from "@/client-side-queries/rq-queries/code-submit"; import useSessionStore from "@/data-store/session-store"; import { useStepperStore } from "@/data-store/stepper-store"; +import { loadFromLocalStorage } from "@/lib/localStorage"; const smallProps: ConfettiProps = { force: 0.6, @@ -63,10 +64,20 @@ const SubmitButton = () => { const handleSubmitButtonClick = async () => { try { const dateTime = getCurrentDateTime(); - await postSubmitCount({ code, dateTime, email, challenge }); + + const submitEmail = email ? email : loadFromLocalStorage("email"); + const submitChallenge = challenge + ? challenge + : loadFromLocalStorage("challenge"); + await codeSubmission({ + code, + dateTime, + email: submitEmail, + challenge: submitChallenge, + }); setSubmitClicked(true); - } catch (error) { - alert("Something went wrong. Try submitting again!"); + } catch (error: any) { + alert(error.response.data.message); } }; diff --git a/components/core/editor.tsx b/components/core/editor.tsx index 9a87a28..79c3aa0 100644 --- a/components/core/editor.tsx +++ b/components/core/editor.tsx @@ -10,6 +10,7 @@ import { Button } from "../ui/button"; import StaticPrompt from "./target-image"; import LandingPageChallengeCode from "../landing/test-challenges/challenge-code"; import { useStepperStore } from "@/data-store/stepper-store"; +import { loadFromLocalStorage, saveToLocalStorage } from "@/lib/localStorage"; const Editor = () => { const { code, setCode } = useSessionStore(); @@ -39,10 +40,19 @@ const Editor = () => { }; loadAce(); + const localStorageCode = loadFromLocalStorage("code"); + if (code.length === 0 && localStorageCode.length > 0) { + setCode(localStorageCode); + } }, []); if (!AceEditor) return ; + const onType = (newCode: string) => { + setCode(newCode); + saveToLocalStorage("code", code); + }; + return (
@@ -71,7 +81,7 @@ const Editor = () => { theme={aceEditorTheme} name='editor' height='100%' - onChange={(newCode: string) => setCode(newCode)} + onChange={(newCode: string) => onType(newCode)} fontSize={fontSize} showPrintMargin={true} showGutter={true} diff --git a/components/ui/useCode/StepTwo.tsx b/components/ui/useCode/StepTwo.tsx index db053c4..67cb6e9 100644 --- a/components/ui/useCode/StepTwo.tsx +++ b/components/ui/useCode/StepTwo.tsx @@ -13,6 +13,7 @@ import { useRouter } from "next/navigation"; import { ChallengeFormField } from "./Challenge-FormField"; import { Loader2 } from "lucide-react"; import { challengeEnum } from "@/data-store/challenge-store"; +import { saveToLocalStorage } from "@/lib/localStorage"; const formStepTwoSchema = z.object({ challenge: z.nativeEnum(challengeEnum, { @@ -31,7 +32,7 @@ const formStepTwoSchema = z.object({ }); export function StepTwo() { - const { progress, setProgress, step, setStep, setChallenge } = + const { progress, setProgress, step, setStep, setChallenge, email } = useStepperStore(); const { setCode } = useSessionStore(); @@ -47,7 +48,8 @@ export function StepTwo() { setCode(LandingPageCode()); setChallenge(selection); setProgress(progress + progressIncrements); - + saveToLocalStorage("email", email); + saveToLocalStorage("challenge", selection); router.push("/code-area"); } } diff --git a/lib/localStorage.ts b/lib/localStorage.ts new file mode 100644 index 0000000..bf74363 --- /dev/null +++ b/lib/localStorage.ts @@ -0,0 +1,26 @@ +export const saveToLocalStorage = (key: string, value: string) => { + try { + localStorage.setItem(key, value); + } catch (error) { + console.error("Error saving to localStorage", error); + } +}; + +export const loadFromLocalStorage = (key: string) => { + try { + const value = localStorage.getItem(key); + if (value === null) { + return ""; + } + return value; + } catch (error) { + console.error("Error reading from localStorage", error); + return ""; + } +}; + +export const removeItemFromLocalStorage = (key: string) => { + if (typeof window !== "undefined") { + localStorage.removeItem(key); + } +}; diff --git a/lib/reset-fe-state.tsx b/lib/reset-fe-state.tsx index 0ee66d3..433fb84 100644 --- a/lib/reset-fe-state.tsx +++ b/lib/reset-fe-state.tsx @@ -1,5 +1,6 @@ import useCodeAreaStore from "@/data-store/code-area-store"; import useSessionStore from "@/data-store/session-store"; +import { removeItemFromLocalStorage } from "./localStorage"; /* A custom hook that resets the coding area state. Clears the code from coding area and resets certain settings. @@ -11,5 +12,8 @@ export function useResetFeState() { return function performReset() { reset(); codeAreaReset(); + removeItemFromLocalStorage("email"); + removeItemFromLocalStorage("code"); + removeItemFromLocalStorage("challenge"); }; } diff --git a/lib/validate-user.ts b/lib/validate-user.ts index c7637e2..e87883a 100644 --- a/lib/validate-user.ts +++ b/lib/validate-user.ts @@ -2,6 +2,7 @@ Currently, it doesn't do anything, however it allows for more scalability if there are multiple different validations neccecary to authorize a user. */ import { maxProgress } from "@/data-store/stepper-store"; +import { loadFromLocalStorage } from "./localStorage"; export default function validateUser( validationType: string, @@ -12,13 +13,21 @@ export default function validateUser( ) { validationType = validationType.toLowerCase(); + /* + Email is only in local storage if you went through the proper initialization steps. If a user refreshes the /code-area, their STATE gets lost, but local storage doesn't. So, if the user refreshes their page, we'll just allow the STATE to get lost, but since this user has email in local storage, we know for a fact this user got to the /code-area via the proper path. + */ if (validationType === "code") { - return ( - email === "" || - check === false || - progress !== maxProgress || - challenge === "" - ); + const localStorageEmail = loadFromLocalStorage("email"); + if ( + (email === "" || + check === false || + progress !== maxProgress || + challenge === "") && + localStorageEmail?.length === 0 + ) { + return true; + } + return false; } else { throw new Error("Invalid Validation Type"); } diff --git a/xata.ts b/xata.ts index f2ab9ae..c4b758d 100644 --- a/xata.ts +++ b/xata.ts @@ -14,6 +14,22 @@ const tables = [ { 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" }, + ], + }, ] as const; export type SchemaTables = typeof tables; @@ -22,8 +38,17 @@ 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; + export type DatabaseSchema = { Site: SiteRecord; + "Backlog-Score": BacklogScoreRecord; + EmailSubmitRateLimiting: EmailSubmitRateLimitingRecord; }; const DatabaseClient = buildClient();