Nov 11, 20269 min read

Assignment Submission Backend for EdTech: Uploads, Deadlines and Duplicate Checks

Design the backend behind assignment submissions: direct uploads to private storage, deadline and size checks, resubmissions, background fingerprinting to flag identical files and short-lived links for teachers, built by hand on AWS or in TypeScript with Encore.

Illustration for Assignment Submission Backend for EdTech: Uploads, Deadlines and Duplicate Checks

Assignment submission looks like a file upload form, and the edge cases arrive in the last ten minutes before every deadline. Hundreds of students upload at once, some files are too large, some uploads finish after the deadline, students resubmit, and two students hand in the same PDF. Teachers then need to open and grade each file without it ever being publicly reachable, because submissions are student records.

This guide designs that backend: direct uploads to private storage, a confirm step that applies the deadline and size rules, resubmissions, background fingerprinting that flags identical files, short-lived links for teachers and a grading step that cannot grade a file the student has since replaced, first by hand on AWS and then in TypeScript with Encore.

The submission backend at a glance

JobHand-built on AWSWith Encore in your AWS account
Assignments and submissionsRDS for PostgreSQLEncore database on RDS
Student filesPrivate S3 bucket and presigned URLsEncore bucket with signed URLs
Background checksSQS queue and a Lambda or workerTopic and subscription (SNS and SQS)
Teacher accessPresigned GET URLs from an APIsignedDownloadUrl from an endpoint
Local developmentLocalStack or a shared dev bucketencore run with local storage

How do you build it by hand on AWS?

  1. Data: an assignments table with the due date and a size limit, and a submissions table with one row per student and assignment, the object key, a late flag, a hash and a status.
  2. Upload: the API returns a presigned PUT URL for a key under assignment/student/, valid for ten minutes, and the app uploads directly to S3. Uploads never pass through the API servers, which matters when a whole class submits in the same minute.
  3. Confirm: after the upload, the app calls the API, which checks with HeadObject that the file exists and is under the limit, compares the database's clock with the due date, and upserts the submission row.
  4. Background checks: the confirm step sends an SQS message, and a worker downloads the file, hashes it and flags matches within the assignment. A dead-letter queue collects files that cannot be read.
  5. Grading: teachers get presigned GET URLs that expire in minutes, generated only after the API checks they teach the course.

How do you build it with Encore?

Encore

Encore takes the database, bucket and queue that a TypeScript or Go service declares and provisions them in your own AWS or GCP account on deploy. For this backend that means RDS, a private S3 bucket and an SNS topic with an SQS queue on AWS, or Cloud SQL, Cloud Storage and Pub/Sub on GCP, as described in the AWS infrastructure docs.

Application code passes through Encore and infrastructure guardrails to resources provisioned in your own AWS or GCP account.
Encore provisions the infrastructure your application needs in your own AWS or GCP account.

Set up the app

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create classroom --example=ts/hello-world$ cd classroom && mkdir -p coursework/migrations
-- coursework/migrations/1_create_submissions.up.sql CREATE TABLE assignments ( id BIGSERIAL PRIMARY KEY, course_id TEXT NOT NULL, title TEXT NOT NULL, due_at TIMESTAMPTZ NOT NULL, max_bytes INTEGER NOT NULL DEFAULT 20971520 -- 20 MiB ); CREATE TABLE enrollments ( course_id TEXT NOT NULL, student_id TEXT NOT NULL, PRIMARY KEY (course_id, student_id) ); CREATE TABLE submissions ( id BIGSERIAL PRIMARY KEY, assignment_id BIGINT NOT NULL REFERENCES assignments(id), student_id TEXT NOT NULL, object_key TEXT NOT NULL, submitted_at TIMESTAMPTZ NOT NULL DEFAULT now(), late BOOLEAN NOT NULL, sha256 TEXT, status TEXT NOT NULL DEFAULT 'received', -- received, checked, flagged, graded grade NUMERIC(5,2), feedback TEXT, UNIQUE (assignment_id, student_id) );
// auth/auth.ts import { APIError, Gateway } from "encore.dev/api"; import { authHandler, Header } from "encore.dev/auth"; import { secret } from "encore.dev/config"; import { createHmac, timingSafeEqual } from "node:crypto"; const authTokenHMAC = secret("AuthTokenHMAC"); interface AuthParams { authorization: Header<"Authorization">; } export interface AuthData { userID: string; } export const handler = authHandler<AuthParams, AuthData>(async (params) => { const token = params.authorization?.replace("Bearer ", "") ?? ""; // Tokens are "<id>.<unix-expiry>.<hex hmac>". In production, verify a JWT // against your IdP JWKS (signature, issuer, audience, expiry) and return the subject. if (!authTokenHMAC()) throw APIError.unauthenticated("invalid token"); const parts = token.split("."); if (parts.length !== 3) throw APIError.unauthenticated("invalid token"); const [userID, expStr, sig] = parts; const exp = Number(expStr); if (!userID || userID.includes("/") || userID.includes(" ") || !Number.isFinite(exp) || exp * 1000 <= Date.now()) { throw APIError.unauthenticated("invalid token"); } const expected = createHmac("sha256", authTokenHMAC()).update(`${userID}.${expStr}`).digest("hex"); const a = Buffer.from(sig); const b = Buffer.from(expected); if (a.length !== b.length || !timingSafeEqual(a, b)) { throw APIError.unauthenticated("invalid token"); } return { userID }; }); export const gateway = new Gateway({ authHandler: handler });
// coursework/coursework.ts import { api, APIError } from "encore.dev/api"; import { Subscription, Topic } from "encore.dev/pubsub"; import { Bucket } from "encore.dev/storage/objects"; import { SQLDatabase } from "encore.dev/storage/sqldb"; import { getAuthData } from "~encore/auth"; import { createHash, randomUUID } from "node:crypto"; const db = new SQLDatabase("coursework", { migrations: "./migrations" }); // Private: students and teachers only ever get short-lived signed links. const submissions = new Bucket("submissions", { versioned: false }); interface SubmissionReceived { submissionId: number; assignmentId: number; objectKey: string; } const submissionReceived = new Topic<SubmissionReceived>("submission-received", { deliveryGuarantee: "at-least-once", }); async function enrolledAssignment(assignmentId: number, studentId: string) { const a = await db.queryRow<{ due_at: Date; max_bytes: number }>` SELECT a.due_at, a.max_bytes FROM assignments a JOIN enrollments e ON e.course_id = a.course_id WHERE a.id = ${assignmentId} AND e.student_id = ${studentId} `; if (!a) throw APIError.notFound("unknown assignment"); return a; } // Step 1: the app asks for an upload URL and PUTs the PDF straight to the bucket. export const uploadUrl = api( { expose: true, auth: true, method: "POST", path: "/assignments/:assignmentId/upload-url" }, async ({ assignmentId }: { assignmentId: number }): Promise<{ key: string; url: string }> => { const { userID: studentId } = getAuthData()!; await enrolledAssignment(assignmentId, studentId); const key = `${assignmentId}/${studentId}/${randomUUID()}.pdf`; const { url } = await submissions.signedUploadUrl(key, { ttl: 600 }); return { key, url }; }, ); interface SubmitParams { assignmentId: number; key: string; } // Step 2: the app confirms the upload. A retry of the same key is idempotent; // a new key is an explicit resubmission that replaces the previous file. export const submit = api( { expose: true, auth: true, method: "POST", path: "/assignments/:assignmentId/submit" }, async ({ assignmentId, key }: SubmitParams): Promise<{ late: boolean }> => { const { userID: studentId } = getAuthData()!; if (!key.startsWith(`${assignmentId}/${studentId}/`) || key.includes("..")) { throw APIError.permissionDenied("not your upload"); } const a = await enrolledAssignment(assignmentId, studentId); if (!(await submissions.exists(key))) throw APIError.failedPrecondition("file not uploaded"); const attrs = await submissions.attrs(key); if (attrs.size > a.max_bytes) throw APIError.invalidArgument("file too large"); const row = await db.queryRow<{ id: number; late: boolean; object_key: string; sha256: string | null }>` INSERT INTO submissions (assignment_id, student_id, object_key, late) VALUES (${assignmentId}, ${studentId}, ${key}, now() > ${a.due_at}) ON CONFLICT (assignment_id, student_id) DO UPDATE SET object_key = EXCLUDED.object_key, submitted_at = CASE WHEN submissions.object_key IS DISTINCT FROM EXCLUDED.object_key THEN now() ELSE submissions.submitted_at END, late = CASE WHEN submissions.object_key IS DISTINCT FROM EXCLUDED.object_key THEN EXCLUDED.late ELSE submissions.late END, sha256 = CASE WHEN submissions.object_key IS DISTINCT FROM EXCLUDED.object_key THEN NULL ELSE submissions.sha256 END, status = CASE WHEN submissions.object_key IS DISTINCT FROM EXCLUDED.object_key THEN 'received' ELSE submissions.status END, grade = CASE WHEN submissions.object_key IS DISTINCT FROM EXCLUDED.object_key THEN NULL ELSE submissions.grade END, feedback = CASE WHEN submissions.object_key IS DISTINCT FROM EXCLUDED.object_key THEN NULL ELSE submissions.feedback END RETURNING id, late, object_key, sha256 `; if (!row) throw APIError.internal("submission not stored"); // Same key with a fingerprint already recorded is a no-op. Same key // with no fingerprint means the earlier publish did not land, so retry it. if (row.object_key === key && row.sha256) { return { late: row.late }; } await submissionReceived.publish({ submissionId: row.id, assignmentId, objectKey: key }); return { late: row.late }; }, ); // Step 3, in the background: fingerprint the file and flag identical submissions. const _ = new Subscription(submissionReceived, "fingerprint", { retryPolicy: { minBackoff: "10s", maxBackoff: "5m", maxRetries: 5 }, handler: async (e) => { const data = await submissions.download(e.objectKey); const sha256 = createHash("sha256").update(data).digest("hex"); const dup = await db.queryRow<{ id: number }>` SELECT id FROM submissions WHERE assignment_id = ${e.assignmentId} AND sha256 = ${sha256} AND id <> ${e.submissionId} LIMIT 1 `; // Only update if this message is still about the current file for the submission. await db.exec` UPDATE submissions SET sha256 = ${sha256}, status = ${dup ? "flagged" : "checked"} WHERE id = ${e.submissionId} AND object_key = ${e.objectKey} `; }, });

The late flag comes from the database clock at confirm time, so neither the student's device nor the upload time decides it. A retry of the same object key returns the existing row and leaves any grade in place, and it publishes the fingerprint event again if the earlier publish did not land. An explicit resubmission with a new key replaces the file, clears the grade and resets the check, and the subscriber only writes its result if the key still matches, so a slow check of an older file cannot overwrite the newer one. The conflict update always returns the row, so two confirmations of the same key cannot crash on a missing RETURNING value.

Teachers then open a file through a short-lived signed download URL, and grading is a conditional update that only succeeds while the submission is still checked or flagged. A file replaced after the teacher opened it goes back to received and has to be graded again. The object storage docs cover signed URLs and upload preconditions, and the Pub/Sub docs cover the retry policy and dead letters.

Student identity comes from the auth handler, and both endpoints check that the caller is enrolled in the assignment's course, so one student cannot submit as another. A production app also checks that a teacher belongs to the course before returning a download link.

encore run starts Postgres locally, stores files on disk and runs the topic, so a full submit and fingerprint cycle can be traced in the local development dashboard. When a coding agent adds a new check, say a page count limit, it is another subscription on the same topic in a pull request, and its queue and permissions are created on the next deploy.

Best fit: EdTech teams who want student files in private storage in their own AWS or GCP account, with uploads, checks and grading links in one TypeScript service.

Things to decide early

  • Resubmission policy. A new upload key replaces the latest file and clears any grade. A retry of the same key does not. If teachers need every attempt, add an attempt number to the key and the unique constraint, or enable versioning on the bucket.
  • Extensions. Per-student due dates belong in a separate table that the confirm step checks before falling back to the assignment's due_at.
  • Retention. Decide how long submissions are kept after a course ends, and delete both the rows and the objects on that schedule.
  • Similarity beyond identical files. Hashes only catch byte-identical copies, so paraphrase detection needs a separate service called from another subscriber.

Frequently asked questions

How should students upload assignment files?

Have the backend return a short-lived signed upload URL for a key that includes the assignment and student IDs, let the app upload straight to a private bucket, then confirm the upload with a second call. The backend checks that the object exists, enforces the size limit and deadline, and records the submission.

How do I mark late submissions reliably?

Compare the database's now() with the assignment's due_at when the submission is confirmed, and store the result in a late column. Do not trust a timestamp from the client, and do not use the upload time from storage, since a student could start an upload before the deadline and confirm it hours later.

How can I detect students submitting the same file?

Compute a SHA-256 hash of each uploaded file in a background worker and compare it with other submissions for the same assignment. Identical hashes mean byte-for-byte identical files. That catches copied files cheaply, while detecting paraphrased work needs a separate similarity service.

How do I build an assignment submission backend in TypeScript with Encore?

Declare the database with SQLDatabase, a private Bucket for files with signedUploadUrl and signedDownloadUrl, and a Topic and Subscription that fingerprints each file after submission. Encore provisions the database, the bucket and the queue in your own AWS or GCP account and runs them locally with encore run.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders