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.

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.
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.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.HeadObject that the file exists and is under the limit, compares the database's clock with the due date, and upserts the submission row.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.

Run in your terminal to get started locally.
-- 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.
due_at.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.
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.
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.
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.