diff --git a/README.md b/README.md new file mode 100644 index 0000000..4e67829 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# VBytes Attendance + +## Requirements +- Docker Engine (with Compose v2) + +## Setup +1. Copy the provided `.env` file (already checked in). Adjust if needed: + ```env + POSTGRES_PASSWORD=postgrespass123 + JWT_SECRET=supersecretjwtkey + ADMIN_USERNAME=admin + ADMIN_PASSWORD=AdminPass!234 + JWT_COOKIE_SECURE=false + ENABLE_HTTPS_REDIRECT=false + WEB_PORT=3000 + CSRF_ALLOWED_ORIGINS=http://192.168.68.61:3000 + ``` + - Change `JWT_SECRET` and `ADMIN_PASSWORD` before production use. + - Keep `JWT_COOKIE_SECURE=false` and `ENABLE_HTTPS_REDIRECT=false` unless you run behind HTTPS. + - Update `CSRF_ALLOWED_ORIGINS` to the host/port you’ll use to access the web app. + +2. Start the stack: + ```bash + docker compose up -d --build + ``` + +3. Open the web app at `http://:3000` using the admin credentials (`ADMIN_USERNAME`, `ADMIN_PASSWORD`). + +4. Stop the stack: + ```bash + docker compose down + ``` + To wipe Postgres data (e.g., after upgrading versions), also remove the volume: + ```bash + docker volume rm vbytes_postgres-data + ``` + +That’s it—the Compose file starts Postgres, the Rust API, and the SvelteKit frontend using the values from `.env`. diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..1cc93c0 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,21 @@ +FROM rustlang/rust:nightly as builder +WORKDIR /app + +# Cache dependencies +COPY Cargo.toml Cargo.lock ./ +RUN mkdir src && echo "fn main() {}" > src/main.rs +RUN cargo build --release + +# Build application +COPY . . +RUN cargo build --release + +FROM debian:bookworm-slim as runtime +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=builder /app/target/release/api /usr/local/bin/api +ENV ROCKET_ADDRESS=0.0.0.0 +ENV ROCKET_PORT=8080 +EXPOSE 8080 +CMD ["api"] diff --git a/api/src/models.rs b/api/src/models.rs index 9f6c4e8..4493ac1 100644 --- a/api/src/models.rs +++ b/api/src/models.rs @@ -32,7 +32,7 @@ pub struct LoginResponse { pub username: String, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Clone)] #[serde(crate = "rocket::serde")] pub struct PersonResponse { pub id: i32, @@ -59,13 +59,13 @@ impl From for PersonResponse { } } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Clone)] #[serde(crate = "rocket::serde")] pub struct PersonsResponse { pub persons: Vec, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Clone)] #[serde(crate = "rocket::serde")] pub struct PersonActionResponse { pub person: PersonResponse, diff --git a/package.json b/package.json new file mode 100644 index 0000000..0d890f3 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "devDependencies": { + "dotenv": "^17.2.2" + } +} diff --git a/web/package.json b/web/package.json index 702f879..9daf3e2 100644 --- a/web/package.json +++ b/web/package.json @@ -18,6 +18,7 @@ "@sveltejs/kit": "^2.22.0", "@sveltejs/vite-plugin-svelte": "^6.0.0", "@tailwindcss/vite": "^4.0.0", + "dotenv": "^16.4.5", "prettier": "^3.4.2", "prettier-plugin-svelte": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.11", diff --git a/web/src/lib/client/person-collection.ts b/web/src/lib/client/person-collection.ts new file mode 100644 index 0000000..4a5a25e --- /dev/null +++ b/web/src/lib/client/person-collection.ts @@ -0,0 +1,29 @@ +import type { Person } from '$lib/types'; + +type IncludeFn = (person: Person) => boolean; + +export function updateCollection( + current: Person[], + updated: Person, + shouldInclude: IncludeFn +): Person[] { + const include = shouldInclude(updated); + const index = current.findIndex((person) => person.id === updated.id); + + if (include) { + if (index >= 0) { + const next = current.slice(); + next[index] = updated; + return next; + } + return [updated, ...current]; + } + + if (index >= 0) { + const next = current.slice(); + next.splice(index, 1); + return next; + } + + return current; +} diff --git a/web/src/lib/client/person-events.ts b/web/src/lib/client/person-events.ts new file mode 100644 index 0000000..939a2ec --- /dev/null +++ b/web/src/lib/client/person-events.ts @@ -0,0 +1,40 @@ +import type { Person } from '$lib/types'; + +export type PersonEvent = { + person: Person; +}; + +export function listenToPersonEvents(onPerson: (person: Person) => void) { + let stopped = false; + let source: EventSource | null = null; + + function connect() { + if (stopped) return; + source = new EventSource('/api/events'); + source.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as PersonEvent; + if (data.person) { + onPerson(data.person); + } + } catch (err) { + console.error('Failed to parse person event', err); + } + }; + + source.onerror = () => { + source?.close(); + source = null; + if (stopped) return; + setTimeout(connect, 2000); + }; + } + + connect(); + + return () => { + stopped = true; + source?.close(); + source = null; + }; +} diff --git a/web/src/lib/server/backend.ts b/web/src/lib/server/backend.ts index 2d1a809..3b87d0d 100644 --- a/web/src/lib/server/backend.ts +++ b/web/src/lib/server/backend.ts @@ -30,9 +30,10 @@ export async function proxyRequest( raw?: () => Record; }; - const setCookies = typeof headerUtils.getSetCookie === 'function' - ? headerUtils.getSetCookie() - : headerUtils.raw?.()['set-cookie'] ?? []; + const setCookies = + typeof headerUtils.getSetCookie === 'function' + ? headerUtils.getSetCookie() + : (headerUtils.raw?.()['set-cookie'] ?? []); return { response, setCookies }; } diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 9c06423..bd80e7b 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -46,24 +46,34 @@
-
-
-

VBytes

-

Gästhantering

-
+
+
+
+

VBytes

+

Gästhantering

+
+ {#if ui.loggedIn} + + {/if} +
{#if ui.loggedIn} -
-
+ {/if}
- {#if ui.message} -

{ui.message}

- {/if} + {#if ui.message} +

{ui.message}

+ {/if}
{@render children?.()} diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 6169289..77352a0 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -2,6 +2,7 @@ import { goto } from '$app/navigation'; import type { Person } from '$lib/types'; import { onMount } from 'svelte'; + import { listenToPersonEvents } from '$lib/client/person-events'; let searchQuery = $state(''); let searchResults = $state([]); @@ -139,19 +140,27 @@ onMount(() => { void loadDefaultList(); + const stop = listenToPersonEvents((person) => { + updatePersonList(person); + }); + return () => { + stop(); + }; });

Checka in

-

Listan visar automatiskt alla som inte är incheckade. Sök för att begränsa listan.

+

+ Listan visar automatiskt alla som inte är incheckade. Sök för att begränsa listan. +

Ålder: {person.age} år

{#if person.under_ten} -

+

VARNING: Person under 10 år – kompletterande information krävs innan incheckning.

{/if} @@ -200,7 +218,7 @@ disabled={person.checked_in || searchLoading} class={`rounded-md px-4 py-2 text-sm font-semibold transition-colors ${ person.checked_in - ? 'bg-slate-200 text-slate-600 cursor-not-allowed' + ? 'cursor-not-allowed bg-slate-200 text-slate-600' : 'bg-emerald-600 text-white hover:bg-emerald-700' }`} > diff --git a/web/src/routes/api/events/+server.ts b/web/src/routes/api/events/+server.ts new file mode 100644 index 0000000..56ae8e1 --- /dev/null +++ b/web/src/routes/api/events/+server.ts @@ -0,0 +1,31 @@ +import type { RequestHandler } from './$types'; +import { API_BASE_URL } from '$lib/server/config'; + +export const GET: RequestHandler = async (event) => { + const url = new URL('/events', API_BASE_URL).toString(); + const headers = new Headers(); + + const cookie = event.request.headers.get('cookie'); + if (cookie) { + headers.set('cookie', cookie); + } + headers.set('accept', 'text/event-stream'); + + const response = await event.fetch(url, { + method: 'GET', + headers, + credentials: 'include' + }); + + const proxiedHeaders = new Headers(response.headers); + if (!proxiedHeaders.has('content-type')) { + proxiedHeaders.set('content-type', 'text/event-stream'); + } + proxiedHeaders.set('cache-control', 'no-cache'); + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: proxiedHeaders + }); +}; diff --git a/web/src/routes/api/persons/search/+server.ts b/web/src/routes/api/persons/search/+server.ts index 47e17aa..b979209 100644 --- a/web/src/routes/api/persons/search/+server.ts +++ b/web/src/routes/api/persons/search/+server.ts @@ -8,9 +8,13 @@ export const GET: RequestHandler = async (event) => { throw error(400, 'Söktext krävs.'); } - const { response, setCookies } = await proxyRequest(event, `/persons/search?q=${encodeURIComponent(query)}`, { - method: 'GET' - }); + const { response, setCookies } = await proxyRequest( + event, + `/persons/search?q=${encodeURIComponent(query)}`, + { + method: 'GET' + } + ); const headers = new Headers(); for (const cookie of setCookies) { diff --git a/web/src/routes/checked-in/+page.svelte b/web/src/routes/checked-in/+page.svelte index 86c40e3..bc96b90 100644 --- a/web/src/routes/checked-in/+page.svelte +++ b/web/src/routes/checked-in/+page.svelte @@ -2,6 +2,8 @@ import { goto } from '$app/navigation'; import type { Person } from '$lib/types'; import { onMount } from 'svelte'; + import { listenToPersonEvents } from '$lib/client/person-events'; + import { updateCollection } from '$lib/client/person-collection'; type StatusFilter = 'all' | 'inside' | 'outside'; type CheckedFilter = 'all' | 'checked-in' | 'not-checked-in'; @@ -23,6 +25,47 @@ return response; } + function matchesFilters(person: Person) { + if (checkedFilter === 'checked-in' && !person.checked_in) return false; + if (checkedFilter === 'not-checked-in' && person.checked_in) return false; + if (statusFilter === 'inside' && !person.inside) return false; + if (statusFilter === 'outside' && person.inside) return false; + if (searchQuery.trim()) { + const query = searchQuery.trim().toLowerCase(); + const matchesText = + person.name.toLowerCase().includes(query) || + person.phone_number.toLowerCase().includes(query) || + person.id.toString() === query; + if (!matchesText) return false; + } + return true; + } + + function applyFilteredList(list: Person[]) { + const filtered = list.filter((person) => { + if (statusFilter === 'inside' && !person.inside) return false; + if (statusFilter === 'outside' && person.inside) return false; + if (checkedFilter === 'checked-in' && !person.checked_in) return false; + if (checkedFilter === 'not-checked-in' && person.checked_in) return false; + return true; + }); + persons = filtered; + if (persons.length === 0) { + infoMessage = 'Inga personer matchar kriterierna.'; + } else { + infoMessage = ''; + } + } + + function handlePersonUpdate(person: Person) { + persons = updateCollection(persons, person, matchesFilters); + if (persons.length === 0) { + infoMessage = 'Inga personer matchar kriterierna.'; + } else { + infoMessage = ''; + } + } + async function fetchCheckedIn() { loading = true; errorMessage = ''; @@ -61,20 +104,8 @@ } const data = await response.json(); - let list: Person[] = data.persons ?? []; - - list = list.filter((person) => { - if (checkedFilter === 'checked-in' && !person.checked_in) return false; - if (checkedFilter === 'not-checked-in' && person.checked_in) return false; - if (statusFilter === 'inside' && !person.inside) return false; - if (statusFilter === 'outside' && person.inside) return false; - return true; - }); - - persons = list; - if (persons.length === 0) { - infoMessage = 'Inga personer matchar kriterierna.'; - } + const list: Person[] = data.persons ?? []; + applyFilteredList(list); } catch (err) { console.error('Fetch checked-in failed', err); errorMessage = 'Ett oväntat fel inträffade när listan skulle hämtas.'; @@ -99,6 +130,12 @@ onMount(() => { void fetchCheckedIn(); + const stop = listenToPersonEvents((person) => { + handlePersonUpdate(person); + }); + return () => { + stop(); + }; }); @@ -108,7 +145,8 @@

Översikt

- Visar personer med status, både incheckade och inte incheckade. Sök efter namn, telefonnummer eller id och filtrera på inne/ute. + Visar personer med status, både incheckade och inte incheckade. Sök efter namn, + telefonnummer eller id och filtrera på inne/ute.

@@ -128,7 +166,7 @@ id="checked-query" placeholder="Exempel: 42, Anna eller 0701234567" bind:value={searchQuery} - class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100" + class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none" />
@@ -137,7 +175,7 @@ id="status" bind:value={statusFilter} onchange={handleStatusChange} - class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100" + class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none" > @@ -145,12 +183,14 @@
- +

Ålder: {person.age} år

{#if person.under_ten} -

+

VARNING: Person under 10 år – kompletterande information krävs innan utcheckning.

{/if} @@ -206,7 +225,7 @@ class={`rounded-md px-4 py-2 text-sm font-semibold transition-colors ${ person.checked_in ? 'bg-red-600 text-white hover:bg-red-700' - : 'bg-slate-200 text-slate-600 cursor-not-allowed' + : 'cursor-not-allowed bg-slate-200 text-slate-600' }`} > Checka ut diff --git a/web/src/routes/create/+page.server.ts b/web/src/routes/create/+page.server.ts index 0454cd5..90398ad 100644 --- a/web/src/routes/create/+page.server.ts +++ b/web/src/routes/create/+page.server.ts @@ -1,92 +1,92 @@ import { fail, type Actions } from '@sveltejs/kit'; export const actions: Actions = { - default: async ({ request, fetch }) => { - const formData = await request.formData(); + default: async ({ request, fetch }) => { + const formData = await request.formData(); - const name = formData.get('name')?.toString().trim() ?? ''; - const ageRaw = formData.get('age')?.toString().trim() ?? ''; - const phone = formData.get('phone_number')?.toString().trim() ?? ''; - const manualId = formData.get('manual_id')?.toString().trim() ?? ''; - const checkedIn = formData.get('checked_in') === 'on'; - const inside = formData.get('inside') === 'on'; + const name = formData.get('name')?.toString().trim() ?? ''; + const ageRaw = formData.get('age')?.toString().trim() ?? ''; + const phone = formData.get('phone_number')?.toString().trim() ?? ''; + const manualId = formData.get('manual_id')?.toString().trim() ?? ''; + const checkedIn = formData.get('checked_in') === 'on'; + const inside = formData.get('inside') === 'on'; - const values = { - name, - age: ageRaw, - phone_number: phone, - manual_id: manualId, - checked_in: checkedIn, - inside - }; + const values = { + name, + age: ageRaw, + phone_number: phone, + manual_id: manualId, + checked_in: checkedIn, + inside + }; - if (!name) { - return fail(400, { - errors: { name: 'Ange ett namn.' }, - values - }); - } + if (!name) { + return fail(400, { + errors: { name: 'Ange ett namn.' }, + values + }); + } - const parsedAge = Number.parseInt(ageRaw, 10); - if (Number.isNaN(parsedAge) || parsedAge < 0) { - return fail(400, { - errors: { age: 'Ålder måste vara ett heltal större än eller lika med 0.' }, - values - }); - } + const parsedAge = Number.parseInt(ageRaw, 10); + if (Number.isNaN(parsedAge) || parsedAge < 0) { + return fail(400, { + errors: { age: 'Ålder måste vara ett heltal större än eller lika med 0.' }, + values + }); + } - if (!phone) { - return fail(400, { - errors: { phone_number: 'Ange ett telefonnummer.' }, - values - }); - } + if (!phone) { + return fail(400, { + errors: { phone_number: 'Ange ett telefonnummer.' }, + values + }); + } - const payload: Record = { - name, - age: parsedAge, - phone_number: phone, - checked_in: checkedIn, - inside - }; + const payload: Record = { + name, + age: parsedAge, + phone_number: phone, + checked_in: checkedIn, + inside + }; - if (manualId.length > 0) { - const parsedId = Number.parseInt(manualId, 10); - if (Number.isNaN(parsedId) || parsedId < 0) { - return fail(400, { - errors: { manual_id: 'ID måste vara ett positivt heltal om det anges.' }, - values - }); - } - payload.id = parsedId; - } + if (manualId.length > 0) { + const parsedId = Number.parseInt(manualId, 10); + if (Number.isNaN(parsedId) || parsedId < 0) { + return fail(400, { + errors: { manual_id: 'ID måste vara ett positivt heltal om det anges.' }, + values + }); + } + payload.id = parsedId; + } - const response = await fetch('/api/persons', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(payload) - }); + const response = await fetch('/api/persons', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload) + }); - const text = await response.text(); - if (!response.ok) { - let message = 'Kunde inte skapa personen.'; - try { - const body = JSON.parse(text); - message = body.message ?? message; - } catch { - if (text.trim().length > 0) { - message = text; - } - } + const text = await response.text(); + if (!response.ok) { + let message = 'Kunde inte skapa personen.'; + try { + const body = JSON.parse(text); + message = body.message ?? message; + } catch { + if (text.trim().length > 0) { + message = text; + } + } - return fail(response.status, { - errors: { general: message }, - values - }); - } + return fail(response.status, { + errors: { general: message }, + values + }); + } - return { - success: 'Personen har lagts till.' - }; - } + return { + success: 'Personen har lagts till.' + }; + } }; diff --git a/web/src/routes/create/+page.svelte b/web/src/routes/create/+page.svelte index d7bcd89..9aa1b4c 100644 --- a/web/src/routes/create/+page.svelte +++ b/web/src/routes/create/+page.svelte @@ -28,19 +28,21 @@ inside: false }; -const values = $derived({ - ...defaults, - ...(props.form?.values ?? {}) -} as FormValues); + const values = $derived({ + ...defaults, + ...(props.form?.values ?? {}) + } as FormValues); -const errors = $derived((props.form?.errors ?? {}) as FormErrors); -const success = $derived(props.form?.success ?? null); + const errors = $derived((props.form?.errors ?? {}) as FormErrors); + const success = $derived(props.form?.success ?? null);

Lägg till person

-

Fyll i uppgifterna nedan. Om ID lämnas tomt skapas ett automatiskt.

+

+ Fyll i uppgifterna nedan. Om ID lämnas tomt skapas ett automatiskt. +

@@ -50,7 +52,7 @@ const success = $derived(props.form?.success ?? null); name="name" value={values.name} required - class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100" + class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none" /> {#if errors.name}

{errors.name}

@@ -65,35 +67,39 @@ const success = $derived(props.form?.success ?? null); min="0" value={values.age} required - class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100" + class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none" /> {#if errors.age}

{errors.age}

{/if}
- + {#if errors.phone_number}

{errors.phone_number}

{/if}
- + {#if errors.manual_id}

{errors.manual_id}

@@ -122,12 +128,16 @@ const success = $derived(props.form?.success ?? null);
{#if errors.general} -

{errors.general}

+

+ {errors.general} +

{/if} {#if success} -

{success}

+

+ {success} +

{/if} -
+
- +
@@ -63,7 +66,7 @@ bind:value={password} autocomplete="current-password" required - class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100" + class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none" />
{#if errorMessage}