12 Commits

14 changed files with 216 additions and 399 deletions

View File

@@ -1,366 +1,177 @@
# Borrow System API Documentation
**Frontend:** https://insta.the1s.de
**Backend base URL:** `https://insta.the1s.de/backend/api`
## Overview
---
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
All API endpoints require **either**:
### 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.
---
All requests must include a valid API key in the URL path as the `:key` parameter. API keys are 8-digit numeric strings.
## 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)
#### 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)
**Response 200:**
```json
{
"data": [
{
"id": 1,
"item_name": "DJI 1er Mikro",
"can_borrow_role": 4,
"inSafe": 1,
"item_name": "Laptop",
"can_borrow_role": 1,
"in_safe": true,
"safe_nr": 3,
"door_key": "123",
"entry_created_at": "2025-08-19T22:02:16.000Z",
"entry_updated_at": "2025-08-19T22:02:16.000Z",
"last_borrowed_person": "alice",
"door_key": 101,
"last_borrowed_person": "jdoe",
"currently_borrowing": null
}
]
}
```
#### Error Response (500)
**Response 500:**
```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)
- `:itemId` Item ID (integer)
**Response 200:** Returns on successful toggle.
#### Authentication
- 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)
**Response 500:**
```json
{
"data": {}
}
```
_(Implementation currently only returns `{ success: true }`, so `data` may be empty.)_
#### Error Response (500)
```json
{
"message": "Failed to update item state"
}
{ "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)
- `:loan_code` Loan code (string)
- **key** - API key
- **loan_code** - A 6-digit numeric loan code
#### Authentication
- 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)
**Response 200:**
```json
{
"data": {
"username": "john",
"username": "jdoe",
"returned_date": null,
"take_date": "2025-01-01T10:00:00.000Z",
"lockers": "[1, 2, 3]"
"take_date": "2024-01-15T10:30:00.000Z",
"lockers": [1, 3]
}
}
```
#### Error Response (404)
**Response 404:**
```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`
- `currently_borrowing = NULL`
- `last_borrowed_person = username`
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.
**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)
- `:loan_code` Loan code (string)
**Response 200:** Empty JSON object on success.
#### Authentication
- 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)
**Response 500:**
```json
{
"data": {}
}
{ "message": "Loan not found or already taken" }
```
#### Error Response (500)
```json
{
"message": "Failed to set return date"
}
```
> **Note:** This endpoint will fail if the loan has already been taken or does not exist.
---
### 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`
- `currently_borrowing = username`
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.
**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)
- `:loan_code` Loan code (string)
**Response 200:** Empty JSON object on success.
#### 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)
**Response 500:**
```json
{
"data": {}
}
{ "message": "Failed to set return date" }
```
#### Error Response (500)
```json
{
"message": "Failed to set take date"
}
```
> **Note:** This endpoint will fail if the loan has already been returned (i.e., `returned_date` is not `NULL`).
---
### 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)
- `:doorKey` Door key/token (string) used by hardware to identify the locker.
- **key** - API key
- **doorKey** - The door key identifier assigned to an item
#### 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)
**Response 200:**
```json
{
"data": {
"safe_nr": 5,
"id": 42
"safe_nr": 3,
"id": 1
}
}
```
#### Error Response (500)
**Response 500:**
```json
{
"message": "Failed to open door"
}
{ "message": "Failed to open door" }
```
---
## Error Handling
## Authentication Error Messages
### 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.
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.

View File

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

View File

@@ -16,6 +16,7 @@ import { Flex } from "@chakra-ui/react";
import { Footer } from "./components/footer/Footer";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { API_BASE } from "@/config/api.config";
import { ContactPage } from "./pages/ContactPage";
const queryClient = new QueryClient();
@@ -80,6 +81,7 @@ function App() {
<Route path="/" element={<HomePage />} />
<Route path="/my-loans" element={<MyLoansPage />} />
<Route path="/landingpage" element={<Landingpage />} />
<Route path="/contact" element={<ContactPage />} />
</Route>
<Route path="/login" element={<LoginPage />} />

View File

@@ -22,6 +22,7 @@ import {
MoreVertical,
Languages,
Table,
ContactRound,
} from "lucide-react";
import { useUserContext } from "@/states/Context";
import { useState } from "react";
@@ -141,7 +142,7 @@ export const Header = () => {
value="help"
onSelect={() =>
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",
"noopener,noreferrer",
)
@@ -153,6 +154,16 @@ export const Header = () => {
</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.Item
value="logout"
@@ -268,7 +279,7 @@ export const Header = () => {
</Button>
<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"
>
<Button variant="ghost">
@@ -278,6 +289,17 @@ export const Header = () => {
</HStack>
</Button>
</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">
<HStack gap={2}>
<LogOut size={18} />

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -29,14 +29,14 @@ export const createUser = async (
};
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 };
return { success: false };
};
export const changePassword = async (username, newPassword) => {
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],
);
if (result.affectedRows > 0) return { success: true };
@@ -52,7 +52,7 @@ export const editUserById = async (
is_admin,
) => {
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],
);
if (result.affectedRows > 0) return { success: true };
@@ -61,7 +61,7 @@ export const editUserById = async (
export const getAllUsers = async () => {
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 };
return { success: false };
@@ -69,7 +69,7 @@ export const getAllUsers = async () => {
export const getUserById = async (userId) => {
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],
);
if (rows.length === 0) {

View File

@@ -1,100 +0,0 @@
USE borrow_system_new;
-- USERS
INSERT INTO users (username, password, email, first_name, last_name, role, is_admin)
VALUES
('user1', 'passwordhash1', 'user1@example.com', 'First1', 'Last1', 1, false),
('user2', 'passwordhash2', 'user2@example.com', 'First2', 'Last2', 1, false),
('user3', 'passwordhash3', 'user3@example.com', 'First3', 'Last3', 2, false),
('admin1', 'passwordhash4', 'admin1@example.com', 'Admin', 'One', 9, true),
('admin2', 'passwordhash5', 'admin2@example.com', 'Admin', 'Two', 9, true);
-- ITEMS
INSERT INTO items (item_name, can_borrow_role, in_safe, safe_nr, door_key, last_borrowed_person, currently_borrowing)
VALUES
('Item1', 1, true, 1, 101, NULL, NULL),
('Item2', 1, true, 2, 102, 'user1', 'user1'),
('Item3', 2, true, 3, 103, 'user2', NULL),
('Item4', 1, false, NULL, NULL, NULL, NULL),
('Item5', 2, false, NULL, NULL, 'user3', 'user3');
-- LOANS
INSERT INTO loans (
username,
lockers,
loan_code,
start_date,
end_date,
take_date,
returned_date,
created_at,
loaned_items_id,
loaned_items_name,
deleted,
note
)
VALUES
(
'user1',
JSON_ARRAY('Locker1', 'Locker2'),
'123456',
'2026-02-01 09:00:00',
'2026-02-10 17:00:00',
'2026-02-01 09:15:00',
NULL,
'2026-02-01 09:00:00',
JSON_ARRAY(1, 2),
JSON_ARRAY('Item1', 'Item2'),
false,
'Erste allgemeine Ausleihe'
),
(
'user2',
JSON_ARRAY('Locker3'),
'234567',
'2026-02-02 10:00:00',
'2026-02-05 16:00:00',
'2026-02-02 10:05:00',
'2026-02-05 15:30:00',
'2026-02-02 10:00:00',
JSON_ARRAY(3),
JSON_ARRAY('Item3'),
false,
'Zurückgegeben vor Enddatum'
),
(
'user3',
JSON_ARRAY(),
'345678',
'2026-02-03 08:30:00',
'2026-02-15 18:00:00',
NULL,
NULL,
'2026-02-03 08:30:00',
JSON_ARRAY(5),
JSON_ARRAY('Item5'),
false,
'Noch ausgeliehen'
),
(
'user1',
JSON_ARRAY('Locker4'),
'456789',
'2025-12-01 09:00:00',
'2025-12-03 17:00:00',
'2025-12-01 09:10:00',
'2025-12-03 16:45:00',
'2025-12-01 09:00:00',
JSON_ARRAY(1),
JSON_ARRAY('Item1'),
true,
'Alte, gelöschte Ausleihe'
);
-- API KEYS
INSERT INTO apiKeys (api_key, entry_name)
VALUES
('10000001', 'Entry1'),
('10000002', 'Entry2'),
('10000003', 'Entry3'),
('10000004', 'Entry4');

View File

@@ -11,7 +11,6 @@ CREATE TABLE users (
is_admin bool NOT NULL DEFAULT false,
entry_created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
entry_updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
secret_user bool NOT NULL DEFAULT false,
PRIMARY KEY (id)
) ENGINE=InnoDB;

View File

@@ -1,35 +1,35 @@
services:
demo_usr_frontend:
container_name: demo_borrow_system-usr-frontend
usr-frontend_v2:
container_name: borrow_system-usr-frontend
networks:
- proxynet
build: ./FrontendV2
restart: unless-stopped
demo_admin_frontend:
container_name: demo_borrow_system-admin-frontend
admin-frontend:
container_name: borrow_system-admin-frontend
networks:
- proxynet
build: ./admin
restart: unless-stopped
demo_backend_v2:
container_name: demo_borrow_system-backend_v2
backend_v2:
container_name: borrow_system-backend_v2
networks:
- proxynet
build: ./backendV2
environment:
NODE_ENV: production
DB_HOST: demo_mysql_v2
DB_HOST: mysql_v2
DB_USER: root
DB_PASSWORD: ${DB_PASSWORD_V2}
DB_NAME: borrow_system_new
depends_on:
- demo_mysql_v2
- mysql_v2
restart: unless-stopped
demo_mysql_v2:
container_name: demo_borrow_system-mysql-v2
mysql_v2:
container_name: borrow_system-mysql-v2
networks:
- proxynet
image: mysql:8.0
@@ -39,12 +39,12 @@ services:
MYSQL_DATABASE: borrow_system_new
TZ: Europe/Berlin
volumes:
- demo_mysql-v2-data:/var/lib/mysql
- mysql-v2-data:/var/lib/mysql
- ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro
volumes:
mysql-data:
demo_mysql-v2-data:
mysql-v2-data:
networks:
proxynet: