summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'app/src')
-rw-r--r--app/src/app.d.ts3
-rw-r--r--app/src/hooks.server.ts26
-rw-r--r--app/src/lib/common/assignments.ts32
-rw-r--r--app/src/lib/server/assignments.ts106
-rw-r--r--app/src/lib/server/s3.ts16
-rw-r--r--app/src/lib/server/sessions.ts4
-rw-r--r--app/src/routes/assignments/[assignmentId]/+page.server.ts30
-rw-r--r--app/src/routes/assignments/[assignmentId]/+page.svelte30
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>