11 Commits

27 changed files with 520 additions and 417 deletions

View File

@@ -1,366 +1,177 @@
# Borrow System API Documentation # Borrow System API Documentation
**Frontend:** https://insta.the1s.de ## Overview
**Backend base URL:** `https://insta.the1s.de/backend/api`
--- The Borrow System API provides endpoints for managing items, loans, and door access for a borrowing/locker system. All endpoints require authentication via an 8-digit API key passed as a URL parameter.
## Authentication ## Authentication
All API endpoints require **either**: All requests must include a valid API key in the URL path as the `:key` parameter. API keys are 8-digit numeric strings.
### 1. Bearer Token (JWT)
Send an `Authorization` header:
```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:
```http
GET /api/items/12345678
```
Where `12345678` is your API key.
The API key is validated server-side.
---
## Common Response Codes
- `200 OK` Request was successful.
- `401 Unauthorized` Missing or malformed credentials.
- `403 Forbidden` Credentials invalid or not allowed to access this resource.
- `404 Not Found` Resource (e.g., loan) not found.
- `500 Internal Server Error` Unexpected server error.
---
## Endpoints ## Endpoints
### 1. Get All Items The Base URL for all endpoints is: `https://insta.the1s.de/backend/api`
**GET** `/api/items/:key` ### Get All Items
Returns a list of all items. `GET /items/:key`
#### Path Parameters Returns all items in the system.
- `:key` API key (8-digit number) **Response 200:**
#### Authentication
- Either:
- Valid `Authorization: Bearer <token>`
- Or valid `:key` path parameter
#### Request Example
```http
GET /api/items/12345678 HTTP/1.1
Host: backend.insta.the1s.de
Authorization: Bearer <JWT_TOKEN>
```
#### Successful Response (200)
```json ```json
{ {
"data": [ "data": [
{ {
"id": 1, "id": 1,
"item_name": "DJI 1er Mikro", "item_name": "Laptop",
"can_borrow_role": 4, "can_borrow_role": 1,
"inSafe": 1, "in_safe": true,
"safe_nr": 3, "safe_nr": 3,
"door_key": "123", "door_key": 101,
"entry_created_at": "2025-08-19T22:02:16.000Z", "last_borrowed_person": "jdoe",
"entry_updated_at": "2025-08-19T22:02:16.000Z",
"last_borrowed_person": "alice",
"currently_borrowing": null "currently_borrowing": null
} }
] ]
} }
``` ```
#### Error Response (500) **Response 500:**
```json ```json
{ { "message": "Failed to fetch items" }
"message": "Failed to fetch items"
}
``` ```
--- ---
### 2. Toggle Item Safe State ### Change Item Safe State
Toggles `in_safe` between `0` and `1` for a given item. `POST /change-state/:key/:itemId`
**Keep in mind that when you return a loan by code, the item states are automatically updated.** Toggles the `in_safe` boolean state of an item.
**POST** `/api/change-state/:key/:itemId` **URL Parameters:**
#### Path Parameters - **key** - API key
- **itemId** - The item's ID
- `:key` API key (8-digit number) **Response 200:** Returns on successful toggle.
- `:itemId` Item ID (integer)
#### Authentication **Response 500:**
- Either Bearer token or `:key` API key.
#### Request Example
```http
POST /api/change-state/12345678/42 HTTP/1.1
Host: backend.insta.the1s.de
```
#### Successful Response (200)
```json ```json
{ { "message": "Failed to update item state" }
"data": {}
}
```
_(Implementation currently only returns `{ success: true }`, so `data` may be empty.)_
#### Error Response (500)
```json
{
"message": "Failed to update item state"
}
``` ```
--- ---
### 3. Get Loan by Code ### Get Loan by Code
Fetch loan information by `loan_code`. `GET /get-loan-by-code/:key/:loan_code`
**GET** `/api/get-loan-by-code/:key/:loan_code` Retrieves loan details by its 6-digit loan code.
#### Path Parameters **URL Parameters:**
- `:key` API key (8-digit number) - **key** - API key
- `:loan_code` Loan code (string) - **loan_code** - A 6-digit numeric loan code
#### Authentication **Response 200:**
- Either Bearer token or `:key` API key.
#### Request Example
```http
GET /api/get-loan-by-code/12345678/12345 HTTP/1.1
Host: backend.insta.the1s.de
```
#### Successful Response (200)
```json ```json
{ {
"data": { "data": {
"username": "john", "username": "jdoe",
"returned_date": null, "returned_date": null,
"take_date": "2025-01-01T10:00:00.000Z", "take_date": "2024-01-15T10:30:00.000Z",
"lockers": "[1, 2, 3]" "lockers": [1, 3]
} }
} }
``` ```
#### Error Response (404) **Response 404:**
```json ```json
{ { "message": "Loan not found" }
"message": "Loan not found"
}
``` ```
--- ---
### 4. Set Loan Return Date ### Set Take Date
Sets `returned_date = NOW()` on a loan and updates related items: `POST /set-take-date/:key/:loan_code`
- `in_safe = 1` Records when items are physically taken by setting `take_date` to the current timestamp. Updates associated items to `in_safe = false` and sets `currently_borrowing` to the loan's username.
- `currently_borrowing = NULL`
- `last_borrowed_person = username`
**POST** `/api/set-return-date/:key/:loan_code` **URL Parameters:**
#### Path Parameters - **key** - API key
- **loan_code** - A 6-digit numeric loan code
- `:key` API key (8-digit number) **Response 200:** Empty JSON object on success.
- `:loan_code` Loan code (string)
#### Authentication **Response 500:**
- Either Bearer token or `:key` API key.
#### Request Example
```http
POST /api/set-return-date/12345678/12345 HTTP/1.1
Host: backend.insta.the1s.de
```
#### Successful Response (200)
```json ```json
{ { "message": "Loan not found or already taken" }
"data": {}
}
``` ```
#### Error Response (500) > **Note:** This endpoint will fail if the loan has already been taken or does not exist.
```json
{
"message": "Failed to set return date"
}
```
--- ---
### 5. Set Loan Take Date ### Set Return Date
Sets `take_date = NOW()` on a loan and updates related items: `POST /set-return-date/:key/:loan_code`
- `in_safe = 0` Marks a loan as returned by setting `returned_date` to the current timestamp. Also updates all associated items to `in_safe = true`, clears `currently_borrowing`, and sets `last_borrowed_person`. Therefore, keep in mind that you must not call other endpoints that will change the safe state of an item after or before calling this endpoint, otherwise the state of the items will be inconsistent.
- `currently_borrowing = username`
**POST** `/api/set-take-date/:key/:loan_code` **URL Parameters:**
#### Path Parameters - **key** - API key
- **loan_code** - A 6-digit numeric loan code
- `:key` API key (8-digit number) **Response 200:** Empty JSON object on success.
- `:loan_code` Loan code (string)
#### Authentication **Response 500:**
- 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
{ { "message": "Failed to set return date" }
"data": {}
}
``` ```
#### Error Response (500) > **Note:** This endpoint will fail if the loan has already been returned (i.e., `returned_date` is not `NULL`).
```json
{
"message": "Failed to set take date"
}
```
--- ---
### 6. Open Door by Door Key ### Open Door
Looks up an item by its `door_key`, toggles `in_safe`, and returns safe information. `GET /open-door/:key/:doorKey`
**GET** `/api/open-door/:key/:doorKey` Toggles the safe state of an item identified by its door key and returns the associated safe number.
#### Path Parameters **URL Parameters:**
- `:key` API key (8-digit number) - **key** - API key
- `:doorKey` Door key/token (string) used by hardware to identify the locker. - **doorKey** - The door key identifier assigned to an item
#### Authentication **Response 200:**
- 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": {
"safe_nr": 5, "safe_nr": 3,
"id": 42 "id": 1
} }
} }
``` ```
#### Error Response (500) **Response 500:**
```json ```json
{ { "message": "Failed to open door" }
"message": "Failed to open door"
}
``` ```
--- ## Error Handling
## Authentication Error Messages All endpoints return a `500` status code for server-side failures and a JSON body with a `message` field, except for **Get Loan by Code** which returns `404` when no matching loan is found.
### Missing credentials
Status: `401`
```json
{
"message": "Unauthorized"
}
```
### Invalid JWT
Status: `403`
```json
{
"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

@@ -14,7 +14,7 @@ server {
} }
location /backend/ { location /backend/ {
proxy_pass http://demo_borrow_system-backend_v2:8102/; proxy_pass http://borrow_system-backend_v2:8004/;
} }
location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {

View File

@@ -12,10 +12,11 @@ import { triggerLogoutAtom } from "@/states/Atoms";
import { MyLoansPage } from "./pages/MyLoansPage"; import { MyLoansPage } from "./pages/MyLoansPage";
import Landingpage from "./pages/Landingpage"; import Landingpage from "./pages/Landingpage";
import { changeLanguage } from "i18next"; import { changeLanguage } from "i18next";
import { Box, Flex } from "@chakra-ui/react"; import { Flex } from "@chakra-ui/react";
import { Footer } from "./components/footer/Footer"; import { Footer } from "./components/footer/Footer";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { API_BASE } from "@/config/api.config"; import { API_BASE } from "@/config/api.config";
import { ContactPage } from "./pages/ContactPage";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -71,8 +72,8 @@ function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Flex direction="column" minH="100vh"> <Flex direction="column" minH="100dvh">
<Box as="main" flex="1"> <Flex as="main" flex="1" direction="column">
<UserContext.Provider value={user}> <UserContext.Provider value={user}>
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
@@ -80,13 +81,14 @@ function App() {
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/my-loans" element={<MyLoansPage />} /> <Route path="/my-loans" element={<MyLoansPage />} />
<Route path="/landingpage" element={<Landingpage />} /> <Route path="/landingpage" element={<Landingpage />} />
<Route path="/contact" element={<ContactPage />} />
</Route> </Route>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</UserContext.Provider> </UserContext.Provider>
</Box> </Flex>
<Footer /> <Footer />
</Flex> </Flex>
</QueryClientProvider> </QueryClientProvider>

View File

@@ -22,6 +22,7 @@ import {
MoreVertical, MoreVertical,
Languages, Languages,
Table, Table,
ContactRound,
} from "lucide-react"; } from "lucide-react";
import { useUserContext } from "@/states/Context"; import { useUserContext } from "@/states/Context";
import { useState } from "react"; import { useState } from "react";
@@ -141,7 +142,7 @@ export const Header = () => {
value="help" value="help"
onSelect={() => onSelect={() =>
window.open( window.open(
"https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki", "https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki/?action=_pages",
"_blank", "_blank",
"noopener,noreferrer", "noopener,noreferrer",
) )
@@ -153,6 +154,16 @@ export const Header = () => {
</HStack> </HStack>
} }
/> />
<Menu.Item
value="contact"
onSelect={() => navigate("/contact", { replace: true })}
children={
<HStack gap={3}>
<ContactRound size={16} />
<Text as="span">{t("contact")}</Text>
</HStack>
}
/>
<Menu.Separator /> <Menu.Separator />
<Menu.Item <Menu.Item
value="logout" value="logout"
@@ -268,7 +279,7 @@ export const Header = () => {
</Button> </Button>
<a <a
href="https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki" href="https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki/?action=_pages"
target="_blank" target="_blank"
> >
<Button variant="ghost"> <Button variant="ghost">
@@ -278,6 +289,17 @@ export const Header = () => {
</HStack> </HStack>
</Button> </Button>
</a> </a>
<Button
variant={"outline"}
onClick={() => navigate("/contact", { replace: true })}
>
<HStack gap={2}>
<ContactRound size={18} />
<Text as="span">{t("contact")}</Text>
</HStack>
</Button>
<Button onClick={logout} variant="outline" colorScheme="red"> <Button onClick={logout} variant="outline" colorScheme="red">
<HStack gap={2}> <HStack gap={2}>
<LogOut size={18} /> <LogOut size={18} />

View File

@@ -5,7 +5,14 @@ export const Footer = () => {
const { data: info } = useVersionInfoQuery(); const { data: info } = useVersionInfoQuery();
return ( return (
<Box as="footer" py={4} textAlign="center" width="100%"> <Box
as="footer"
py={4}
textAlign="center"
width="100%"
flexShrink={0}
fontSize="sm"
>
Made with by Theis Gaedigk - Class of 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"} |

View File

@@ -1,23 +1,15 @@
"use client"; "use client"
import { ChakraProvider, defaultSystem } from "@chakra-ui/react"; import { ChakraProvider, defaultSystem } from "@chakra-ui/react"
import * as React from "react"; import {
import type { ReactNode } from "react"; ColorModeProvider,
import { ColorModeProvider as ThemeColorModeProvider } from "./color-mode"; type ColorModeProviderProps,
} from "./color-mode"
export interface ColorModeProviderProps { export function Provider(props: 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>{children}</ColorModeProvider> <ColorModeProvider {...props} />
</ChakraProvider> </ChakraProvider>
); )
} }

View File

@@ -0,0 +1,84 @@
import {
Field,
Textarea,
Button,
Alert,
Container,
Text,
} from "@chakra-ui/react";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import { API_BASE } from "@/config/api.config";
import Cookies from "js-cookie";
import { Header } from "@/components/Header";
interface Alert {
type: "info" | "warning" | "success" | "error" | "neutral";
headline: string;
text: string;
}
export const ContactPage = () => {
const { t } = useTranslation();
const [message, setMessage] = useState("");
const [alert, setAlert] = useState<Alert | null>(null);
const sendMessage = async () => {
// Logic to send the message
const result = await fetch(`${API_BASE}/api/users/contact`, {
method: "POST",
headers: {
Authorization: `Bearer ${Cookies.get("token") || ""}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ message }),
});
if (result.ok) {
setAlert({
type: "success",
headline: t("contactPage_successHeadline"),
text: t("contactPage_successText"),
});
setMessage("");
} else {
setAlert({
type: "error",
headline: t("contactPage_errorHeadline"),
text: t("contactPage_errorText"),
});
}
};
return (
<Container className="px-6 sm:px-8 pt-10">
<Header />
<Field.Root invalid={message === ""}>
<Field.Label>
<Text>{t("contactPage_messageDescription")}</Text>
<Field.RequiredIndicator />
</Field.Label>
<Textarea
placeholder={t("contactPage_messagePlaceholder")}
variant="subtle"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
{message === "" && (
<Field.ErrorText>{t("contactPage_messageErrorText")}</Field.ErrorText>
)}
</Field.Root>
{alert && (
<Alert.Root status={alert.type}>
<Alert.Indicator />
<Alert.Content>
<Alert.Title>{alert.headline}</Alert.Title>
<Alert.Description>{alert.text}</Alert.Description>
</Alert.Content>
</Alert.Root>
)}
<Button onClick={sendMessage}>{t("contactPage_sendButton")}</Button>
</Container>
);
};

View File

@@ -9,12 +9,13 @@ import {
Card, Card,
SimpleGrid, SimpleGrid,
Button, Button,
Container,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import MyAlert from "@/components/myChakra/MyAlert"; import MyAlert from "@/components/myChakra/MyAlert";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { API_BASE } from "@/config/api.config"; import { API_BASE } from "@/config/api.config";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { useNavigate } from "react-router-dom"; import { Header } from "@/components/Header";
export const formatDateTime = (value: string | null | undefined) => { export const formatDateTime = (value: string | null | undefined) => {
if (!value) return "N/A"; if (!value) return "N/A";
@@ -32,6 +33,7 @@ type Loan = {
returned_date: string | null; returned_date: string | null;
take_date: string | null; take_date: string | null;
loaned_items_name: string[] | string; loaned_items_name: string[] | string;
note: string | null;
}; };
type Device = { type Device = {
@@ -46,7 +48,6 @@ type Device = {
const Landingpage: React.FC = () => { const Landingpage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [loans, setLoans] = useState<Loan[]>([]); const [loans, setLoans] = useState<Loan[]>([]);
@@ -59,7 +60,7 @@ const Landingpage: React.FC = () => {
const setError = ( const setError = (
status: "error" | "success", status: "error" | "success",
message: string, message: string,
description: string description: string,
) => { ) => {
setIsError(false); setIsError(false);
setErrorStatus(status); setErrorStatus(status);
@@ -85,7 +86,7 @@ const Landingpage: React.FC = () => {
setError( setError(
"error", "error",
t("error-by-loading"), t("error-by-loading"),
t("unexpected-date-format_loan") t("unexpected-date-format_loan"),
); );
} }
@@ -102,7 +103,7 @@ const Landingpage: React.FC = () => {
setError( setError(
"error", "error",
t("error-by-loading"), t("error-by-loading"),
t("unexpected-date-format_device") t("unexpected-date-format_device"),
); );
} }
} catch (e) { } catch (e) {
@@ -115,14 +116,8 @@ const Landingpage: React.FC = () => {
}, []); }, []);
return ( return (
<> <Container className="px-6 sm:px-8 pt-10">
<Heading as="h1" size="lg" mb={2}> <Header />
Matthias-Claudius-Schule Technik
</Heading>
<Button onClick={() => navigate("/", { replace: true })}>
{t("back")}
</Button>
<Heading as="h2" size="md" mb={4}> <Heading as="h2" size="md" mb={4}>
{t("all-loans")} {t("all-loans")}
@@ -168,6 +163,9 @@ const Landingpage: React.FC = () => {
<Table.ColumnHeader> <Table.ColumnHeader>
<strong>{t("return-date")}</strong> <strong>{t("return-date")}</strong>
</Table.ColumnHeader> </Table.ColumnHeader>
<Table.ColumnHeader>
<strong>{t("note")}</strong>
</Table.ColumnHeader>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
@@ -184,6 +182,7 @@ const Landingpage: React.FC = () => {
</Table.Cell> </Table.Cell>
<Table.Cell>{formatDateTime(loan.take_date)}</Table.Cell> <Table.Cell>{formatDateTime(loan.take_date)}</Table.Cell>
<Table.Cell>{formatDateTime(loan.returned_date)}</Table.Cell> <Table.Cell>{formatDateTime(loan.returned_date)}</Table.Cell>
<Table.Cell>{loan.note}</Table.Cell>
</Table.Row> </Table.Row>
))} ))}
</Table.Body> </Table.Body>
@@ -260,7 +259,7 @@ const Landingpage: React.FC = () => {
</HStack> </HStack>
</Button> </Button>
</HStack> </HStack>
</> </Container>
); );
}; };

View File

@@ -7,7 +7,6 @@ import Cookies from "js-cookie";
import { Navigate, useNavigate, useLocation } from "react-router-dom"; import { Navigate, useNavigate, useLocation } from "react-router-dom";
import { PasswordInput } from "@/components/ui/password-input"; import { PasswordInput } from "@/components/ui/password-input";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Footer } from "@/components/footer/Footer";
import { API_BASE } from "@/config/api.config"; import { API_BASE } from "@/config/api.config";
export const LoginPage = () => { export const LoginPage = () => {
@@ -71,7 +70,7 @@ export const LoginPage = () => {
} }
return ( return (
<div className="min-h-screen flex items-center justify-center p-4"> <div className="flex flex-1 items-center justify-center p-4">
<form onSubmit={(e) => e.preventDefault()}> <form onSubmit={(e) => e.preventDefault()}>
<Card.Root maxW="sm"> <Card.Root maxW="sm">
<Card.Header> <Card.Header>
@@ -115,7 +114,6 @@ export const LoginPage = () => {
</Card.Footer> </Card.Footer>
</Card.Root> </Card.Root>
</form> </form>
<Footer />
</div> </div>
); );
}; };

View File

@@ -1,13 +1,10 @@
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { API_BASE } from "@/config/api.config"; import { API_BASE } from "@/config/api.config";
import { useTranslation } from "react-i18next";
export const getBorrowableItems = async ( export const getBorrowableItems = async (
startDate: string, startDate: string,
endDate: string, endDate: string,
) => { ) => {
const { t } = useTranslation();
try { try {
const response = await fetch(`${API_BASE}/api/loans/borrowable-items`, { const response = await fetch(`${API_BASE}/api/loans/borrowable-items`, {
method: "POST", method: "POST",
@@ -24,7 +21,8 @@ export const getBorrowableItems = async (
data: null, data: null,
status: "error", status: "error",
title: "Server error", title: "Server error",
description: t("serverError"), description:
"An error occurred on the server. Sometimes reloading the page helps. Otherwise, please contact the administrator.",
}; };
} }

View File

@@ -68,7 +68,7 @@
"admin-status": "Admin-Status", "admin-status": "Admin-Status",
"first-name": "Vorname", "first-name": "Vorname",
"last-name": "Nachname", "last-name": "Nachname",
"app-title": "Ausleihsystem (demo)", "app-title": "Ausleihsystem",
"last-borrowed-person": "Zuletzt ausgeliehen von", "last-borrowed-person": "Zuletzt ausgeliehen von",
"currently-borrowed-by": "Derzeit ausgeliehen von", "currently-borrowed-by": "Derzeit ausgeliehen von",
"back": "Zurückgehen", "back": "Zurückgehen",

View File

@@ -68,7 +68,7 @@
"admin-status": "Admin status", "admin-status": "Admin status",
"first-name": "First name", "first-name": "First name",
"last-name": "Last name", "last-name": "Last name",
"app-title": "Borrow System (demo)", "app-title": "Borrow System",
"last-borrowed-person": "Last borrowed by", "last-borrowed-person": "Last borrowed by",
"currently-borrowed-by": "Currently borrowed by", "currently-borrowed-by": "Currently borrowed by",
"back": "Go back", "back": "Go back",

View File

@@ -1,23 +1,16 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import svgr from "vite-plugin-svgr";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import path from "node:path"; import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss()], plugins: [react(), svgr(), tailwindcss(), tsconfigPaths()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
server: { server: {
host: "0.0.0.0", host: "0.0.0.0",
allowedHosts: ["insta.the1s.de"], port: 8001,
port: 8101, watch: {
watch: { usePolling: true }, usePolling: true,
hmr: {
host: "insta.the1s.de",
port: 8101,
protocol: "wss",
}, },
}, },
}); });

View File

@@ -14,7 +14,7 @@ server {
} }
location /backend/ { location /backend/ {
proxy_pass http://demo_borrow_system-backend_v2:8102/; proxy_pass http://borrow_system-backend_v2:8004/;
} }
location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {

View File

@@ -3,6 +3,7 @@ import { useState } from "react";
import { loginFunc } from "@/utils/loginUser"; import { loginFunc } from "@/utils/loginUser";
import MyAlert from "../components/myChakra/MyAlert"; import MyAlert from "../components/myChakra/MyAlert";
import { Button, Card, Field, Input, Stack } from "@chakra-ui/react"; import { Button, Card, Field, Input, Stack } from "@chakra-ui/react";
import { PasswordInput } from "@/components/ui/password-input";
const Login: React.FC<{ onSuccess: () => void }> = ({ onSuccess }) => { const Login: React.FC<{ onSuccess: () => void }> = ({ onSuccess }) => {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
@@ -43,8 +44,7 @@ const Login: React.FC<{ onSuccess: () => void }> = ({ onSuccess }) => {
</Field.Root> </Field.Root>
<Field.Root> <Field.Root>
<Field.Label>password</Field.Label> <Field.Label>password</Field.Label>
<Input <PasswordInput
type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
/> />

View File

@@ -0,0 +1,159 @@
"use client"
import type {
ButtonProps,
GroupProps,
InputProps,
StackProps,
} from "@chakra-ui/react"
import {
Box,
HStack,
IconButton,
Input,
InputGroup,
Stack,
mergeRefs,
useControllableState,
} from "@chakra-ui/react"
import * as React from "react"
import { LuEye, LuEyeOff } from "react-icons/lu"
export interface PasswordVisibilityProps {
/**
* The default visibility state of the password input.
*/
defaultVisible?: boolean
/**
* The controlled visibility state of the password input.
*/
visible?: boolean
/**
* Callback invoked when the visibility state changes.
*/
onVisibleChange?: (visible: boolean) => void
/**
* Custom icons for the visibility toggle button.
*/
visibilityIcon?: { on: React.ReactNode; off: React.ReactNode }
}
export interface PasswordInputProps
extends InputProps,
PasswordVisibilityProps {
rootProps?: GroupProps
}
export const PasswordInput = React.forwardRef<
HTMLInputElement,
PasswordInputProps
>(function PasswordInput(props, ref) {
const {
rootProps,
defaultVisible,
visible: visibleProp,
onVisibleChange,
visibilityIcon = { on: <LuEye />, off: <LuEyeOff /> },
...rest
} = props
const [visible, setVisible] = useControllableState({
value: visibleProp,
defaultValue: defaultVisible || false,
onChange: onVisibleChange,
})
const inputRef = React.useRef<HTMLInputElement>(null)
return (
<InputGroup
endElement={
<VisibilityTrigger
disabled={rest.disabled}
onPointerDown={(e) => {
if (rest.disabled) return
if (e.button !== 0) return
e.preventDefault()
setVisible(!visible)
}}
>
{visible ? visibilityIcon.off : visibilityIcon.on}
</VisibilityTrigger>
}
{...rootProps}
>
<Input
{...rest}
ref={mergeRefs(ref, inputRef)}
type={visible ? "text" : "password"}
/>
</InputGroup>
)
})
const VisibilityTrigger = React.forwardRef<HTMLButtonElement, ButtonProps>(
function VisibilityTrigger(props, ref) {
return (
<IconButton
tabIndex={-1}
ref={ref}
me="-2"
aspectRatio="square"
size="sm"
variant="ghost"
height="calc(100% - {spacing.2})"
aria-label="Toggle password visibility"
{...props}
/>
)
},
)
interface PasswordStrengthMeterProps extends StackProps {
max?: number
value: number
}
export const PasswordStrengthMeter = React.forwardRef<
HTMLDivElement,
PasswordStrengthMeterProps
>(function PasswordStrengthMeter(props, ref) {
const { max = 4, value, ...rest } = props
const percent = (value / max) * 100
const { label, colorPalette } = getColorPalette(percent)
return (
<Stack align="flex-end" gap="1" ref={ref} {...rest}>
<HStack width="full" {...rest}>
{Array.from({ length: max }).map((_, index) => (
<Box
key={index}
height="1"
flex="1"
rounded="sm"
data-selected={index < value ? "" : undefined}
layerStyle="fill.subtle"
colorPalette="gray"
_selected={{
colorPalette,
layerStyle: "fill.solid",
}}
/>
))}
</HStack>
{label && <HStack textStyle="xs">{label}</HStack>}
</Stack>
)
})
function getColorPalette(percent: number) {
switch (true) {
case percent < 33:
return { label: "Low", colorPalette: "red" }
case percent < 66:
return { label: "Medium", colorPalette: "orange" }
default:
return { label: "High", colorPalette: "green" }
}
}

View File

@@ -1,10 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ESNext", "target": "ES2022",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */
@@ -23,13 +24,10 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true, "noUncheckedSideEffectImports": true,
/* Chakra / Pfad Aliases */ /* Path aliases */
"baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
}, }
"forceConsistentCasingInFileNames": true
}, },
"include": ["src"] "include": ["src"]
} }

View File

@@ -8,13 +8,9 @@ export default defineConfig({
plugins: [react(), svgr(), tailwindcss(), tsconfigPaths()], plugins: [react(), svgr(), tailwindcss(), tsconfigPaths()],
server: { server: {
host: "0.0.0.0", host: "0.0.0.0",
allowedHosts: ["admin.insta.the1s.de"], port: 8003,
port: 8103, watch: {
watch: { usePolling: true }, usePolling: true,
hmr: {
host: "admin.insta.the1s.de",
port: 8103,
protocol: "wss",
}, },
}, },
}); });

View File

@@ -1,9 +1,9 @@
{ {
"backend-info": { "backend-info": {
"version": "v2.1 (demo)" "version": "v2.1.1 (demo)"
}, },
"frontend-info": { "frontend-info": {
"version": "v2.1 (demo)" "version": "v2.1.2 (demo)"
}, },
"admin-panel-info": { "admin-panel-info": {
"version": "v1.3.2 (demo)" "version": "v1.3.2 (demo)"

View File

@@ -29,14 +29,14 @@ export const createUser = async (
}; };
export const deleteUserById = async (userId) => { export const deleteUserById = async (userId) => {
const [result] = await pool.query("DELETE FROM users WHERE id = ? AND secret_user = false", [userId]); const [result] = await pool.query("DELETE FROM users WHERE id = ?", [userId]);
if (result.affectedRows > 0) return { success: true }; if (result.affectedRows > 0) return { success: true };
return { success: false }; return { success: false };
}; };
export const changePassword = async (username, newPassword) => { export const changePassword = async (username, newPassword) => {
const [result] = await pool.query( const [result] = await pool.query(
"UPDATE users SET password = ?, entry_updated_at = NOW() WHERE username = ? AND secret_user = false", "UPDATE users SET password = ?, entry_updated_at = NOW() WHERE username = ?",
[newPassword, username], [newPassword, username],
); );
if (result.affectedRows > 0) return { success: true }; if (result.affectedRows > 0) return { success: true };
@@ -52,7 +52,7 @@ export const editUserById = async (
is_admin, is_admin,
) => { ) => {
const [result] = await pool.query( const [result] = await pool.query(
"UPDATE users SET first_name = ?, last_name = ?, role = ?, email = ?, is_admin = ?, entry_updated_at = NOW() WHERE id = ? AND secret_user = false", "UPDATE users SET first_name = ?, last_name = ?, role = ?, email = ?, is_admin = ?, entry_updated_at = NOW() WHERE id = ?",
[first_name, last_name, role, email, is_admin, userId], [first_name, last_name, role, email, is_admin, userId],
); );
if (result.affectedRows > 0) return { success: true }; if (result.affectedRows > 0) return { success: true };
@@ -61,7 +61,7 @@ export const editUserById = async (
export const getAllUsers = async () => { export const getAllUsers = async () => {
const [result] = await pool.query( const [result] = await pool.query(
"SELECT id, username, first_name, last_name, role, email, is_admin, entry_created_at, entry_updated_at FROM users WHERE secret_user = false", "SELECT id, username, first_name, last_name, role, email, is_admin, entry_created_at, entry_updated_at FROM users",
); );
if (result.length > 0) return { success: true, data: result }; if (result.length > 0) return { success: true, data: result };
return { success: false }; return { success: false };
@@ -69,7 +69,7 @@ export const getAllUsers = async () => {
export const getUserById = async (userId) => { export const getUserById = async (userId) => {
const [rows] = await pool.query( const [rows] = await pool.query(
"SELECT id, username, first_name, last_name, role, email, is_admin FROM users WHERE id = ? AND secret_user = false", "SELECT id, username, first_name, last_name, role, email, is_admin FROM users WHERE id = ?",
[userId], [userId],
); );
if (rows.length === 0) { if (rows.length === 0) {

View File

@@ -22,7 +22,7 @@ export const getItemsFromDatabaseV2 = async () => {
export const getLoanByCodeV2 = async (loan_code) => { export const getLoanByCodeV2 = async (loan_code) => {
const [result] = await pool.query( const [result] = await pool.query(
"SELECT username, returned_date, take_date, lockers FROM loans WHERE loan_code = ?;", "SELECT username, returned_date, take_date, lockers FROM loans WHERE loan_code = ?;",
[loan_code] [loan_code],
); );
if (result.length > 0) { if (result.length > 0) {
return { success: true, data: result[0] }; return { success: true, data: result[0] };
@@ -33,7 +33,7 @@ export const getLoanByCodeV2 = async (loan_code) => {
export const changeInSafeStateV2 = async (itemId) => { export const changeInSafeStateV2 = async (itemId) => {
const [result] = await pool.query( const [result] = await pool.query(
"UPDATE items SET in_safe = NOT in_safe WHERE id = ?", "UPDATE items SET in_safe = NOT in_safe WHERE id = ?",
[itemId] [itemId],
); );
if (result.affectedRows > 0) { if (result.affectedRows > 0) {
return { success: true }; return { success: true };
@@ -42,50 +42,62 @@ export const changeInSafeStateV2 = async (itemId) => {
}; };
export const setReturnDateV2 = async (loanCode) => { export const setReturnDateV2 = async (loanCode) => {
const [items] = await pool.query( try {
"SELECT loaned_items_id FROM loans WHERE loan_code = ?", const [items] = await pool.query(
[loanCode] "SELECT loaned_items_id, username FROM loans WHERE loan_code = ?",
); [loanCode],
);
const [owner] = await pool.query( if (items.length === 0)
"SELECT username FROM loans WHERE loan_code = ?", return { success: false, message: "No items found for loan" };
[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 itemIds = Array.isArray(items[0].loaned_items_id) const [result] = await pool.query(
? items[0].loaned_items_id "UPDATE loans SET returned_date = NOW() WHERE loan_code = ? AND returned_date IS NULL",
: JSON.parse(items[0].loaned_items_id || "[]"); [loanCode],
);
const [setItemStates] = await pool.query( if (result.affectedRows === 0) return { success: false };
"UPDATE items SET in_safe = 1, currently_borrowing = NULL, last_borrowed_person = (?) WHERE id IN (?)",
[owner[0].username, itemIds]
);
const [result] = await pool.query( if (itemIds.length > 0) {
"UPDATE loans SET returned_date = NOW() WHERE loan_code = ?", await pool.query(
[loanCode] "UPDATE items SET in_safe = 1, currently_borrowing = NULL, last_borrowed_person = ? WHERE id IN (?)",
); [items[0].username, itemIds],
);
}
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) { return { success: true, data: { returned: true } };
return { success: true }; } catch (error) {
console.error("setReturnDateV2 error:", error);
return { success: false, message: "Failed to set return date" };
} }
return { success: false };
}; };
export const setTakeDateV2 = async (loanCode) => { export const setTakeDateV2 = async (loanCode) => {
const [isTaken] = await pool.query(
"SELECT take_date FROM loans WHERE loan_code = ?",
[loanCode],
);
if (isTaken.length === 0 || isTaken[0].take_date !== null) {
return { success: false, message: "Loan not found or already taken" };
}
const [items] = await pool.query( const [items] = await pool.query(
"SELECT loaned_items_id FROM loans WHERE loan_code = ?", "SELECT loaned_items_id FROM loans WHERE loan_code = ?",
[loanCode] [loanCode],
); );
const [owner] = await pool.query( const [owner] = await pool.query(
"SELECT username FROM loans WHERE loan_code = ?", "SELECT username FROM loans WHERE loan_code = ?",
[loanCode] [loanCode],
); );
if (items.length === 0) return { success: false }; if (items.length === 0)
return { success: false, message: "No items found for loan" };
const itemIds = Array.isArray(items[0].loaned_items_id) const itemIds = Array.isArray(items[0].loaned_items_id)
? items[0].loaned_items_id ? items[0].loaned_items_id
@@ -93,18 +105,18 @@ export const setTakeDateV2 = async (loanCode) => {
const [setItemStates] = await pool.query( const [setItemStates] = await pool.query(
"UPDATE items SET in_safe = 0, currently_borrowing = (?) WHERE id IN (?)", "UPDATE items SET in_safe = 0, currently_borrowing = (?) WHERE id IN (?)",
[owner[0].username, itemIds] [owner[0].username, itemIds],
); );
const [result] = await pool.query( const [result] = await pool.query(
"UPDATE loans SET take_date = NOW() WHERE loan_code = ?", "UPDATE loans SET take_date = NOW() WHERE loan_code = ? AND take_date IS NULL",
[loanCode] [loanCode],
); );
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) { if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true }; return { success: true };
} }
return { success: false }; return { message: "Failed to set take date", success: false };
}; };
export const getAllLoansV2 = async () => { export const getAllLoansV2 = async () => {
@@ -118,12 +130,12 @@ export const getAllLoansV2 = async () => {
export const openDoor = async (doorKey) => { export const openDoor = async (doorKey) => {
const [result] = await pool.query( const [result] = await pool.query(
"SELECT safe_nr, id FROM items WHERE door_key = ?;", "SELECT safe_nr, id FROM items WHERE door_key = ?;",
[doorKey] [doorKey],
); );
if (result.length > 0) { if (result.length > 0) {
const [changeItemSate] = await pool.query( const [changeItemSate] = await pool.query(
"UPDATE items SET in_safe = NOT in_safe WHERE id = ?", "UPDATE items SET in_safe = NOT in_safe WHERE id = ?",
[result[0].id] [result[0].id],
); );
if (changeItemSate.affectedRows > 0) { if (changeItemSate.affectedRows > 0) {
return { success: true, data: result[0] }; return { success: true, data: result[0] };

View File

@@ -47,7 +47,7 @@ router.get(
} else { } else {
res.status(404).json({ message: "Loan not found" }); res.status(404).json({ message: "Loan not found" });
} }
} },
); );
// Route for API to set the return date by the loan code // Route for API to set the return date by the loan code
@@ -58,11 +58,11 @@ router.post(
const loanCode = req.params.loan_code; const loanCode = req.params.loan_code;
const result = await setReturnDateV2(loanCode); const result = await setReturnDateV2(loanCode);
if (result.success) { if (result.success) {
res.status(200).json({ data: result.data }); res.status(200).json({});
} else { } else {
res.status(500).json({ message: "Failed to set return date" }); res.status(500).json({ message: "Failed to set return date" });
} }
} },
); );
// Route for API to set the take away date by the loan code // Route for API to set the take away date by the loan code
@@ -73,11 +73,11 @@ router.post(
const loanCode = req.params.loan_code; const loanCode = req.params.loan_code;
const result = await setTakeDateV2(loanCode); const result = await setTakeDateV2(loanCode);
if (result.success) { if (result.success) {
res.status(200).json({ data: result.data }); res.status(200).json({});
} else { } else {
res.status(500).json({ message: "Failed to set take date" }); res.status(500).json({ message: result.message });
} }
} },
); );
// Route for API to open a door // Route for API to open a door

View File

@@ -62,6 +62,7 @@ router.post("/createLoan", authenticate, async (req, res) => {
mailInfo.data.start_date, mailInfo.data.start_date,
mailInfo.data.end_date, mailInfo.data.end_date,
mailInfo.data.created_at, mailInfo.data.created_at,
mailInfo.data.note,
); );
return res.status(201).json({ return res.status(201).json({
message: "Loan created successfully", message: "Loan created successfully",

View File

@@ -34,7 +34,14 @@ const formatDateTime = (value) => {
return "N/A"; return "N/A";
}; };
function buildLoanEmail({ user, items, startDate, endDate, createdDate }) { function buildLoanEmail({
user,
items,
startDate,
endDate,
createdDate,
note,
}) {
const brand = process.env.MAIL_BRAND_COLOR || "#0ea5e9"; const brand = process.env.MAIL_BRAND_COLOR || "#0ea5e9";
const itemsList = const itemsList =
Array.isArray(items) && items.length Array.isArray(items) && items.length
@@ -116,6 +123,12 @@ function buildLoanEmail({ user, items, startDate, endDate, createdDate }) {
createdDate, createdDate,
)}</td> )}</td>
</tr> </tr>
<tr>
<td style="padding:10px 14px; color:#6b7280; vertical-align:top;">Notiz</td>
<td style="padding:10px 14px; font-weight:600; color:#111827;">${
note || "Keine Notiz"
}</td>
</tr>
</tbody> </tbody>
</table> </table>
<p style="margin:22px 0 0 0; font-size:14px;"> <p style="margin:22px 0 0 0; font-size:14px;">
@@ -134,7 +147,14 @@ function buildLoanEmail({ user, items, startDate, endDate, createdDate }) {
</html>`; </html>`;
} }
function buildLoanEmailText({ user, items, startDate, endDate, createdDate }) { function buildLoanEmailText({
user,
items,
startDate,
endDate,
createdDate,
note,
}) {
const itemsText = const itemsText =
Array.isArray(items) && items.length ? items.join(", ") : "N/A"; Array.isArray(items) && items.length ? items.join(", ") : "N/A";
return [ return [
@@ -145,10 +165,18 @@ function buildLoanEmailText({ user, items, startDate, endDate, createdDate }) {
`Start: ${formatDateTime(startDate)}`, `Start: ${formatDateTime(startDate)}`,
`Ende: ${formatDateTime(endDate)}`, `Ende: ${formatDateTime(endDate)}`,
`Erstellt am: ${formatDateTime(createdDate)}`, `Erstellt am: ${formatDateTime(createdDate)}`,
`Notiz: ${note || "Keine Notiz"}`,
].join("\n"); ].join("\n");
} }
export function sendMailLoan(user, items, startDate, endDate, createdDate) { export function sendMailLoan(
user,
items,
startDate,
endDate,
createdDate,
note,
) {
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({
host: process.env.MAIL_HOST, host: process.env.MAIL_HOST,
port: process.env.MAIL_PORT, port: process.env.MAIL_PORT,
@@ -170,8 +198,16 @@ export function sendMailLoan(user, items, startDate, endDate, createdDate) {
startDate, startDate,
endDate, endDate,
createdDate, createdDate,
note,
}),
html: buildLoanEmail({
user,
items,
startDate,
endDate,
createdDate,
note,
}), }),
html: buildLoanEmail({ user, items, startDate, endDate, createdDate }),
}); });
console.log("Loan message sent:", info.messageId); console.log("Loan message sent:", info.messageId);

View File

@@ -11,7 +11,6 @@ CREATE TABLE users (
is_admin bool NOT NULL DEFAULT false, is_admin bool NOT NULL DEFAULT false,
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,
secret_user bool NOT NULL DEFAULT false,
PRIMARY KEY (id) PRIMARY KEY (id)
) ENGINE=InnoDB; ) ENGINE=InnoDB;

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 = 8102; const port = 8004;
app.use(cors()); app.use(cors());
// Body-Parser VOR den Routen registrieren // Body-Parser VOR den Routen registrieren

View File

@@ -1,37 +1,35 @@
services: services:
demo_usr_frontend: # usr-frontend_v2:
container_name: demo_borrow_system-usr-frontend # container_name: borrow_system-usr-frontend
networks: # build: ./FrontendV2
- proxynet # ports:
build: ./FrontendV2 # - "8001:80"
restart: unless-stopped # restart: unless-stopped
demo_admin_frontend: # admin-frontend:
container_name: demo_borrow_system-admin-frontend # container_name: borrow_system-admin-frontend
networks: # build: ./admin
- proxynet # ports:
build: ./admin # - "8003:80"
restart: unless-stopped # restart: unless-stopped
demo_backend_v2: backend_v2:
container_name: demo_borrow_system-backend_v2 container_name: borrow_system-backend_v2
networks:
- proxynet
build: ./backendV2 build: ./backendV2
ports:
- "8004:8004"
environment: environment:
NODE_ENV: production NODE_ENV: production
DB_HOST: demo_mysql_v2 DB_HOST: mysql_v2
DB_USER: root DB_USER: root
DB_PASSWORD: ${DB_PASSWORD_V2} DB_PASSWORD: ${DB_PASSWORD_V2}
DB_NAME: borrow_system_new DB_NAME: borrow_system_new
depends_on: depends_on:
- demo_mysql_v2 - mysql_v2
restart: unless-stopped restart: unless-stopped
demo_mysql_v2: mysql_v2:
container_name: demo_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:
@@ -39,13 +37,11 @@ services:
MYSQL_DATABASE: borrow_system_new MYSQL_DATABASE: borrow_system_new
TZ: Europe/Berlin TZ: Europe/Berlin
volumes: volumes:
- demo_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:
demo_mysql-v2-data: mysql-v2-data:
networks:
proxynet:
external: true