42 Commits

Author SHA1 Message Date
b44edb2b1d chnaged config 2026-01-16 17:17:15 +01:00
a72fabc0a0 Merge branch 'dev' into debian12 2026-01-16 17:11:30 +01:00
727bd832dc edited docs 2026-01-16 17:10:47 +01:00
3b93b1fa23 secured admin frontend as well 2026-01-16 17:09:11 +01:00
9963731b10 secured backend -> made backend internal accessable 2026-01-16 17:07:56 +01:00
5546401aa4 refactored dialogue component 2026-01-07 15:44:44 +01:00
2f405539fb changed translation 2026-01-07 15:32:06 +01:00
1406f28f86 Merge branch 'dev' into debian12 2026-01-07 15:06:51 +01:00
c803e42a76 fixed docs api key example 2026-01-07 15:06:28 +01:00
76c0e6a64b added 404 2025-12-05 14:44:09 +01:00
ebda6424c7 fixed design of item table in the admin panel 2025-12-05 10:17:46 +01:00
38d1091e9b Merge branch 'dev' into debian12 2025-11-30 21:23:22 +01:00
f82efecb8c edited docker config 2025-11-30 21:21:21 +01:00
1f12bc8839 t 2025-11-30 21:17:36 +01:00
f19750f6f3 edited port config 2025-11-30 21:12:14 +01:00
808b3fd5c4 Merge branch 'dev' into debian12 2025-11-30 21:07:32 +01:00
e362515eff edited gitignore 2025-11-29 14:56:49 +01:00
31960d1ff8 fixed footer message 2025-11-25 18:02:05 +01:00
0891598eb9 changed version info 2025-11-25 17:30:56 +01:00
39ff02f2e7 Merge branch 'dev' into debian12 2025-11-25 17:11:27 +01:00
3bf5560834 edited docs 2025-11-25 17:09:22 +01:00
4c60fea4c4 fixed bug: mailer did not send email 2025-11-25 16:57:20 +01:00
0577a63205 deleted xls 2025-11-25 16:15:08 +01:00
fd2ccaa747 feat: add door_key field to items and update related logic in forms and database 2025-11-24 17:12:37 +01:00
df6b5eac59 fixed bug and edited version 2025-11-24 16:55:27 +01:00
d64489aed4 edited scheme but still not working 2025-11-24 16:05:56 +01:00
cc67fb4f85 changed version info 2025-11-24 15:35:03 +01:00
75ff4aadc1 fixed color bug 2025-11-24 14:16:55 +01:00
6f998d07c1 Merge branch 'dev' into debian12 2025-11-23 21:52:34 +01:00
cb6b5858e5 refactor: rename lockerNumber to safe_nr and update related logic 2025-11-23 21:52:12 +01:00
f2bb326040 Merge branch 'dev' into debian12 2025-11-23 21:40:11 +01:00
85e6d7fe00 fixed bugs 2025-11-23 21:39:18 +01:00
4b9f55268c updated scheme 2025-11-23 21:25:08 +01:00
8c701db900 changed ports 2025-11-23 21:11:23 +01:00
d1664338a6 add networks configuration for frontend and backend services in docker-compose 2025-11-23 21:06:12 +01:00
1a2624cd9e again 2025-11-23 20:34:19 +01:00
a138190cc6 fixed bugs 2025-11-23 20:32:14 +01:00
993e0cd74b fixed bugs 2025-11-23 20:29:31 +01:00
90ca266793 changed docker config 2025-11-23 20:26:49 +01:00
dab004a7b6 changed docker config 2025-11-23 20:26:27 +01:00
d039336f39 Merge branch 'dev' into debian12 2025-11-23 20:20:41 +01:00
b9d67cd147 deleted old backend 2025-11-23 20:20:23 +01:00
36 changed files with 639 additions and 2944 deletions

4
.gitignore vendored
View File

@@ -113,3 +113,7 @@ secrets/
keys/ keys/
ToDo.txt ToDo.txt
# only in development branch
next-env.d.ts

View File

@@ -1,27 +1,32 @@
# Backend API (V2) Documentation # Borrow System API Documentation
This document describes the current backend API routes and their real response shapes, based on the code in `backendV2`. **Frontend:** https://insta.the1s.de
**Backend base URL:** `https://insta.the1s.de/backend/api`
---
## Base URLs
- Frontend: `https://insta.the1s.de`
- Backend: `https://backend.insta.the1s.de`
- Base path: `https://backend.insta.the1s.de/api`
Service status: `https://status.the1s.de`
--- ---
## Authentication ## Authentication
All **protected** endpoints require an API key as a path parameter `:key`. All API endpoints require **either**:
Rules for `:key`: ### 1. Bearer Token (JWT)
- Exactly 8 characters Send an `Authorization` header:
- Digits only (`^[0-9]{8}$`)
```http
Authorization: Bearer <JWT_TOKEN>
```
- Used for user-based access.
- Token must be valid and not expired.
### 2. API Key (for devices / machine-to-machine)
Include an API key in the route as `:key` parameter:
```text
/api/.../:key/...
```
Example: Example:
@@ -29,59 +34,48 @@ Example:
GET /api/items/12345678 GET /api/items/12345678
``` ```
On missing / invalid key: Where `12345678` is your API key.
The API key is validated server-side.
- Status: `401 Unauthorized`
- Body (exact message depends on `authenticate` in `backendV2/services/authentication.js`)
Auth-related modules:
- `backendV2/services/authentication.js`
- `backendV2/services/database.js`
Route handlers:
- `backendV2/routes/api/api.route.js`
- `backendV2/routes/api/api.database.js`
--- ---
## Endpoints (Overview) ## Common Response Codes
1. **Public** - `200 OK` Request was successful.
- `401 Unauthorized` Missing or malformed credentials.
- `GET /api/all-items` List all items (no auth; from original docs) - `403 Forbidden` Credentials invalid or not allowed to access this resource.
- `404 Not Found` Resource (e.g., loan) not found.
2. **Items (authenticated)** - `500 Internal Server Error` Unexpected server error.
- `GET /api/items/:key` List all items
- `POST /api/change-state/:key/:itemId/:state` Toggle item safe state
3. **Loans (authenticated)**
- `GET /api/get-loan-by-code/:key/:loan_code` Get loan by code
- `POST /api/set-take-date/:key/:loan_code` Set “take” date and mark items as out
- `POST /api/set-return-date/:key/:loan_code` Set “return” date and mark items as returned
--- ---
## 1) Items ## Endpoints
### 1.1 Get all items ### 1. Get All Items
**GET** `/api/items/:key` **GET** `/api/items/:key`
Returns all items wrapped in a `data` property. Returns a list of all items.
- Handler: `getItemsFromDatabaseV2` in `api.database.js` #### Path Parameters
- SQL: `SELECT * FROM items;`
#### Example request - `:key` API key (8-digit number)
#### Authentication
- Either:
- Valid `Authorization: Bearer <token>`
- Or valid `:key` path parameter
#### Request Example
```http ```http
GET https://backend.insta.the1s.de/api/items/12345678 GET /api/items/12345678 HTTP/1.1
Host: backend.insta.the1s.de
Authorization: Bearer <JWT_TOKEN>
``` ```
#### Successful response #### Successful Response (200)
```json ```json
{ {
@@ -90,8 +84,9 @@ GET https://backend.insta.the1s.de/api/items/12345678
"id": 1, "id": 1,
"item_name": "DJI 1er Mikro", "item_name": "DJI 1er Mikro",
"can_borrow_role": 4, "can_borrow_role": 4,
"in_safe": 1, "inSafe": 1,
"safe_nr": "01", "safe_nr": 3,
"door_key": "123",
"entry_created_at": "2025-08-19T22:02:16.000Z", "entry_created_at": "2025-08-19T22:02:16.000Z",
"entry_updated_at": "2025-08-19T22:02:16.000Z", "entry_updated_at": "2025-08-19T22:02:16.000Z",
"last_borrowed_person": "alice", "last_borrowed_person": "alice",
@@ -101,245 +96,271 @@ GET https://backend.insta.the1s.de/api/items/12345678
} }
``` ```
#### Error response #### Error Response (500)
```json ```json
{ "message": "Failed to fetch items" } {
"message": "Failed to fetch items"
}
``` ```
#### Status codes
- `200 OK` success, `data` is an array (possibly empty)
- `401 Unauthorized` invalid / missing key
- `500 Internal Server Error` database error or `success: false` from DB layer
--- ---
### 2.2 Toggle item safe state ### 2. Toggle Item Safe State
Toggles `in_safe` between `0` and `1` for a given item.
**Keep in mind that when you return a loan by code, the item states are automatically updated.**
**POST** `/api/change-state/:key/:itemId` **POST** `/api/change-state/:key/:itemId`
> You do not need this endpoint to set the states of the items when the items are taken out or returned. When you take or return a loan, the item states are set automatically by the loan endpoints. This endpoint is only for manually toggling the `inSafe` state of an item. #### Path Parameters
Path parameters: - `:key` API key (8-digit number)
- `:itemId` Item ID (integer)
- `:key` API key (8 digits) #### Authentication
- `:itemId` numeric `id` of the item
Handler in `api.route.js` calls `changeInSafeStateV2(itemId)`, which executes: - Either Bearer token or `:key` API key.
```sql #### Request Example
UPDATE items SET in_safe = NOT in_safe WHERE id = ?
```
#### Example request
```http ```http
POST https://backend.insta.the1s.de/api/change-state/12345678/42 POST /api/change-state/12345678/42 HTTP/1.1
Host: backend.insta.the1s.de
``` ```
(Will toggle `in_safe` for item `42`.) #### Successful Response (200)
#### Successful response (current implementation)
```json ```json
{ {
"data": null "data": {}
} }
``` ```
#### Error responses _(Implementation currently only returns `{ success: true }`, so `data` may be empty.)_
Invalid `state` (anything other than `"0"` or `"1"`): #### Error Response (500)
```json ```json
{ "message": "Invalid state value" } {
"message": "Failed to update item state"
}
``` ```
Failed update:
```json
{ "message": "Failed to update item state" }
```
#### Status codes
- `200 OK` item state toggled
- `400 Bad Request` invalid `state` parameter
- `401 Unauthorized` invalid / missing key
- `500 Internal Server Error` database/update failure or `success: false` from DB layer
--- ---
## 3) Loans ### 3. Get Loan by Code
### 3.1 Get loan by code Fetch loan information by `loan_code`.
**GET** `/api/get-loan-by-code/:key/:loan_code` **GET** `/api/get-loan-by-code/:key/:loan_code`
Path parameters: #### Path Parameters
- `:key` API key - `:key` API key (8-digit number)
- `:loan_code` 6-digit loan code (`^[0-9]{6}$` per DB constraint) - `:loan_code` Loan code (string)
Database layer (`getLoanByCodeV2`) currently selects: #### Authentication
```sql - Either Bearer token or `:key` API key.
SELECT first_name, returned_date, take_date, lockers
FROM loans
WHERE loan_code = ?;
```
#### Example request #### Request Example
```http ```http
GET https://backend.insta.the1s.de/api/get-loan-by-code/12345678/646473 GET /api/get-loan-by-code/12345678/12345 HTTP/1.1
Host: backend.insta.the1s.de
``` ```
#### Successful response #### Successful Response (200)
```json ```json
{ {
"data": { "data": {
"first_name": "Theis", "username": "john",
"returned_date": null, "returned_date": null,
"take_date": "2025-08-25T13:23:00.000Z", "take_date": "2025-01-01T10:00:00.000Z",
"lockers": ["01", "03"] "lockers": "[1, 2, 3]"
} }
} }
``` ```
#### Error response #### Error Response (404)
```json
{ "message": "Loan not found" }
```
#### Status codes
- `200 OK` loan found
- `401 Unauthorized` invalid / missing key
- `404 Not Found` no matching loan for this `loan_code`
---
### 3.2 Set take date
**POST** `/api/set-take-date/:key/:loan_code`
Path parameters:
- `:key` API key
- `:loan_code` loan code
#### Example request
```http
POST https://backend.insta.the1s.de/api/set-take-date/12345678/646473
```
#### Successful response
```json ```json
{ {
"data": null "message": "Loan not found"
} }
``` ```
#### Error response
```json
{ "message": "Failed to set take date" }
```
#### Status codes
- `200 OK` take date set and items marked as out
- `401 Unauthorized` invalid / missing key
- `500 Internal Server Error` invalid loan, missing items, or DB error / `success: false`
--- ---
### 3.3 Set return date ### 4. Set Loan Return Date
Sets `returned_date = NOW()` on a loan and updates related items:
- `in_safe = 1`
- `currently_borrowing = NULL`
- `last_borrowed_person = username`
**POST** `/api/set-return-date/:key/:loan_code` **POST** `/api/set-return-date/:key/:loan_code`
Path parameters: #### Path Parameters
- `:key` API key - `:key` API key (8-digit number)
- `:loan_code` loan code - `:loan_code` Loan code (string)
#### Example request #### Authentication
- Either Bearer token or `:key` API key.
#### Request Example
```http ```http
POST https://backend.insta.the1s.de/api/set-return-date/12345678/646473 POST /api/set-return-date/12345678/12345 HTTP/1.1
Host: backend.insta.the1s.de
``` ```
#### Successful response (current implementation) #### Successful Response (200)
```json ```json
{ {
"data": null "data": {}
} }
``` ```
#### Error response #### Error Response (500)
```json ```json
{ "message": "Failed to set return date" } {
"message": "Failed to set return date"
}
``` ```
#### Status codes
- `200 OK` return date set and items marked as returned
- `401 Unauthorized` invalid / missing key
- `500 Internal Server Error` invalid loan, missing items, or DB error / `success: false`
--- ---
## Common Response Shapes ### 5. Set Loan Take Date
**Success list (authenticated items):** Sets `take_date = NOW()` on a loan and updates related items:
- `in_safe = 0`
- `currently_borrowing = username`
**POST** `/api/set-take-date/:key/:loan_code`
#### Path Parameters
- `:key` API key (8-digit number)
- `:loan_code` Loan code (string)
#### Authentication
- Either Bearer token or `:key` API key.
#### Request Example
```http
POST /api/set-take-date/12345678/LOAN-12345 HTTP/1.1
Host: backend.insta.the1s.de
```
#### Successful Response (200)
```json ```json
{ {
"data": [ "data": {}
/* array of rows */
]
} }
``` ```
**Success single loan:** #### Error Response (500)
```json
{
"message": "Failed to set take date"
}
```
---
### 6. Open Door by Door Key
Looks up an item by its `door_key`, toggles `in_safe`, and returns safe information.
**GET** `/api/open-door/:key/:doorKey`
#### Path Parameters
- `:key` API key (8-digit number)
- `:doorKey` Door key/token (string) used by hardware to identify the locker.
#### Authentication
- Either Bearer token or `:key` API key.
#### Request Example
```http
GET /api/open-door/12345678/123 HTTP/1.1
Host: backend.insta.the1s.de
```
#### Successful Response (200)
```json ```json
{ {
"data": { "data": {
/* selected loan fields */ "safe_nr": 5,
"id": 42
} }
} }
``` ```
**Success mutations (current code):** #### Error Response (500)
```json ```json
{ "data": null } {
"message": "Failed to open door"
}
``` ```
**Errors:** ---
## Authentication Error Messages
### Missing credentials
Status: `401`
```json ```json
{ "message": "Failed to fetch items" } {
{ "message": "Failed to update item state" } "message": "Unauthorized"
{ "message": "Invalid state value" } }
{ "message": "Loan not found" }
{ "message": "Failed to set return date" }
{ "message": "Failed to set take date" }
``` ```
**HTTP Status Codes:** ### Invalid JWT
- `200 OK` operation succeeded Status: `403`
- `400 Bad Request` invalid `state` parameter
- `401 Unauthorized` invalid/missing API key ```json
- `404 Not Found` loan not found {
- `500 Internal Server Error` database / server failure or `success: false` from DB layer "message": "Present token invalid"
}
```
### Invalid API Key
Status: `403`
```json
{
"message": "API Key invalid"
}
```
---
## Notes
- All responses are JSON.
- Time fields like `take_date` and `returned_date` are in the format returned by MySQL (usually ISO-like strings).
- `loaned_items_id` in the database is stored as a JSON array string (e.g. `"[1,2,3]"`) and is parsed internally; clients do not interact with this field directly via current endpoints.

View File

@@ -1,4 +1,4 @@
FROM node:18 as builder FROM node:22-alpine AS builder
WORKDIR /app WORKDIR /app

View File

@@ -9,6 +9,14 @@ server {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
location = /backend {
return 301 /backend/;
}
location /backend/ {
proxy_pass http://borrow_system-backend_v2:8102/;
}
location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y; expires 1y;
access_log off; access_log off;

View File

@@ -4,25 +4,18 @@ import {
Heading, Heading,
Stack, Stack,
Text, Text,
CloseButton,
Dialog,
Portal,
HStack, HStack,
IconButton, IconButton,
Menu, Menu,
Box, Box,
Avatar, Avatar,
Card,
Grid,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { PasswordInput } from "@/components/ui/password-input";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { setIsLoggedInAtom, triggerLogoutAtom } from "@/states/Atoms"; import { setIsLoggedInAtom, triggerLogoutAtom } from "@/states/Atoms";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import {
CircleUserRound, CircleUserRound,
RotateCcwKey,
Code, Code,
LifeBuoy, LifeBuoy,
LogOut, LogOut,
@@ -33,69 +26,19 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useUserContext } from "@/states/Context"; import { useUserContext } from "@/states/Context";
import { useState } from "react"; import { useState } from "react";
import MyAlert from "./myChakra/MyAlert";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { API_BASE } from "@/config/api.config"; import { UserDialogue } from "./UserDialogue";
export const Header = () => { export const Header = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const userData = useUserContext(); const userData = useUserContext();
console.log(userData);
const { t } = useTranslation(); const { t } = useTranslation();
// Error handling states
const [isMsg, setIsMsg] = useState(false);
const [msgStatus, setMsgStatus] = useState<"error" | "success">("error");
const [msgTitle, setMsgTitle] = useState("");
const [msgDescription, setMsgDescription] = useState("");
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [, setTriggerLogout] = useAtom(triggerLogoutAtom); const [, setTriggerLogout] = useAtom(triggerLogoutAtom);
const [, setIsLoggedIn] = useAtom(setIsLoggedInAtom); const [, setIsLoggedIn] = useAtom(setIsLoggedInAtom);
// Dialog control
const [isPwOpen, setPwOpen] = useState(false);
const [userDialog, setUserDialog] = useState(false); const [userDialog, setUserDialog] = useState(false);
const changePassword = async () => {
if (newPassword !== confirmPassword) {
setMsgTitle(t("err_pw_change"));
setMsgDescription(t("pw_mismatch"));
setMsgStatus("error");
setIsMsg(true);
return;
}
const response = await fetch(`${API_BASE}/api/users/change-password`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`,
},
body: JSON.stringify({ oldPassword, newPassword }),
});
if (!response.ok) {
setMsgTitle(t("err_pw_change"));
setMsgDescription(t("pw_mismatch"));
setMsgStatus("error");
setIsMsg(true);
return;
}
setMsgTitle(t("pw_success"));
setMsgDescription(t("pw_success_desc"));
setMsgStatus("success");
setIsMsg(true);
setOldPassword("");
setNewPassword("");
setConfirmPassword("");
};
const username = userData.first_name ? userData.first_name : "N/A"; const username = userData.first_name ? userData.first_name : "N/A";
const fullname = userData.first_name + " " + userData.last_name; const fullname = userData.first_name + " " + userData.last_name;
const randomColor = [ const randomColor = [
@@ -375,146 +318,7 @@ export const Header = () => {
</Flex> </Flex>
{/* User Info Dialoge */} {/* User Info Dialoge */}
{userDialog && ( {userDialog && <UserDialogue setUserDialog={setUserDialog} fullname={fullname} randomColor={randomColor} />}
<Flex
position="fixed"
inset={0}
zIndex={1000}
align="center"
justify="center"
bg="blackAlpha.400"
backdropFilter="blur(6px)"
>
<Card.Root maxW="sm" w="full" mx={4}>
<Card.Header>
<Card.Title>
<Flex justify="center" align="center" w="100%">
<Avatar.Root
size={"2xl"}
colorPalette={randomColor[Math.floor(Math.random() * 10)]}
>
<Avatar.Fallback name={fullname} />
</Avatar.Root>
</Flex>
</Card.Title>
<Card.Description>{t("user-info-desc")}</Card.Description>
</Card.Header>
<Card.Body>
<Stack gap="4" w="full">
<Box as="dl">
<Grid
templateColumns="auto 1fr"
rowGap={2}
columnGap={4}
alignItems="start"
>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("first-name")}:
</Text>
<Text as="dd">{userData.first_name}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("last-name")}:
</Text>
<Text as="dd">{userData.last_name}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("username")}:
</Text>
<Text as="dd">{userData.username}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("role")}:
</Text>
<Text as="dd">{userData.role}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("admin-status")}:
</Text>
<Text as="dd">
{userData.is_admin ? t("yes") : t("no")}
</Text>
</Grid>
</Box>
<Button variant="solid" onClick={() => setPwOpen(true)}>
<HStack gap={2}>
<RotateCcwKey size={18} />
<Text as="span">{t("change-password")}</Text>
</HStack>
</Button>
</Stack>
</Card.Body>
<Card.Footer justifyContent="flex-end">
<Button variant="outline" onClick={() => setUserDialog(false)}>
{t("cancel")}
</Button>
</Card.Footer>
</Card.Root>
</Flex>
)}
{/* Passwort-Dialog (kontrolliert) */}
<Dialog.Root open={isPwOpen} onOpenChange={(e: any) => setPwOpen(e.open)}>
<Portal>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content maxW="md">
<Dialog.Header>
<Dialog.Title>{t("change-password")}</Dialog.Title>
</Dialog.Header>
<form
onSubmit={(e) => {
e.preventDefault();
changePassword();
}}
>
<Dialog.Body>
<Stack gap={3}>
<PasswordInput
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
placeholder={t("old-password")}
/>
<PasswordInput
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={t("new-password")}
/>
<PasswordInput
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={t("confirm-password")}
/>
</Stack>
</Dialog.Body>
<Dialog.Footer>
<Stack w="100%" gap={3}>
{isMsg && (
<MyAlert
status={msgStatus}
title={msgTitle}
description={msgDescription}
/>
)}
<HStack justify="flex-end" gap={2}>
<Dialog.ActionTrigger asChild>
<Button variant="outline">{t("cancel")}</Button>
</Dialog.ActionTrigger>
<Button type="submit" colorScheme="teal">
{t("save")}
</Button>
</HStack>
</Stack>
</Dialog.Footer>
</form>
<Dialog.CloseTrigger asChild>
<CloseButton size="sm" />
</Dialog.CloseTrigger>
</Dialog.Content>
</Dialog.Positioner>
</Portal>
</Dialog.Root>
</Stack> </Stack>
); );
}; };

View File

@@ -0,0 +1,220 @@
import {
Button,
Flex,
Stack,
Text,
CloseButton,
Dialog,
Portal,
HStack,
Box,
Avatar,
Card,
Grid,
} from "@chakra-ui/react";
import { PasswordInput } from "@/components/ui/password-input";
import { RotateCcwKey } from "lucide-react";
import MyAlert from "./myChakra/MyAlert";
import { API_BASE } from "@/config/api.config";
import { useUserContext } from "@/states/Context";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import Cookies from "js-cookie";
type UserDialogueProps = {
setUserDialog: (value: boolean) => void;
fullname: string;
randomColor: string[];
};
export const UserDialogue = (props: UserDialogueProps) => {
const userData = useUserContext();
const { t } = useTranslation();
// Error handling states
const [isMsg, setIsMsg] = useState(false);
const [msgStatus, setMsgStatus] = useState<"error" | "success">("error");
const [msgTitle, setMsgTitle] = useState("");
const [msgDescription, setMsgDescription] = useState("");
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
// Dialog control
const [isPwOpen, setPwOpen] = useState(false);
const changePassword = async () => {
if (newPassword !== confirmPassword) {
setMsgTitle(t("err_pw_change"));
setMsgDescription(t("pw_mismatch"));
setMsgStatus("error");
setIsMsg(true);
return;
}
const response = await fetch(`${API_BASE}/api/users/change-password`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`,
},
body: JSON.stringify({ oldPassword, newPassword }),
});
if (!response.ok) {
setMsgTitle(t("err_pw_change"));
setMsgDescription(t("pw_mismatch"));
setMsgStatus("error");
setIsMsg(true);
return;
}
setMsgTitle(t("pw_success"));
setMsgDescription(t("pw_success_desc"));
setMsgStatus("success");
setIsMsg(true);
setOldPassword("");
setNewPassword("");
setConfirmPassword("");
};
return (
<Flex
position="fixed"
inset={0}
zIndex={1000}
align="center"
justify="center"
bg="blackAlpha.400"
backdropFilter="blur(6px)"
>
<Card.Root maxW="sm" w="full" mx={4}>
<Card.Header>
<Card.Title>
<Flex justify="center" align="center" w="100%">
<Avatar.Root
size={"2xl"}
colorPalette={props.randomColor[Math.floor(Math.random() * 10)]}
>
<Avatar.Fallback name={props.fullname} />
</Avatar.Root>
</Flex>
</Card.Title>
<Card.Description>{t("user-info-desc")}</Card.Description>
</Card.Header>
<Card.Body>
<Stack gap="4" w="full">
<Box as="dl">
<Grid
templateColumns="auto 1fr"
rowGap={2}
columnGap={4}
alignItems="start"
>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("first-name")}:
</Text>
<Text as="dd">{userData.first_name}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("last-name")}:
</Text>
<Text as="dd">{userData.last_name}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("username")}:
</Text>
<Text as="dd">{userData.username}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("role")}:
</Text>
<Text as="dd">{userData.role}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("admin-status")}:
</Text>
<Text as="dd">{userData.is_admin ? t("yes") : t("no")}</Text>
</Grid>
</Box>
<Button variant="solid" onClick={() => setPwOpen(true)}>
<HStack gap={2}>
<RotateCcwKey size={18} />
<Text as="span">{t("change-password")}</Text>
</HStack>
</Button>
</Stack>
</Card.Body>
<Card.Footer justifyContent="flex-end">
<Button variant="outline" onClick={() => props.setUserDialog(false)}>
{t("cancel")}
</Button>
</Card.Footer>
</Card.Root>
{/* Passwort-Dialog (kontrolliert) */}
<Dialog.Root open={isPwOpen} onOpenChange={(e: any) => setPwOpen(e.open)}>
<Portal>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content maxW="md">
<Dialog.Header>
<Dialog.Title>{t("change-password")}</Dialog.Title>
</Dialog.Header>
<form
onSubmit={(e) => {
e.preventDefault();
changePassword();
}}
>
<Dialog.Body>
<Stack gap={3}>
<PasswordInput
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
placeholder={t("old-password")}
/>
<PasswordInput
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={t("new-password")}
/>
<PasswordInput
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={t("confirm-password")}
/>
</Stack>
</Dialog.Body>
<Dialog.Footer>
<Stack w="100%" gap={3}>
{isMsg && (
<MyAlert
status={msgStatus}
title={msgTitle}
description={msgDescription}
/>
)}
<HStack justify="flex-end" gap={2}>
<Dialog.ActionTrigger asChild>
<Button variant="outline">{t("cancel")}</Button>
</Dialog.ActionTrigger>
<Button type="submit" colorScheme="teal">
{t("save")}
</Button>
</HStack>
</Stack>
</Dialog.Footer>
</form>
<Dialog.CloseTrigger asChild>
<CloseButton size="sm" />
</Dialog.CloseTrigger>
</Dialog.Content>
</Dialog.Positioner>
</Portal>
</Dialog.Root>
</Flex>
);
};

View File

@@ -14,7 +14,7 @@ export const Footer = () => {
left="0" left="0"
right="0" right="0"
> >
Made with by Theis Gaedigk - Year 2019 at MCS-Bochum Made with by Theis Gaedigk - Class of 2019 at MCS-Bochum
<br /> <br />
Frontend-Version: {info ? info["frontend-info"].version : "N/A"} | Frontend-Version: {info ? info["frontend-info"].version : "N/A"} |
Backend-Version: {info ? info["backend-info"].version : "N/A"} Backend-Version: {info ? info["backend-info"].version : "N/A"}

View File

@@ -1,15 +1,23 @@
"use client" "use client";
import { ChakraProvider, defaultSystem } from "@chakra-ui/react" import { ChakraProvider, defaultSystem } from "@chakra-ui/react";
import { import * as React from "react";
ColorModeProvider, import type { ReactNode } from "react";
type ColorModeProviderProps, import { ColorModeProvider as ThemeColorModeProvider } from "./color-mode";
} from "./color-mode"
export function Provider(props: ColorModeProviderProps) { export interface ColorModeProviderProps {
children: React.ReactNode;
}
export function ColorModeProvider({ children }: ColorModeProviderProps) {
// Wrap children with the real color-mode provider
return <ThemeColorModeProvider>{children}</ThemeColorModeProvider>;
}
export function Provider({ children }: { children: ReactNode }) {
return ( return (
<ChakraProvider value={defaultSystem}> <ChakraProvider value={defaultSystem}>
<ColorModeProvider {...props} /> <ColorModeProvider>{children}</ColorModeProvider>
</ChakraProvider> </ChakraProvider>
) );
} }

View File

@@ -63,7 +63,7 @@
"timezone-info": "Die angezeigten Daten und Uhrzeiten werden in deutscher Zeitzone dargestellt und müssen auch so eingegeben werden.", "timezone-info": "Die angezeigten Daten und Uhrzeiten werden in deutscher Zeitzone dargestellt und müssen auch so eingegeben werden.",
"optional-note": "Optionale Notiz", "optional-note": "Optionale Notiz",
"note": "Notiz", "note": "Notiz",
"user-info-desc": "Hier können Sie Ihre persönlichen Informationen einsehen und ändern.", "user-info-desc": "Hier können Sie Ihre persönlichen Informationen einsehen und das Passwort ändern. Falls Sie weitere Änderungen benötigen, wenden Sie sich bitte an einen Administrator.",
"role": "Rolle", "role": "Rolle",
"admin-status": "Admin-Status", "admin-status": "Admin-Status",
"first-name": "Vorname", "first-name": "Vorname",

View File

@@ -63,7 +63,7 @@
"timezone-info": "The displayed dates and times are shown in Berlin timezone and must also be entered as such.", "timezone-info": "The displayed dates and times are shown in Berlin timezone and must also be entered as such.",
"optional-note": "Optional note", "optional-note": "Optional note",
"note": "Note", "note": "Note",
"user-info-desc": "Here you can view and edit your personal information.", "user-info-desc": "Here you can view your personal information and change your password. If you need to make further changes, please contact an administrator.",
"role": "Role", "role": "Role",
"admin-status": "Admin status", "admin-status": "Admin status",
"first-name": "First name", "first-name": "First name",

View File

@@ -1,8 +1,14 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import path from "node:path";
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss()], plugins: [tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
server: { server: {
host: "0.0.0.0", host: "0.0.0.0",
allowedHosts: ["insta.the1s.de"], allowedHosts: ["insta.the1s.de"],

View File

@@ -9,6 +9,14 @@ server {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
location = /backend {
return 301 /backend/;
}
location /backend/ {
proxy_pass http://borrow_system-backend_v2:8102/;
}
location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y; expires 1y;
access_log off; access_log off;

View File

@@ -29,8 +29,8 @@ const AddItemForm: React.FC<AddItemFormProps> = ({ onClose, alert }) => {
<Input id="item_name" placeholder="z.B. Laptop" /> <Input id="item_name" placeholder="z.B. Laptop" />
</Field.Root> </Field.Root>
<Field.Root> <Field.Root>
<Field.Label>Schließfachnummer (immer zwei Zahlen)</Field.Label> <Field.Label>Schließfachnummer</Field.Label>
<Input id="lockerNumber" placeholder="Nummer 01 - 06" /> <Input id="safe_nr" placeholder="Nummer 1 - 6" />
</Field.Root> </Field.Root>
<Field.Root> <Field.Root>
<Field.Label>Ausleih-Berechtigung (Rolle)</Field.Label> <Field.Label>Ausleih-Berechtigung (Rolle)</Field.Label>
@@ -57,17 +57,15 @@ const AddItemForm: React.FC<AddItemFormProps> = ({ onClose, alert }) => {
(document.getElementById("can_borrow_role") as HTMLInputElement) (document.getElementById("can_borrow_role") as HTMLInputElement)
?.value ?.value
); );
const lockerValue = ( const safeNrValue = (
document.getElementById("lockerNumber") as HTMLInputElement document.getElementById("safe_nr") as HTMLInputElement
)?.value.trim(); )?.value.trim();
const lockerNumber = const safeNr = safeNrValue === "" ? null : safeNrValue;
lockerValue === "" ? null : Number(lockerValue);
if (!name || Number.isNaN(role)) return; if (!name || Number.isNaN(role)) return;
if (lockerNumber !== null && Number.isNaN(lockerNumber)) return;
const res = await createItem(name, role, lockerNumber); const res = await createItem(name, role, safeNr);
if (res.success) { if (res.success) {
alert( alert(
"success", "success",

View File

@@ -38,6 +38,7 @@ type Items = {
can_borrow_role: string; can_borrow_role: string;
in_safe: boolean; in_safe: boolean;
safe_nr: string; safe_nr: string;
door_key: string;
entry_created_at: string; entry_created_at: string;
entry_updated_at: string; entry_updated_at: string;
last_borrowed_person: string | null; last_borrowed_person: string | null;
@@ -68,7 +69,13 @@ const ItemTable: React.FC = () => {
const handleLockerNumberChange = (id: number, value: string) => { const handleLockerNumberChange = (id: number, value: string) => {
setItems((prev) => setItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, lockerNumber: value } : it)) prev.map((it) => (it.id === id ? { ...it, safe_nr: value } : it))
);
};
const handleDoorKeyChange = (id: number, value: string) => {
setItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, door_key: value } : it))
); );
}; };
@@ -186,7 +193,12 @@ const ItemTable: React.FC = () => {
{/* make table fill available width, like UserTable */} {/* make table fill available width, like UserTable */}
{!isLoading && ( {!isLoading && (
<Table.Root size="sm" striped w="100%" style={{ tableLayout: "auto" }}> <Table.Root
size="sm"
striped
w="100%"
style={{ tableLayout: "auto" }} // Spalten nach Content
>
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.ColumnHeader> <Table.ColumnHeader>
@@ -201,9 +213,12 @@ const ItemTable: React.FC = () => {
<Table.ColumnHeader> <Table.ColumnHeader>
<strong>Im Schließfach</strong> <strong>Im Schließfach</strong>
</Table.ColumnHeader> </Table.ColumnHeader>
<Table.ColumnHeader> <Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>Schließfachnummer</strong> <strong>Schließfachnummer</strong>
</Table.ColumnHeader> </Table.ColumnHeader>
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>Schlüssel</strong>
</Table.ColumnHeader>
<Table.ColumnHeader> <Table.ColumnHeader>
<strong>Eintrag erstellt am</strong> <strong>Eintrag erstellt am</strong>
</Table.ColumnHeader> </Table.ColumnHeader>
@@ -216,7 +231,7 @@ const ItemTable: React.FC = () => {
<Table.ColumnHeader> <Table.ColumnHeader>
<strong>Dav **</strong> <strong>Dav **</strong>
</Table.ColumnHeader> </Table.ColumnHeader>
<Table.ColumnHeader> <Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>Aktionen</strong> <strong>Aktionen</strong>
</Table.ColumnHeader> </Table.ColumnHeader>
</Table.Row> </Table.Row>
@@ -290,17 +305,28 @@ const ItemTable: React.FC = () => {
value={item.safe_nr} value={item.safe_nr}
/> />
</Table.Cell> </Table.Cell>
<Table.Cell>
<Input
size="sm"
w="max-content"
onChange={(e) =>
handleDoorKeyChange(item.id, e.target.value)
}
value={item.door_key}
/>
</Table.Cell>
<Table.Cell>{formatDateTime(item.entry_created_at)}</Table.Cell> <Table.Cell>{formatDateTime(item.entry_created_at)}</Table.Cell>
<Table.Cell>{formatDateTime(item.entry_updated_at)}</Table.Cell> <Table.Cell>{formatDateTime(item.entry_updated_at)}</Table.Cell>
<Table.Cell>{item.last_borrowed_person}</Table.Cell> <Table.Cell>{item.last_borrowed_person}</Table.Cell>
<Table.Cell>{item.currently_borrowing}</Table.Cell> <Table.Cell>{item.currently_borrowing}</Table.Cell>
<Table.Cell> <Table.Cell whiteSpace="nowrap">
<Button <Button
onClick={() => onClick={() =>
handleEditItems( handleEditItems(
item.id, item.id,
item.item_name, item.item_name,
item.safe_nr, item.safe_nr,
item.door_key,
item.can_borrow_role item.can_borrow_role
).then((response) => { ).then((response) => {
if (response.success) { if (response.success) {

View File

@@ -165,7 +165,7 @@ export const deleteItem = async (itemId: number) => {
export const createItem = async ( export const createItem = async (
item_name: string, item_name: string,
can_borrow_role: number, can_borrow_role: number,
lockerNumber: number | null lockerNumber: string | null
) => { ) => {
console.log(JSON.stringify({ item_name, can_borrow_role, lockerNumber })); console.log(JSON.stringify({ item_name, can_borrow_role, lockerNumber }));
try { try {
@@ -184,7 +184,7 @@ export const createItem = async (
return { return {
success: false, success: false,
message: message:
"Fehler beim Erstellen des Gegenstands. Der Name des Gegenstandes darf nicht mehrmals vergeben werden.", "Fehler beim Erstellen des Gegenstands. Der Name des Gegenstandes und die Schließfachnummer dürfen nicht mehrmals vergeben werden.",
}; };
} }
return { success: true }; return { success: true };
@@ -198,9 +198,9 @@ export const handleEditItems = async (
itemId: number, itemId: number,
item_name: string, item_name: string,
safe_nr: string | null, safe_nr: string | null,
door_key: string | null,
can_borrow_role: string can_borrow_role: string
) => { ) => {
const newSafeNr = Number(safe_nr || 0);
try { try {
const response = await fetch( const response = await fetch(
`${API_BASE}/api/admin/item-data/edit-item/${itemId}`, `${API_BASE}/api/admin/item-data/edit-item/${itemId}`,
@@ -210,7 +210,7 @@ export const handleEditItems = async (
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`, Authorization: `Bearer ${Cookies.get("token")}`,
}, },
body: JSON.stringify({ item_name, newSafeNr, can_borrow_role }), body: JSON.stringify({ item_name, safe_nr, door_key, can_borrow_role }),
} }
); );
if (!response.ok) { if (!response.ok) {

View File

@@ -1,12 +0,0 @@
FROM node:20-alpine
ENV NODE_ENV=production
WORKDIR /backend
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 8002
CMD ["npm", "start"]

View File

@@ -1,8 +0,0 @@
{
"backend-info": {
"version": "v2.0 (dev)"
},
"frontend-info": {
"version": "v2.0 (dev)"
}
}

1072
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +0,0 @@
{
"name": "backend",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^17.2.1",
"ejs": "^3.1.10",
"express": "^5.1.0",
"jose": "^6.0.12",
"mysql2": "^3.14.3",
"nodemailer": "^7.0.6"
}
}

View File

@@ -1,599 +0,0 @@
import express from "express";
import {
loginFunc,
getItemsFromDatabase,
getLoansFromDatabase,
getUserLoansFromDatabase,
deleteLoanFromDatabase,
getBorrowableItemsFromDatabase,
createLoanInDatabase,
onTake,
loginAdmin,
onReturn,
getAllUsers,
deleteUserID,
handleEdit,
createUser,
getAllLoans,
getAllItems,
deleteItemID,
createItem,
changeUserPassword,
changeUserPasswordFRONTEND,
changeInSafeStateV2,
updateItemByID,
getAllApiKeys,
createAPIentry,
deleteAPKey,
getLoanInfoWithID,
SETdeleteLoanFromDatabase,
} from "../services/database.js";
import { authenticate, generateToken } from "../services/tokenService.js";
const router = express.Router();
import nodemailer from "nodemailer";
import dotenv from "dotenv";
dotenv.config();
// Nice HTML + text templates for the loan email
function buildLoanEmail({ user, items, startDate, endDate, createdDate }) {
const brand = process.env.MAIL_BRAND_COLOR || "#0ea5e9";
const itemsList =
Array.isArray(items) && items.length
? `<ul style="margin:4px 0 0 18px; padding:0;">${items
.map(
(i) =>
`<li style="margin:2px 0; color:#111827; line-height:1.3;">${i}</li>`
)
.join("")}</ul>`
: "<span style='color:#111827;'>N/A</span>";
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<meta name="x-apple-disable-message-reformatting">
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
:root { color-scheme: light; supported-color-schemes: light; }
body { margin:0; padding:0; }
/* Mobile stacking */
@media (max-width:480px) {
.outer { width:100% !important; }
.pad-sm { padding:16px !important; }
.w-label { width:120px !important; }
}
/* Dark-mode override safety */
@media (prefers-color-scheme: dark) {
body, table, td, p, a, h1, h2, h3 { background:#ffffff !important; color:#111827 !important; }
.brand-header { background:${brand} !important; color:#ffffff !important; }
a { color:${brand} !important; }
}
</style>
</head>
<body bgcolor="#ffffff" style="background:#ffffff; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; color:#111827; -webkit-text-size-adjust:100%;">
<!-- Preheader (hidden) -->
<div style="display:none; max-height:0; overflow:hidden; opacity:0; mso-hide:all;">
Neue Ausleihe erstellt Übersicht der Buchung.
</div>
<div role="article" aria-roledescription="email" lang="de" style="padding:24px; background:#f2f4f7;">
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" class="outer" style="max-width:600px; margin:0 auto; background:#ffffff; border:1px solid #e5e7eb; border-radius:14px; overflow:hidden;">
<tr>
<td class="brand-header" style="padding:22px 26px; background:${brand}; color:#ffffff;">
<h1 style="margin:0; font-size:18px; line-height:1.35; font-weight:600;">Neue Ausleihe erstellt</h1>
</td>
</tr>
<tr>
<td class="pad-sm" style="padding:24px 26px; color:#111827;">
<p style="margin:0 0 14px 0; line-height:1.4;">Es wurde eine neue Ausleihe angelegt. Hier sind die Details:</p>
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="border-collapse:collapse; font-size:14px; line-height:1.3; background:#fcfcfd; border:1px solid #e5e7eb; border-radius:10px; overflow:hidden;">
<tbody>
<tr>
<td class="w-label" style="padding:10px 14px; color:#6b7280; width:170px; border-bottom:1px solid #ececec;">Benutzer</td>
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${
user || "N/A"
}</td>
</tr>
<tr>
<td style="padding:10px 14px; color:#6b7280; vertical-align:top; border-bottom:1px solid #ececec;">Ausgeliehene Gegenstände</td>
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${itemsList}</td>
</tr>
<tr>
<td style="padding:10px 14px; color:#6b7280; border-bottom:1px solid #ececec;">Startdatum</td>
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${formatDateTime(
startDate
)}</td>
</tr>
<tr>
<td style="padding:10px 14px; color:#6b7280; border-bottom:1px solid #ececec;">Enddatum</td>
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${formatDateTime(
endDate
)}</td>
</tr>
<tr>
<td style="padding:10px 14px; color:#6b7280;">Erstellt am</td>
<td style="padding:10px 14px; font-weight:600; color:#111827;">${formatDateTime(
createdDate
)}</td>
</tr>
</tbody>
</table>
<p style="margin:22px 0 0 0; font-size:14px;">
<a href="https://admin.insta.the1s.de/api" style="display:inline-block; background:${brand}; color:#ffffff; text-decoration:none; padding:10px 16px; border-radius:6px; font-weight:600; font-size:14px;" target="_blank" rel="noopener noreferrer">
Übersicht öffnen
</a>
</p>
<p style="margin:18px 0 0 0; font-size:12px; color:#6b7280; line-height:1.4;">
Diese E-Mail wurde automatisch vom Ausleihsystem gesendet. Bitte nicht antworten.
</p>
</td>
</tr>
</table>
</div>
</body>
</html>`;
}
function buildLoanEmailText({ user, items, startDate, endDate, createdDate }) {
const itemsText =
Array.isArray(items) && items.length ? items.join(", ") : "N/A";
return [
"Neue Ausleihe erstellt",
"",
`Benutzer: ${user || "N/A"}`,
`Gegenstände: ${itemsText}`,
`Start: ${formatDateTime(startDate)}`,
`Ende: ${formatDateTime(endDate)}`,
`Erstellt am: ${formatDateTime(createdDate)}`,
].join("\n");
}
function sendMailLoan(user, items, startDate, endDate, createdDate) {
const transporter = nodemailer.createTransport({
host: process.env.MAIL_HOST,
port: process.env.MAIL_PORT,
secure: true,
auth: {
user: process.env.MAIL_USER,
pass: process.env.MAIL_PASSWORD,
},
});
(async () => {
const info = await transporter.sendMail({
from: '"Ausleihsystem" <noreply@mcs-medien.de>',
to: process.env.MAIL_SENDEES,
subject: "Eine neue Ausleihe wurde erstellt!",
text: buildLoanEmailText({
user,
items,
startDate,
endDate,
createdDate,
}),
html: buildLoanEmail({ user, items, startDate, endDate, createdDate }),
});
console.log("Message sent:", info.messageId);
})();
console.log("sendMailLoan called");
}
const formatDateTime = (value) => {
if (value == null) return "N/A";
const toOut = (d) => {
if (!(d instanceof Date) || isNaN(d.getTime())) return "N/A";
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
const hh = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${dd}.${mm}.${yyyy} ${hh}:${mi} Uhr`;
};
if (value instanceof Date) return toOut(value);
if (typeof value === "number") return toOut(new Date(value));
const s = String(value).trim();
// Direct pattern: "YYYY-MM-DD[ T]HH:mm[:ss]"
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::\d{2})?/);
if (m) {
const [, y, M, d, h, min] = m;
return `${d}.${M}.${y} ${h}:${min} Uhr`;
}
// ISO or other parseable formats
const dObj = new Date(s);
if (!isNaN(dObj.getTime())) return toOut(dObj);
return "N/A";
};
router.post("/login", async (req, res) => {
const result = await loginFunc(req.body.username, req.body.password);
if (result.success) {
const token = await generateToken({
username: result.data.username,
role: result.data.role,
});
res.status(200).json({ message: "Login successful", token });
} else {
res.status(401).json({ message: "Invalid credentials" });
}
});
router.get("/items", authenticate, async (req, res) => {
const result = await getItemsFromDatabase(req.user.role);
if (result.success) {
res.status(200).json(result.data);
} else {
res.status(500).json({ message: "Failed to fetch items" });
}
});
router.get("/loans", authenticate, async (req, res) => {
const result = await getLoansFromDatabase();
if (result.success) {
res.status(200).json(result.data);
} else {
res.status(500).json({ message: "Failed to fetch loans" });
}
});
router.get("/userLoans", authenticate, async (req, res) => {
const result = await getUserLoansFromDatabase(req.user.username);
if (result.success) {
res.status(200).json(result.data);
} else {
res.status(500).json({ message: "Failed to fetch user loans" });
}
});
router.delete("/deleteLoan/:id", authenticate, async (req, res) => {
const loanId = req.params.id;
const result = await deleteLoanFromDatabase(loanId);
if (result.success) {
res.status(200).json({ message: "Loan deleted successfully" });
} else {
res.status(500).json({ message: "Failed to delete loan" });
}
});
router.delete("/SETdeleteLoan/:id", authenticate, async (req, res) => {
const loanId = req.params.id;
const result = await SETdeleteLoanFromDatabase(loanId);
if (result.success) {
res.status(200).json({ message: "Loan deleted successfully" });
} else {
res.status(500).json({ message: "Failed to delete loan" });
}
});
router.post("/borrowableItems", authenticate, async (req, res) => {
const { startDate, endDate } = req.body || {};
if (!startDate || !endDate) {
return res
.status(400)
.json({ message: "startDate and endDate are required" });
}
const result = await getBorrowableItemsFromDatabase(
startDate,
endDate,
req.user.role
);
if (result.success) {
// return the array directly for consistency with /items
return res.status(200).json(result.data);
} else {
return res
.status(500)
.json({ message: "Failed to fetch borrowable items" });
}
});
router.post("/takeLoan/:id", authenticate, async (req, res) => {
const loanId = req.params.id;
const result = await onTake(loanId);
if (result.success) {
res.status(200).json({ message: "Loan taken successfully" });
} else {
res.status(500).json({ message: "Failed to take loan" });
}
});
router.post("/returnLoan/:id", authenticate, async (req, res) => {
const loanId = req.params.id;
const result = await onReturn(loanId);
if (result.success) {
res.status(200).json({ message: "Loan returned successfully" });
} else {
res.status(500).json({ message: "Failed to return loan" });
}
});
router.post("/createLoan", authenticate, async (req, res) => {
try {
const { items, startDate, endDate } = req.body || {};
if (!Array.isArray(items) || items.length === 0) {
return res.status(400).json({ message: "Items array is required" });
}
// If dates are not provided, default to now .. +7 days
const start =
startDate ?? new Date().toISOString().slice(0, 19).replace("T", " ");
const end =
endDate ??
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 19)
.replace("T", " ");
// Coerce item IDs to numbers and filter invalids
const itemIds = items
.map((v) => Number(v))
.filter((n) => Number.isFinite(n));
if (itemIds.length === 0) {
return res.status(400).json({ message: "No valid item IDs provided" });
}
const result = await createLoanInDatabase(
req.user.username,
start,
end,
itemIds
);
if (result.success) {
const mailInfo = await getLoanInfoWithID(result.data.id);
console.log(mailInfo);
sendMailLoan(
mailInfo.data.username,
mailInfo.data.loaned_items_name,
mailInfo.data.start_date,
mailInfo.data.end_date,
mailInfo.data.created_at
);
return res.status(201).json({
message: "Loan created successfully",
loanId: result.data.id,
loanCode: result.data.loan_code,
});
}
if (result.code === "CONFLICT") {
return res
.status(409)
.json({ message: "Items not available in the selected period" });
}
if (result.code === "BAD_REQUEST") {
return res.status(400).json({ message: result.message });
}
return res.status(500).json({ message: "Failed to create loan" });
} catch (err) {
console.error("createLoan error:", err);
return res.status(500).json({ message: "Failed to create loan" });
}
});
router.post("/changePassword", authenticate, async (req, res) => {
const { oldPassword, newPassword } = req.body || {};
const username = req.user.username;
const result = await changeUserPasswordFRONTEND(
username,
oldPassword,
newPassword
);
if (result.success) {
res.status(200).json({ message: "Password changed successfully" });
} else {
res.status(500).json({ message: "Failed to change password" });
}
});
// Admin panel functions
router.post("/loginAdmin", async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) {
return res
.status(400)
.json({ message: "Username and password are required" });
}
const result = await loginAdmin(username, password);
if (result.success) {
const token = await generateToken({
username: result.data.username,
role: result.data.role,
});
return res.status(200).json({
message: "Login successful",
first_name: result.data.first_name,
token,
});
}
return res.status(401).json({ message: "Invalid credentials" });
});
router.get("/allUsers", authenticate, async (req, res) => {
const result = await getAllUsers();
if (result.success) {
return res.status(200).json(result.data);
}
return res.status(500).json({ message: "Failed to fetch users" });
});
router.delete("/deleteUser/:id", authenticate, async (req, res) => {
const userId = req.params.id;
const result = await deleteUserID(userId);
if (result.success) {
return res.status(200).json({ message: "User deleted successfully" });
}
return res.status(500).json({ message: "Failed to delete user" });
});
router.get("/verifyToken", authenticate, async (req, res) => {
res.status(200).json({ message: "Token is valid", user: req.user });
});
router.post("/editUser/:id", authenticate, async (req, res) => {
const userId = req.params.id;
const { username, role } = req.body || {};
const result = await handleEdit(userId, username, role);
if (result.success) {
return res.status(200).json({ message: "User edited successfully" });
}
return res.status(500).json({ message: "Failed to edit user" });
});
router.post("/createUser", authenticate, async (req, res) => {
const { username, role, password } = req.body || {};
const result = await createUser(username, role, password);
if (result.success) {
return res.status(201).json({ message: "User created successfully" });
}
return res.status(500).json({ message: "Failed to create user" });
});
router.get("/allLoans", authenticate, async (req, res) => {
const result = await getAllLoans();
if (result.success) {
return res.status(200).json(result.data);
}
return res.status(500).json({ message: "Failed to fetch loans" });
});
router.get("/allItems", authenticate, async (req, res) => {
const result = await getAllItems();
if (result.success) {
return res.status(200).json(result.data);
}
return res.status(500).json({ message: "Failed to fetch items" });
});
router.delete("/deleteItem/:id", authenticate, async (req, res) => {
const itemId = req.params.id;
const result = await deleteItemID(itemId);
if (result.success) {
return res.status(200).json({ message: "Item deleted successfully" });
}
return res.status(500).json({ message: "Failed to delete item" });
});
router.post("/createItem", authenticate, async (req, res) => {
const { item_name, can_borrow_role } = req.body || {};
const result = await createItem(item_name, can_borrow_role);
if (result.success) {
return res.status(201).json({ message: "Item created successfully" });
}
return res.status(500).json({ message: "Failed to create item" });
});
router.post("/changePWadmin", authenticate, async (req, res) => {
const newPassword = req.body.newPassword;
if (!newPassword) {
return res.status(400).json({ message: "New password is required" });
}
const result = await changeUserPassword(req.body.username, newPassword);
if (result.success) {
return res.status(200).json({ message: "Password changed successfully" });
}
return res.status(500).json({ message: "Failed to change password" });
});
router.post("/updateItemByID", authenticate, async (req, res) => {
const role = req.body.can_borrow_role;
const itemId = req.body.itemId;
const item_name = req.body.item_name;
const result = await updateItemByID(itemId, item_name, role);
if (result.success) {
return res.status(200).json({ message: "Item updated successfully" });
}
return res.status(500).json({ message: "Failed to update item" });
});
router.put("/changeSafeState/:itemId", authenticate, async (req, res) => {
const itemId = req.params.itemId;
const result = await changeInSafeStateV2(itemId);
if (result.success) {
return res
.status(200)
.json({ message: "Item safe state updated successfully" });
}
return res.status(500).json({ message: "Failed to update item safe state" });
});
router.get("/apiKeys", authenticate, async (req, res) => {
const result = await getAllApiKeys();
if (result.success) {
return res.status(200).json(result.data);
}
return res.status(500).json({ message: "Failed to fetch API keys" });
});
router.delete("/deleteAPKey/:id", authenticate, async (req, res) => {
const apiKeyId = req.params.id;
const result = await deleteAPKey(apiKeyId);
if (result.success) {
return res.status(200).json({ message: "API key deleted successfully" });
}
return res.status(500).json({ message: "Failed to delete API key" });
});
router.post("/createAPIentry", authenticate, async (req, res) => {
const apiKey = req.body.apiKey;
const user = req.body.user;
if (!apiKey || !user) {
return res.status(400).json({ message: "API key and user are required" });
}
// Ensure apiKey is a number
const apiKeyNum = Number(apiKey);
if (!Number.isFinite(apiKeyNum)) {
return res.status(400).json({ message: "API key must be a number" });
}
const result = await createAPIentry(apiKeyNum, user);
if (result.success) {
return res.status(201).json({ message: "API key created successfully" });
}
if (result.code === "DUPLICATE") {
return res.status(409).json({ message: "API key already exists" });
}
return res.status(500).json({ message: "Failed to create API key" });
});
router.get("/apiKeys/validate/:key", async (req, res) => {
try {
const rawKey = req.params.key;
const result = await getAllApiKeys();
if (!result.success || !Array.isArray(result.data)) {
return res.status(500).json({ valid: false });
}
const isValid = result.data.some((entry) => {
const val = String(
entry?.key ?? entry?.apiKey ?? entry?.api_key ?? entry
);
return val === String(rawKey);
});
return res.status(200).json({ valid: isValid });
} catch (err) {
console.error("validate api key error:", err);
return res.status(500).json({ valid: false });
}
});
export default router;

View File

@@ -1,133 +0,0 @@
import express from "express";
import dotenv from "dotenv";
import {
getItemsFromDatabaseV2,
changeInSafeStateV2,
setTakeDateV2,
setReturnDateV2,
getLoanByCodeV2,
getAllLoansV2,
getAPIkey,
} from "../services/database.js";
dotenv.config();
const router = express.Router();
async function validateAPIKey(apiKey) {
try {
if (!apiKey) return false;
const result = await getAPIkey();
if (!result?.success || !Array.isArray(result.data)) return false;
return result.data.some((row) => String(row.apiKey) === String(apiKey));
} catch (err) {
console.error("validateAPIKey error:", err);
return false;
}
}
// Add a guard that returns Access Denied instead of hanging
const apiKeyGuard = async (req, res, next) => {
try {
const key = req.params.key;
if (!key) {
return res
.status(401)
.json({ message: "Access denied: missing API key" });
}
const ok = await validateAPIKey(key);
if (!ok) {
return res
.status(401)
.json({ message: "Access denied: invalid API key" });
}
next();
} catch (e) {
console.error("apiKeyGuard error:", e);
res.status(500).json({ message: "Internal server error" });
}
};
// Route for API to get ALL items from the database
router.get("/items/:key", apiKeyGuard, async (req, res) => {
const result = await getItemsFromDatabaseV2();
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to fetch items" });
}
});
// Route for API to control the position of an item
router.post(
"/controlInSafe/:key/:itemId/:state",
apiKeyGuard,
async (req, res) => {
const itemId = req.params.itemId;
const state = req.params.state;
if (state === "1" || state === "0") {
const result = await changeInSafeStateV2(itemId, state);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to update item state" });
}
} else {
res.status(400).json({ message: "Invalid state value" });
}
}
);
// Route for API to get a loan by its code
router.get("/getLoanByCode/:key/:loan_code", apiKeyGuard, async (req, res) => {
const loan_code = req.params.loan_code;
const result = await getLoanByCodeV2(loan_code);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(404).json({ message: "Loan not found" });
}
});
// Route for API to set the return date by the loan code
router.post("/setReturnDate/:key/:loan_code", apiKeyGuard, async (req, res) => {
const loanCode = req.params.loan_code;
const result = await setReturnDateV2(loanCode);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to set return date" });
}
});
// Route for API to set the take away date by the loan code
router.post("/setTakeDate/:key/:loan_code", apiKeyGuard, async (req, res) => {
const loanCode = req.params.loan_code;
const result = await setTakeDateV2(loanCode);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to set take date" });
}
});
// Route for API to get ALL loans from the database without sensitive info (only for landingpage)
router.get("/allLoans", async (req, res) => {
const result = await getAllLoansV2();
if (result.success) {
return res.status(200).json(result.data);
}
return res.status(500).json({ message: "Failed to fetch loans" });
});
// Route for API to get ALL items from the database (only for landingpage)
router.get("/allItems", async (req, res) => {
const result = await getItemsFromDatabaseV2();
if (result.success) {
res.status(200).json(result.data);
} else {
res.status(500).json({ message: "Failed to fetch items" });
}
});
export default router;

View File

@@ -1,37 +0,0 @@
import express from "express";
import cors from "cors";
import env from "dotenv";
import apiRouter from "./routes/api.js";
import apiRouterV2 from "./routes/apiV2.js";
env.config();
const app = express();
const port = 8002;
import serverInfo from "./info.json" assert { type: "json" }
app.use(cors());
// Increase body size limits to support large CSV JSON payloads
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
app.set("view engine", "ejs");
app.use(express.json({ limit: "10mb" }));
app.use("/api", apiRouter);
app.use("/apiV2", apiRouterV2);
app.get("/", (req, res) => {
res.render("index.ejs");
});
app.get("/server-info", async (req, res) => {
res.status(200).json(serverInfo);
});
app.listen(port, () => {
console.log(`Server is running on port: ${port}`);
});
// error handling code
app.use((err, req, res, next) => {
// Log the error stack and send a generic error response
console.error(err.stack);
res.status(500).send("Something broke!");
});

View File

@@ -1,551 +0,0 @@
import mysql from "mysql2";
import dotenv from "dotenv";
dotenv.config();
const pool = mysql
.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
})
.promise();
export const loginFunc = async (username, password) => {
const [result] = await pool.query(
"SELECT * FROM users WHERE username = ? AND password = ?",
[username, password]
);
if (result.length > 0) return { success: true, data: result[0] };
return { success: false };
};
export const getItemsFromDatabaseV2 = async () => {
const [rows] = await pool.query("SELECT * FROM items;");
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};
export const getLoanByCodeV2 = async (loan_code) => {
const [result] = await pool.query(
"SELECT * FROM loans WHERE loan_code = ?;",
[loan_code]
);
if (result.length > 0) {
return { success: true, data: result[0] };
}
return { success: false };
};
export const changeInSafeStateV2 = async (itemId) => {
const [result] = await pool.query(
"UPDATE items SET in = NOT inSafe WHERE id = ?",
[itemId]
);
if (result.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
export const setReturnDateV2 = async (loanCode) => {
const [items] = await pool.query(
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
[loanCode]
);
if (items.length === 0) return { success: false };
const itemIds = Array.isArray(items[0].loaned_items_id)
? items[0].loaned_items_id
: JSON.parse(items[0].loaned_items_id || "[]");
const [setItemStates] = await pool.query(
"UPDATE items SET inSafe = 1 WHERE id IN (?)",
[itemIds]
);
const [result] = await pool.query(
"UPDATE loans SET returned_date = NOW() WHERE loan_code = ?",
[loanCode]
);
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
export const setTakeDateV2 = async (loanCode) => {
const [items] = await pool.query(
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
[loanCode]
);
if (items.length === 0) return { success: false };
const itemIds = Array.isArray(items[0].loaned_items_id)
? items[0].loaned_items_id
: JSON.parse(items[0].loaned_items_id || "[]");
const [setItemStates] = await pool.query(
"UPDATE items SET inSafe = 0 WHERE id IN (?)",
[itemIds]
);
const [result] = await pool.query(
"UPDATE loans SET take_date = NOW() WHERE loan_code = ?",
[loanCode]
);
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
export const getItemsFromDatabase = async (role) => {
const sql =
role == 0
? "SELECT * FROM items;"
: "SELECT * FROM items WHERE can_borrow_role >= ?";
const params = role == 0 ? [] : [role];
const [rows] = await pool.query(sql, params);
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};
export const getLoansFromDatabase = async () => {
const [rows] = await pool.query("SELECT * FROM loans;");
return { success: true, data: rows.length > 0 ? rows : null };
};
export const getUserLoansFromDatabase = async (username) => {
const [result] = await pool.query(
"SELECT * FROM loans WHERE username = ? AND deleted = 0;",
[username]
);
if (result.length > 0) {
return { success: true, data: result };
} else if (result.length == 0) {
return { success: true, data: "No loans found for this user" };
} else {
return { success: false };
}
};
export const deleteLoanFromDatabase = async (loanId) => {
const [result] = await pool.query("DELETE FROM loans WHERE id = ?;", [
loanId,
]);
if (result.affectedRows > 0) {
return { success: true };
} else {
return { success: false };
}
};
export const SETdeleteLoanFromDatabase = async (loanId) => {
const [result] = await pool.query(
"UPDATE loans SET deleted = 1 WHERE id = ?;",
[loanId]
);
if (result.affectedRows > 0) {
return { success: true };
} else {
return { success: false };
}
};
export const getBorrowableItemsFromDatabase = async (
startDate,
endDate,
role = 0
) => {
// Overlap if: loan.start < end AND effective_end > start
// effective_end is returned_date if set, otherwise end_date
const hasRoleFilter = Number(role) > 0;
const sql = `
SELECT i.*
FROM items i
WHERE ${hasRoleFilter ? "i.can_borrow_role >= ? AND " : ""}NOT EXISTS (
SELECT 1
FROM loans l
JOIN JSON_TABLE(l.loaned_items_id, '$[*]' COLUMNS (item_id INT PATH '$')) jt
WHERE jt.item_id = i.id
AND l.deleted = 0
AND l.start_date < ?
AND COALESCE(l.returned_date, l.end_date) > ?
);
`;
const params = hasRoleFilter
? [role, endDate, startDate]
: [endDate, startDate];
const [rows] = await pool.query(sql, params);
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};
export const getLoanInfoWithID = async (loanId) => {
const [rows] = await pool.query("SELECT * FROM loans WHERE id = ?;", [
loanId,
]);
if (rows.length > 0) {
return { success: true, data: rows[0] };
}
return { success: false };
};
export const createLoanInDatabase = async (
username,
startDate,
endDate,
itemIds
) => {
if (!username)
return { success: false, code: "BAD_REQUEST", message: "Missing username" };
if (!Array.isArray(itemIds) || itemIds.length === 0)
return {
success: false,
code: "BAD_REQUEST",
message: "No items provided",
};
if (!startDate || !endDate)
return { success: false, code: "BAD_REQUEST", message: "Missing dates" };
const start = new Date(startDate);
const end = new Date(endDate);
if (
!(start instanceof Date) ||
isNaN(start.getTime()) ||
!(end instanceof Date) ||
isNaN(end.getTime()) ||
start >= end
) {
return {
success: false,
code: "BAD_REQUEST",
message: "Invalid date range",
};
}
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
// Ensure all items exist and collect names
const [itemsRows] = await conn.query(
"SELECT id, item_name FROM items WHERE id IN (?)",
[itemIds]
);
if (!itemsRows || itemsRows.length !== itemIds.length) {
await conn.rollback();
return {
success: false,
code: "BAD_REQUEST",
message: "One or more items not found",
};
}
const itemNames = itemIds
.map(
(id) => itemsRows.find((r) => Number(r.id) === Number(id))?.item_name
)
.filter(Boolean);
// Check availability (no overlap with existing loans)
const [confRows] = await conn.query(
`
SELECT COUNT(*) AS conflicts
FROM loans l
JOIN JSON_TABLE(l.loaned_items_id, '$[*]' COLUMNS (item_id INT PATH '$')) jt
ON TRUE
WHERE jt.item_id IN (?)
AND l.deleted = 0
AND l.start_date < ?
AND COALESCE(l.returned_date, l.end_date) > ?
`,
[itemIds, end, start]
);
if (confRows?.[0]?.conflicts > 0) {
await conn.rollback();
return {
success: false,
code: "CONFLICT",
message: "One or more items are not available in the selected period",
};
}
// Generate unique loan_code (retry a few times)
let loanCode = null;
for (let i = 0; i < 6; i++) {
const candidate = Math.floor(100000 + Math.random() * 899999); // 6 digits
const [exists] = await conn.query(
"SELECT 1 FROM loans WHERE loan_code = ? LIMIT 1",
[candidate]
);
if (exists.length === 0) {
loanCode = candidate;
break;
}
}
if (!loanCode) {
await conn.rollback();
return {
success: false,
code: "SERVER_ERROR",
message: "Failed to generate unique loan code",
};
}
// Insert loan
const [insertRes] = await conn.query(
`
INSERT INTO loans (username, loan_code, start_date, end_date, loaned_items_id, loaned_items_name)
VALUES (?, ?, ?, ?, CAST(? AS JSON), CAST(? AS JSON))
`,
[
username,
loanCode,
// Use DATETIME/TIMESTAMP friendly format
new Date(start).toISOString().slice(0, 19).replace("T", " "),
new Date(end).toISOString().slice(0, 19).replace("T", " "),
JSON.stringify(itemIds.map((n) => Number(n))),
JSON.stringify(itemNames),
]
);
await conn.commit();
return {
success: true,
data: {
id: insertRes.insertId,
loan_code: loanCode,
username,
start_date: start,
end_date: end,
items: itemIds,
item_names: itemNames,
},
};
} catch (err) {
await conn.rollback();
console.error("createLoanInDatabase error:", err);
return {
success: false,
code: "SERVER_ERROR",
message: "Failed to create loan",
};
} finally {
conn.release();
}
};
// These functions are only temporary, and will be deleted when the full bin is set up.
export const onTake = async (loanId) => {
const [items] = await pool.query(
"SELECT loaned_items_id FROM loans WHERE id = ?",
[loanId]
);
if (items.length === 0) return { success: false };
const itemIds = Array.isArray(items[0].loaned_items_id)
? items[0].loaned_items_id
: JSON.parse(items[0].loaned_items_id || "[]");
const [setItemStates] = await pool.query(
"UPDATE items SET inSafe = 0 WHERE id IN (?)",
[itemIds]
);
const [result] = await pool.query(
"UPDATE loans SET take_date = NOW() WHERE id = ?",
[loanId]
);
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
export const onReturn = async (loanId) => {
const [items] = await pool.query(
"SELECT loaned_items_id FROM loans WHERE id = ?",
[loanId]
);
if (items.length === 0) return { success: false };
const itemIds = Array.isArray(items[0].loaned_items_id)
? items[0].loaned_items_id
: JSON.parse(items[0].loaned_items_id || "[]");
const [setItemStates] = await pool.query(
"UPDATE items SET inSafe = 1 WHERE id IN (?)",
[itemIds]
);
const [result] = await pool.query(
"UPDATE loans SET returned_date = NOW() WHERE id = ?",
[loanId]
);
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
// Temporary functions end here.
export const loginAdmin = async (username, password) => {
const [result] = await pool.query(
"SELECT * FROM admins WHERE username = ? AND password = ?",
[username, password]
);
if (result.length > 0) return { success: true, data: result[0] };
return { success: false };
};
export const getAllUsers = async () => {
const [result] = await pool.query(
"SELECT id, username, role, entry_created_at FROM users"
);
if (result.length > 0) return { success: true, data: result };
return { success: false };
};
export const deleteUserID = async (userId) => {
const [result] = await pool.query("DELETE FROM users WHERE id = ?", [userId]);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const handleEdit = async (userId, username, role) => {
const [result] = await pool.query(
"UPDATE users SET username = ?, role = ? WHERE id = ?",
[username, role, userId]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const createUser = async (username, role, password) => {
const [result] = await pool.query(
"INSERT INTO users (username, role, password) VALUES (?, ?, ?)",
[username, role, password]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const getAllLoans = async () => {
const [result] = await pool.query("SELECT * FROM loans");
if (result.length > 0) return { success: true, data: result };
return { success: false };
};
export const getAllItems = async () => {
const [result] = await pool.query("SELECT * FROM items");
if (result.length > 0) return { success: true, data: result };
return { success: false };
};
export const deleteItemID = async (itemId) => {
const [result] = await pool.query("DELETE FROM items WHERE id = ?", [itemId]);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const createItem = async (item_name, can_borrow_role) => {
const [result] = await pool.query(
"INSERT INTO items (item_name, can_borrow_role) VALUES (?, ?)",
[item_name, can_borrow_role]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const changeUserPassword = async (username, newPassword) => {
const [result] = await pool.query(
"UPDATE users SET password = ? WHERE username = ?",
[newPassword, username]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const changeUserPasswordFRONTEND = async (
username,
oldPassword,
newPassword
) => {
const [result] = await pool.query(
"UPDATE users SET password = ? WHERE username = ? AND password = ?",
[newPassword, username, oldPassword]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const updateItemByID = async (itemId, item_name, can_borrow_role) => {
const [result] = await pool.query(
"UPDATE items SET item_name = ?, can_borrow_role = ? WHERE id = ?",
[item_name, can_borrow_role, itemId]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const getAllLoansV2 = async () => {
const [rows] = await pool.query(
"SELECT id, username, start_date, end_date, loaned_items_name, returned_date, take_date FROM loans"
);
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};
export const getAllApiKeys = async () => {
const [rows] = await pool.query("SELECT * FROM apiKeys");
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};
export const createAPIentry = async (apiKey, user) => {
const [result] = await pool.query(
"INSERT INTO apiKeys (apiKey, user) VALUES (?, ?)",
[apiKey, user]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const deleteAPKey = async (apiKeyId) => {
const [result] = await pool.query("DELETE FROM apiKeys WHERE id = ?", [
apiKeyId,
]);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const getAPIkey = async () => {
const [rows] = await pool.query("SELECT apiKey FROM apiKeys");
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};

View File

@@ -1,25 +0,0 @@
import { SignJWT, jwtVerify } from "jose";
import env from "dotenv";
env.config();
const secret = new TextEncoder().encode(process.env.SECRET_KEY);
export async function generateToken(payload) {
const newToken = await new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("2h") // Token valid for 2 hours
.sign(secret);
return newToken;
}
export async function authenticate(req, res, next) {
const authHeader = req.headers["authorization"];
const token = authHeader && authHeader.split(" ")[1]; // Bearer <token>
if (token == null) return res.sendStatus(401); // No token present
const { payload } = await jwtVerify(token, secret);
req.user = payload;
next();
}

View File

@@ -1,11 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>backend</title>
</head>
<body>
backend
</body>
</html>

View File

@@ -1,11 +1,11 @@
{ {
"backend-info": { "backend-info": {
"version": "v2.0" "version": "v2.0.1"
}, },
"frontend-info": { "frontend-info": {
"version": "v2.0" "version": "v2.0"
}, },
"admin-panel-info": { "admin-panel-info": {
"version": "v1.2" "version": "v1.3"
} }
} }

View File

@@ -32,10 +32,22 @@ export const createItem = async (item_name, can_borrow_role, lockerNumber) => {
return { success: false }; return { success: false };
}; };
export const editItemById = async (itemId, item_name, can_borrow_role) => { export const editItemById = async (
itemId,
item_name,
can_borrow_role,
safe_nr,
door_key
) => {
let newSafeNr;
if (safe_nr === null || safe_nr === "") {
newSafeNr = null;
} else {
newSafeNr = safe_nr;
}
const [result] = await pool.query( const [result] = await pool.query(
"UPDATE items SET item_name = ?, can_borrow_role = ?, entry_updated_at = NOW() WHERE id = ?", "UPDATE items SET item_name = ?, can_borrow_role = ?, safe_nr = ?, door_key = ?, entry_updated_at = NOW() WHERE id = ?",
[item_name, can_borrow_role, itemId] [item_name, can_borrow_role, newSafeNr, door_key, itemId]
); );
if (result.affectedRows > 0) return { success: true }; if (result.affectedRows > 0) return { success: true };
return { success: false }; return { success: false };

View File

@@ -41,11 +41,14 @@ router.post("/create-item", authenticateAdmin, async (req, res) => {
router.post("/edit-item/:id", authenticateAdmin, async (req, res) => { router.post("/edit-item/:id", authenticateAdmin, async (req, res) => {
const itemId = req.params.id; const itemId = req.params.id;
const { item_name, can_borrow_role } = req.body; const { item_name, can_borrow_role, safe_nr, door_key } = req.body;
const result = await editItemById( const result = await editItemById(
itemId, itemId,
item_name, item_name,
can_borrow_role can_borrow_role,
safe_nr,
door_key
); );
if (result.success) { if (result.success) {
return res.status(200).json({ message: "Item edited successfully" }); return res.status(200).json({ message: "Item edited successfully" });

View File

@@ -114,3 +114,22 @@ export const getAllLoansV2 = async () => {
} }
return { success: false }; return { success: false };
}; };
export const openDoor = async (doorKey) => {
const [result] = await pool.query(
"SELECT safe_nr, id FROM items WHERE door_key = ?;",
[doorKey]
);
if (result.length > 0) {
const [changeItemSate] = await pool.query(
"UPDATE items SET in_safe = NOT in_safe WHERE id = ?",
[result[0].id]
);
if (changeItemSate.affectedRows > 0) {
return { success: true, data: result[0] };
} else {
return { success: false };
}
}
return { success: false };
};

View File

@@ -10,6 +10,7 @@ import {
setTakeDateV2, setTakeDateV2,
setReturnDateV2, setReturnDateV2,
getLoanByCodeV2, getLoanByCodeV2,
openDoor,
} from "./api.database.js"; } from "./api.database.js";
// Route for API to get all items from the database // Route for API to get all items from the database
@@ -79,4 +80,16 @@ router.post(
} }
); );
// Route for API to open a door
router.get("/open-door/:key/:doorKey", authenticate, async (req, res) => {
const doorKey = req.params.doorKey;
const result = await openDoor(doorKey);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to open door" });
}
});
export default router; export default router;

View File

@@ -69,12 +69,20 @@ export const createLoanInDatabase = async (
) )
.filter(Boolean); .filter(Boolean);
// Build lockers array (unique, only 2-digit strings) // Build lockers array (unique, only 2-digit numbers from safe_nr)
const lockers = [ const lockers = [
...new Set( ...new Set(
itemsRows itemsRows
.map((r) => r.safe_nr) .map((r) => r.safe_nr)
.filter((sn) => typeof sn === "string" && /^\d{2}$/.test(sn)) .filter(
(sn) =>
sn !== null &&
sn !== undefined &&
Number.isInteger(Number(sn)) &&
Number(sn) >= 0 &&
Number(sn) <= 99
)
.map((sn) => Number(sn))
), ),
]; ];

View File

@@ -2,6 +2,38 @@ import nodemailer from "nodemailer";
import dotenv from "dotenv"; import dotenv from "dotenv";
dotenv.config(); dotenv.config();
const formatDateTime = (value) => {
if (value == null) return "N/A";
const toOut = (d) => {
if (!(d instanceof Date) || isNaN(d.getTime())) return "N/A";
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
const hh = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${dd}.${mm}.${yyyy} ${hh}:${mi} Uhr`;
};
if (value instanceof Date) return toOut(value);
if (typeof value === "number") return toOut(new Date(value));
const s = String(value).trim();
// Direct pattern: "YYYY-MM-DD[ T]HH:mm[:ss]"
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::\d{2})?/);
if (m) {
const [, y, M, d, h, min] = m;
return `${d}.${M}.${y} ${h}:${min} Uhr`;
}
// ISO or other parseable formats
const dObj = new Date(s);
if (!isNaN(dObj.getTime())) return toOut(dObj);
return "N/A";
};
function buildLoanEmail({ user, items, startDate, endDate, createdDate }) { function buildLoanEmail({ user, items, startDate, endDate, createdDate }) {
const brand = process.env.MAIL_BRAND_COLOR || "#0ea5e9"; const brand = process.env.MAIL_BRAND_COLOR || "#0ea5e9";
const itemsList = const itemsList =
@@ -142,7 +174,8 @@ export function sendMailLoan(user, items, startDate, endDate, createdDate) {
html: buildLoanEmail({ user, items, startDate, endDate, createdDate }), html: buildLoanEmail({ user, items, startDate, endDate, createdDate }),
}); });
console.log("Message sent:", info.messageId); // debugging logs
// console.log("Message sent:", info.messageId);
})(); })();
console.log("sendMailLoan called"); // console.log("sendMailLoan called");
} }

Binary file not shown.

View File

@@ -37,13 +37,13 @@ CREATE TABLE items (
item_name varchar(255) NOT NULL UNIQUE, item_name varchar(255) NOT NULL UNIQUE,
can_borrow_role INT NOT NULL, can_borrow_role INT NOT NULL,
in_safe bool NOT NULL DEFAULT true, in_safe bool NOT NULL DEFAULT true,
safe_nr CHAR(2) DEFAULT NULL UNIQUE, safe_nr INT DEFAULT NULL UNIQUE,
door_key INT DEFAULT NULL UNIQUE,
entry_created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, entry_created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
entry_updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, entry_updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
last_borrowed_person varchar(255) DEFAULT NULL, last_borrowed_person varchar(255) DEFAULT NULL,
currently_borrowing varchar(255) DEFAULT NULL, currently_borrowing varchar(255) DEFAULT NULL,
PRIMARY KEY (id), PRIMARY KEY (id)
CHECK (safe_nr REGEXP '^[0-9]{2}$' OR safe_nr IS NULL)
) ENGINE=InnoDB; ) ENGINE=InnoDB;
CREATE TABLE apiKeys ( CREATE TABLE apiKeys (

View File

@@ -20,7 +20,7 @@ import apiRouter from "./routes/api/api.route.js";
env.config(); env.config();
const app = express(); const app = express();
const port = 8004; const port = 8102;
app.use(cors()); app.use(cors());
// Body-Parser VOR den Routen registrieren // Body-Parser VOR den Routen registrieren

View File

@@ -1,6 +1,8 @@
services: services:
usr-frontend_v2: usr-frontend_v2:
container_name: borrow_system-usr-frontend container_name: borrow_system-usr-frontend
networks:
- proxynet
build: ./FrontendV2 build: ./FrontendV2
ports: ports:
- "8101:80" - "8101:80"
@@ -8,36 +10,18 @@ services:
admin-frontend: admin-frontend:
container_name: borrow_system-admin-frontend container_name: borrow_system-admin-frontend
networks:
- proxynet
build: ./admin build: ./admin
ports: ports:
- "8103:80" - "8103:80"
restart: unless-stopped restart: unless-stopped
#backend:
# container_name: borrow_system-backend
# build: ./backend
# ports:
# - "8002:8002"
# environment:
# NODE_ENV: production
# DB_HOST: mysql
# DB_USER: root
# DB_PASSWORD: ${DB_PASSWORD}
# DB_NAME: borrow_system
# depends_on:
# - mysql
# restart: unless-stopped
# healthcheck:
# test: ["CMD", "wget", "-qO-", "http://localhost:8002/server-info"]
# interval: 30s
# timeout: 5s
# retries: 3
backend_v2: backend_v2:
container_name: borrow_system-backend_v2 container_name: borrow_system-backend_v2
networks:
- proxynet
build: ./backendV2 build: ./backendV2
ports:
- "8102:8102"
environment: environment:
NODE_ENV: production NODE_ENV: production
DB_HOST: mysql_v2 DB_HOST: mysql_v2
@@ -48,22 +32,10 @@ services:
- mysql_v2 - mysql_v2
restart: unless-stopped restart: unless-stopped
# mysql:
# container_name: borrow_system-mysql
# image: mysql:8.0
# restart: unless-stopped
# environment:
# MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
# MYSQL_DATABASE: borrow_system
# TZ: Europe/Berlin
# volumes:
# - mysql-data:/var/lib/mysql
# - ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro
# ports:
# - "3309:3306"
mysql_v2: mysql_v2:
container_name: borrow_system-mysql-v2 container_name: borrow_system-mysql-v2
networks:
- proxynet
image: mysql:8.0 image: mysql:8.0
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -73,9 +45,11 @@ services:
volumes: volumes:
- mysql-v2-data:/var/lib/mysql - mysql-v2-data:/var/lib/mysql
- ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro - ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro
ports:
- "3310:3306"
volumes: volumes:
mysql-data: mysql-data:
mysql-v2-data: mysql-v2-data:
networks:
proxynet:
external: true