diff options
Diffstat (limited to 'app/src')
-rw-r--r-- | app/src/app.d.ts | 3 | ||||
-rw-r--r-- | app/src/hooks.server.ts | 26 | ||||
-rw-r--r-- | app/src/lib/common/assignments.ts | 32 | ||||
-rw-r--r-- | app/src/lib/server/assignments.ts | 106 | ||||
-rw-r--r-- | app/src/lib/server/s3.ts | 16 | ||||
-rw-r--r-- | app/src/lib/server/sessions.ts | 4 | ||||
-rw-r--r-- | app/src/routes/assignments/[assignmentId]/+page.server.ts | 30 | ||||
-rw-r--r-- | app/src/routes/assignments/[assignmentId]/+page.svelte | 30 |
8 files changed, 212 insertions, 35 deletions
diff --git a/app/src/app.d.ts b/app/src/app.d.ts index 7844a85..b7bd0e7 100644 --- a/app/src/app.d.ts +++ b/app/src/app.d.ts @@ -1,4 +1,5 @@ import type { User } from "$lib/server/users"; +import type { S3Client } from "@aws-sdk/client-s3"; import type { PoolClient } from "pg"; // See https://svelte.dev/docs/kit/types#app.d.ts @@ -9,6 +10,8 @@ declare global { interface Locals { dbConn: PoolClient; + s3Client: S3Client; + /** * The user, if they are logged in. * diff --git a/app/src/hooks.server.ts b/app/src/hooks.server.ts index 79535f9..8439eb3 100644 --- a/app/src/hooks.server.ts +++ b/app/src/hooks.server.ts @@ -1,16 +1,30 @@ import { getDbConnection } from "$lib/server/db"; +import { getS3Client } from "$lib/server/s3"; import { validateSessionToken } from "$lib/server/sessions"; -import { redirect, type Handle } from "@sveltejs/kit"; +import { type Handle } from "@sveltejs/kit"; import { sequence } from "@sveltejs/kit/hooks"; const dbHandle = (async ({ event, resolve }) => { const dbConn = await getDbConnection(); - event.locals = { dbConn }; + event.locals.dbConn = dbConn; - const response = await resolve(event); - dbConn.release(); + try { + return await resolve(event); + } finally { + dbConn.release(); + } +}) satisfies Handle; - return response; +// FIXME: Kind of stupid to load for every request. Should probs move handler to $lib and import for relevant routes. +const s3Handle = (async ({ event, resolve }) => { + const s3Client = getS3Client(); + event.locals.s3Client = s3Client; + + try { + return await resolve(event); + } finally { + s3Client.destroy(); + } }) satisfies Handle; const sessionHandle = (async ({ event, resolve }) => { @@ -25,4 +39,4 @@ const sessionHandle = (async ({ event, resolve }) => { return resolve(event); }) satisfies Handle; -export const handle = sequence(dbHandle, sessionHandle); +export const handle = sequence(dbHandle, s3Handle, sessionHandle); diff --git a/app/src/lib/common/assignments.ts b/app/src/lib/common/assignments.ts new file mode 100644 index 0000000..79553cd --- /dev/null +++ b/app/src/lib/common/assignments.ts @@ -0,0 +1,32 @@ +/** A row from the `cemetary_plot` table. */ +export interface CemetaryPlot { + id: number; + address: string; + ownerId: number; + //assignmentInterval: Date; +} + +/** Corresponds to `assignment_state`. */ +export type AssignmentState = "AWAITING_GARDENER_NOTIFICATION" + | "AWAITING_FINISH" + | "AWAITING_WATERMARKING" + | "AWAITING_OWNER_NOTIFICATION" + | "DONE" + ; + +/** A row from the `assignments` table. */ +export interface Assignment { + id: number; + gardenerId: number; + cemetaryPlotId: number; + date: Date; + state: AssignmentState; +} + + +// FIXME: We have ORM at home. A more clearly defined (OOP) model layer. +/** Checks whether the state of the assignment allows it to be "finished". */ +export function canBeFinished(assignment: Assignment): boolean { + return (assignment.state === "AWAITING_GARDENER_NOTIFICATION" || + assignment.state === "AWAITING_FINISH") +} diff --git a/app/src/lib/server/assignments.ts b/app/src/lib/server/assignments.ts index 7622408..787865c 100644 --- a/app/src/lib/server/assignments.ts +++ b/app/src/lib/server/assignments.ts @@ -1,20 +1,6 @@ -import type pg from "pg"; - -/** A row from the `cemetary_plot` table. */ -export interface CemetaryPlot { - id: number; - address: string; - ownerId: number; - //assignmentInterval: Date; -} - -/** A row from the `assignments` table. */ -export interface Assignment { - id: number; - gardenerId: number; - cemetaryPlotId: number; - date: Date; -} +import { PutObjectCommand, type S3Client } from "@aws-sdk/client-s3"; /*=;* / +import type { ClientBase } from "pg"; +import type { CemetaryPlot, Assignment, AssignmentState } from "../common/assignments"; /** * Retrieves all assignments for the given user. @@ -22,7 +8,7 @@ export interface Assignment { * @param dbConn Connection to database. * @param userId ID used to identify user. */ -export async function getAssignments(dbConn: pg.ClientBase, userId: number): Promise<Assignment[]> { +export async function getAssignments(dbConn: ClientBase, userId: number): Promise<Assignment[]> { const result = await dbConn.query( "SELECT * FROM assignments WHERE gardener_id = $1 ORDER BY date", [userId], @@ -34,6 +20,7 @@ export async function getAssignments(dbConn: pg.ClientBase, userId: number): Pro gardenerId: r.gardener_id, cemetaryPlotId: r.cemetary_plot_id, date: r.date, + state: r.state, }) satisfies Assignment, ); } @@ -46,7 +33,7 @@ type GetAssignmentResult = * Retrieves a specfic assignment, along with relevant cemetary plot. */ export async function getAssignmentAndCemetaryById( - dbConn: pg.ClientBase, + dbConn: ClientBase, assignmentId: number, ): Promise<GetAssignmentResult> { const queryText = `SELECT @@ -54,6 +41,7 @@ export async function getAssignmentAndCemetaryById( a.gardener_id, a.cemetary_plot_id, a.date, + a.state, c.id, c.address, c.owner_id, @@ -73,12 +61,84 @@ export async function getAssignmentAndCemetaryById( gardenerId: result.rows[0][1], cemetaryPlotId: result.rows[0][2], date: result.rows[0][3], + state: result.rows[0][4], }; const cemetaryPlot: CemetaryPlot = { - id: result.rows[0][4], - address: result.rows[0][5], - ownerId: result.rows[0][6], - //assignmentInterval: result.rows[0][7], + id: result.rows[0][5], + address: result.rows[0][6], + ownerId: result.rows[0][7], + //assignmentInterval: result.rows[0][8], }; return { assignment, cemetaryPlot }; } + +export interface FinishAssignmentArgs { + images: { bytes: Uint8Array; name: string }[]; + note?: string; + assignmentId: number; +} + +// TODO: Error recovery. +export async function finishAssignment( + dbConn: ClientBase, + s3Client: S3Client, + { images, note, assignmentId }: FinishAssignmentArgs, +): Promise<void> { + // Upload to S3, returning path + // FIXME: Should be factored out? + const uploadPromises = images.map(async (image) => { + const key = generateImageKey(); + + const cmd = new PutObjectCommand({ + Bucket: "images", + Key: key, + Body: image.bytes, + IfNoneMatch: "*", // Error, in case of key collision. + }); + + await s3Client.send(cmd); + return { ...image, key }; + }); + const uploadedImages = await Promise.all(uploadPromises); + + // TODO: Add beanstalkd job + + await dbConn.query("BEGIN"); + + try { + // Create 'images' row for each image. + // FIXME: Apparently node-pg doesn't have an equivalent to Python's `insert_many`?? + for (const image of uploadedImages) { + dbConn.query({ + name: "insert-assignment-image", + text: "INSERT INTO images(s3_path, original_filename, assignment_id) VALUES ($1, $2, $3)", + values: [image.key, image.name, assignmentId], + }); + } + + // Update the assingment's state. + dbConn.query( + `UPDATE + assignments + SET + state = 'AWAITING_WATERMARKING' :: assignment_state, + note = $1 + WHERE + id = $2`, + [note, assignmentId], + ); + + dbConn.query("COMMIT"); + } catch (err) { + // We should probably try to delete S3 objects. + + // We should probably try to delete the job. + + await dbConn.query("ROLLBACK"); + throw err; + } +} + +function generateImageKey(): string { + return crypto.randomUUID(); +} diff --git a/app/src/lib/server/s3.ts b/app/src/lib/server/s3.ts new file mode 100644 index 0000000..a1a28fa --- /dev/null +++ b/app/src/lib/server/s3.ts @@ -0,0 +1,16 @@ +import { S3Client } from "@aws-sdk/client-s3"; + +// We would obviously read from .env in prod, but it's an annoying indirection for this demo. +export function getS3Client(): S3Client { + const client = new S3Client({ + endpoint: "http://localhost:9000", + region: "us-east-1", // Required, but ignored for local usage. + credentials: { + accessKeyId: "minioadmin", + secretAccessKey: "minioadmin", + }, + forcePathStyle: true, // Required for local service since we obv. can't use subdomains. + }); + + return client; +} diff --git a/app/src/lib/server/sessions.ts b/app/src/lib/server/sessions.ts index 0ca46e0..69a8b46 100644 --- a/app/src/lib/server/sessions.ts +++ b/app/src/lib/server/sessions.ts @@ -88,7 +88,7 @@ export async function validateSessionToken( WHERE token = $1;`, [token], ); - console.debug(result); + //console.debug(result); if (result.rowCount === 0) { return { session: null, user: null }; } @@ -105,7 +105,7 @@ export async function validateSessionToken( lastName: result.rows[0].last_name, role: result.rows[0].role, }; - console.debug("Session with token %s: %o, %o", token, session, user); + //console.debug("Session with token %s: %o, %o", token, session, user); // Step 2. const now = Date.now(); diff --git a/app/src/routes/assignments/[assignmentId]/+page.server.ts b/app/src/routes/assignments/[assignmentId]/+page.server.ts index 566dcd9..280c5bd 100644 --- a/app/src/routes/assignments/[assignmentId]/+page.server.ts +++ b/app/src/routes/assignments/[assignmentId]/+page.server.ts @@ -1,5 +1,5 @@ -import { getAssignmentAndCemetaryById } from "$lib/server/assignments"; -import type { PageServerLoad } from "./$types"; +import { finishAssignment, getAssignmentAndCemetaryById } from "$lib/server/assignments"; +import type { PageServerLoad, Actions } from "./$types"; import { error, redirect } from "@sveltejs/kit"; export const load = (async ({ params, url, locals }) => { @@ -25,3 +25,29 @@ export const load = (async ({ params, url, locals }) => { cemetaryPlot, }; }) satisfies PageServerLoad; + +export const actions = { + // FIXME: Skipped input validation. + // FIXME: Is 'load' action run (wrt. authentication)? + finish: async ({ params, request, locals }) => { + const formData = await request.formData(); + const imageFiles = formData.getAll("images") as File[]; + const note = (formData.get("note") as string | null) ?? undefined; + + // Read image files in parallel. + const images = await Promise.all( + imageFiles.map(async (f) => ({ + name: f.name, + bytes: new Uint8Array(await f.arrayBuffer()), + })), + ); + + await finishAssignment(locals.dbConn, locals.s3Client, { + images, + note, + assignmentId: +params.assignmentId, // We have parsing at home... + }); + + return { success: true }; + }, +} satisfies Actions; diff --git a/app/src/routes/assignments/[assignmentId]/+page.svelte b/app/src/routes/assignments/[assignmentId]/+page.svelte index 0b36a19..9cf9890 100644 --- a/app/src/routes/assignments/[assignmentId]/+page.svelte +++ b/app/src/routes/assignments/[assignmentId]/+page.svelte @@ -1,5 +1,7 @@ <script lang="ts"> + import { enhance } from "$app/forms"; import type { PageProps } from "./$types"; + import { canBeFinished } from "$lib/common/assignments"; let { data }: PageProps = $props(); @@ -12,14 +14,38 @@ <h1>Assignment #{data.assignment.id}</h1> <p> - Gravstedet, der skal vedligeholdes er ved <span class="address">{data.cemetaryPlot.address}</span - >. + Gravstedet, der skal vedligeholdes er ved + <span class="address">{data.cemetaryPlot.address}</span>. </p> <p>Vedligeholdelsen skal finde sted <time>{assignmentDate}<time>.</time></time></p> +<p>State: {data.assignment.state}</p> + +<h2>Færdiggør opgave</h2> +{#if canBeFinished(data.assignment)} + <p>Når du har færdiggjort opgaven, kan du uploade billederne her:</p> + <form method="POST" action="?/finish" enctype="multipart/form-data" use:enhance> + <label> + Billeder: + <input type="file" name="images" accept=".jpg, .jpeg, .png, .webp" required multiple /> + </label> + <label> + Ekstra bemærkninger: + <textarea name="notes" placeholder="F.eks.: Vi løb tør for roser (?). De kommer i overmorgen :)" + ></textarea> + </label> + <button>Færddigør job</button> + </form> +{:else} + <p>Du har færddigjort denne opgave!</p> +{/if} <style> .address, time { font-weight: bold; } + + label { + display: block; + } </style> |