From a949ebcdc83b7e7476fa5162c34ab81bfc1e078f Mon Sep 17 00:00:00 2001 From: rxf Date: Wed, 11 Mar 2026 20:33:19 +0100 Subject: [PATCH] Mist, jetzt vielleicht --- Dockerfile | 43 ++++ app/api/check/route.ts | 16 ++ app/api/data/route.ts | 94 +++++++ app/globals.css | 110 ++++++++- app/layout.tsx | 4 +- app/login/actions.ts | 27 ++ app/login/page.tsx | 72 ++++++ app/page.tsx | 65 +---- components/AppLayout.tsx | 44 ++++ components/LogoutButton.tsx | 23 ++ components/TablettenTable.tsx | 208 ++++++++++++++++ compose.yml | 36 +++ deploy.sh | 59 +++++ docker-compose.prod.yml | 39 +++ docker-compose.yml | 44 ++++ lib/auth.ts | 36 +++ lib/checkAblauf.ts | 26 ++ lib/mailService.ts | 49 ++++ lib/mongodb.ts | 36 +++ lib/mysql.ts | 13 + lib/session.ts | 70 ++++++ next.config.ts | 2 +- package-lock.json | 383 ++++++++++++++++++++++++++++- package.json | 10 +- proxy.ts | 50 ++++ scripts/migrate-mongo-to-mysql.mjs | 121 +++++++++ scripts/tabletten_dump.sql | 30 +++ types/tablette.ts | 30 +++ 28 files changed, 1666 insertions(+), 74 deletions(-) create mode 100644 Dockerfile create mode 100644 app/api/check/route.ts create mode 100644 app/api/data/route.ts create mode 100644 app/login/actions.ts create mode 100644 app/login/page.tsx create mode 100644 components/AppLayout.tsx create mode 100644 components/LogoutButton.tsx create mode 100644 components/TablettenTable.tsx create mode 100644 compose.yml create mode 100755 deploy.sh create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 lib/auth.ts create mode 100644 lib/checkAblauf.ts create mode 100644 lib/mailService.ts create mode 100644 lib/mongodb.ts create mode 100644 lib/mysql.ts create mode 100644 lib/session.ts create mode 100644 proxy.ts create mode 100644 scripts/migrate-mongo-to-mysql.mjs create mode 100644 scripts/tabletten_dump.sql create mode 100644 types/tablette.ts diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cd30151 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# ── Stage 1: Abhängigkeiten installieren ───────────────────────────────────── +FROM node:22-alpine AS deps +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev --ignore-scripts + +# ── Stage 2: Build ──────────────────────────────────────────────────────────── +FROM node:22-alpine AS builder +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci --ignore-scripts + +COPY . . + +# Standalone-Output aktiviert kleinste Images +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +# ── Stage 3: Produktions-Image ──────────────────────────────────────────────── +FROM node:22-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +# Unprivilegierter User +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 nextjs + +# Standalone-Bundle + statische Assets kopieren +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/public ./public + +USER nextjs + +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +CMD ["node", "server.js"] diff --git a/app/api/check/route.ts b/app/api/check/route.ts new file mode 100644 index 0000000..be02cab --- /dev/null +++ b/app/api/check/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { doCheckAndMail } from '@/lib/mailService'; + +// Einfacher Shared-Secret-Schutz +const SECRET = process.env.CHECK_SECRET || ''; + +export async function GET(req: NextRequest) { + if (SECRET) { + const token = req.nextUrl.searchParams.get('secret'); + if (token !== SECRET) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + } + const result = await doCheckAndMail(); + return NextResponse.json({ ok: true, result }); +} diff --git a/app/api/data/route.ts b/app/api/data/route.ts new file mode 100644 index 0000000..7fffc4a --- /dev/null +++ b/app/api/data/route.ts @@ -0,0 +1,94 @@ +import { NextRequest, NextResponse } from 'next/server'; +import type { RowDataPacket } from 'mysql2'; +import pool from '@/lib/mysql'; +import { checkAblauf } from '@/lib/checkAblauf'; +import moment from 'moment'; +import { Tablette, DataResponse } from '@/types/tablette'; + +function formatDate(dt: Date | string | null): string { + if (!dt) return ''; + const d = moment(dt); + if (!d.isValid() || d.isBefore(moment('2020-01-01'))) return ''; + return d.format('YYYY-MM-DD'); +} + +const SORT_WHITELIST = new Set(['tab', 'pday', 'cnt', 'at', 'akt', 'until', 'warn', 'rem', 'order']); + +// GET /api/data?sidx=...&sord=asc|desc +export async function GET(req: NextRequest) { + const { searchParams } = req.nextUrl; + const sidxRaw = searchParams.get('sidx') || 'pday'; + const sidx = SORT_WHITELIST.has(sidxRaw) ? sidxRaw : 'pday'; + const sord = searchParams.get('sord') === 'asc' ? 'ASC' : 'DESC'; + const col = `\`${sidx}\``; + + const [rows] = await pool.query( + `SELECT tab, pday, cnt, at, akt, until, warn, rem, \`order\` + FROM tabletten + ORDER BY ${col} ${sord}, rem DESC, tab ASC` + ); + + const values: Tablette[] = rows.map((r) => ({ + tab: r.tab, + pday: r.pday, + cnt: r.cnt, + at: formatDate(r.at), + akt: r.akt, + until: formatDate(r.until), + warn: r.warn === 1 || r.warn === true, + rem: r.rem, + order: r.order, + })); + + const result: DataResponse = { + total: 1, + page: 1, + records: values.length, + values, + }; + + return NextResponse.json(result); +} + +// POST /api/data +// body: { oper: 'add'|'edit'|'del', tab, pday, cnt, at, rem, order } +export async function POST(req: NextRequest) { + const data = await req.json(); + + if (data.oper === 'del') { + await pool.execute('DELETE FROM tabletten WHERE tab = ?', [data.tab]); + return NextResponse.json({ ok: true }); + } + + // add or edit + const pday = parseFloat(data.pday) || 1; + const cnt = parseInt(data.cnt, 10) || 0; + const at = data.at === '' ? '1900-01-01' : data.at; + const rem = data.rem || ''; + const order = data.order || ''; + + const updates = checkAblauf({ cnt, pday, at: new Date(at) }); + + await pool.execute( + `INSERT INTO tabletten (tab, pday, cnt, at, akt, until, warn, rem, \`order\`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + pday = VALUES(pday), + cnt = VALUES(cnt), + at = VALUES(at), + akt = VALUES(akt), + until = VALUES(until), + warn = VALUES(warn), + rem = VALUES(rem), + \`order\` = VALUES(\`order\`)`, + [ + data.tab, pday, cnt, at, + updates.akt, + moment(updates.until).format('YYYY-MM-DD'), + updates.warn ? 1 : 0, + rem, order, + ] + ); + + return NextResponse.json({ ok: true }); +} diff --git a/app/globals.css b/app/globals.css index a2dc41e..7b16ff2 100644 --- a/app/globals.css +++ b/app/globals.css @@ -12,15 +12,109 @@ --font-mono: var(--font-geist-mono); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - body { background: var(--background); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + font-family: "Lucida Grande", Helvetica, Arial, sans-serif; + font-size: 14px; } + +main { + width: 90%; + margin: 20px auto; + background-color: #eeeeee; + padding: 10px; +} + +/* ---- Toolbar ---- */ +.toolbar { + display: flex; + gap: 8px; + margin-bottom: 10px; +} + +.btn { + padding: 6px 14px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 13px; +} +.btn-add { background: #4caf50; color: white; } +.btn-add:hover { background: #388e3c; } +.btn-refresh { background: #2196f3; color: white; } +.btn-refresh:hover { background: #1565c0; } +.btn-delete { background: #e53935; color: white; } +.btn-delete:hover { background: #b71c1c; } +.btn-icon { background: none; border: none; cursor: pointer; font-size: 16px; padding: 0; } + +/* ---- Table ---- */ +.table-container { overflow-x: auto; } + +.main-table { + width: 100%; + border-collapse: collapse; + background: white; +} +.main-table th, +.main-table td { + border: 1px solid #ccc; + padding: 6px 10px; + text-align: left; +} +.main-table th { + background: #d0d0d0; + cursor: pointer; + user-select: none; + text-align: center; +} +.main-table th:hover { background: #bbb; } +.main-table tbody tr:nth-child(even) { background: #f7f7f7; } + +.sortable-header { white-space: nowrap; } +.cell-center { text-align: center; } +.action-cell { white-space: nowrap; } +.col-tab { font-weight: bold; font-size: 115%; } +.col-pday { width: 40px; text-align: center; } +.col-date { width: 110px; white-space: nowrap; text-align: center; } + +/* ---- Warn highlighting (mirrors original) ---- */ +.row-warn { background: #fabebe !important; } +.cell-warn { background: #ff8033 !important; color: white; font-weight: bold; } +.row-abgesetzt { color: #aaa; } + +/* ---- Error ---- */ +.error-msg { color: red; font-weight: bold; } + +/* ---- Modal ---- */ +.modal-overlay { + position: fixed; inset: 0; + background: rgba(0,0,0,0.45); + display: flex; align-items: center; justify-content: center; + z-index: 100; +} +.modal { + background: white; + border-radius: 8px; + padding: 28px 32px; + min-width: 380px; + display: flex; + flex-direction: column; + gap: 12px; + box-shadow: 0 4px 24px rgba(0,0,0,0.25); +} +.modal h2 { margin: 0 0 8px; font-size: 1.2rem; } +.modal label { + display: flex; + flex-direction: column; + font-size: 13px; + gap: 4px; +} +.modal input { + padding: 6px 8px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 14px; +} +.modal input:disabled { background: #eee; color: #777; } +.modal-buttons { display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; } diff --git a/app/layout.tsx b/app/layout.tsx index f7fa87e..cab66b4 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -13,8 +13,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Tabletten-Übersicht", + description: "Verwaltung von Medikamenten und Tabletten", }; export default function RootLayout({ diff --git a/app/login/actions.ts b/app/login/actions.ts new file mode 100644 index 0000000..5dabed6 --- /dev/null +++ b/app/login/actions.ts @@ -0,0 +1,27 @@ +'use server'; + +import { verifyCredentials } from '@/lib/auth'; +import { createSession, deleteSession } from '@/lib/session'; +import { redirect } from 'next/navigation'; + +export async function login(prevState: any, formData: FormData) { + const username = formData.get('username') as string; + const password = formData.get('password') as string; + + if (!username || !password) { + return { error: 'Bitte Benutzername und Passwort eingeben' }; + } + + const isValid = verifyCredentials(username, password); + if (!isValid) { + return { error: 'Ungültige Anmeldedaten' }; + } + + await createSession(username); + redirect('/'); +} + +export async function logout() { + await deleteSession(); + redirect('/login'); +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..8e21df2 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { useActionState } from 'react'; +import { login } from './actions'; + +export default function LoginPage() { + const [state, loginAction, isPending] = useActionState(login, undefined); + + return ( +
+
+
+

Tabletten-Übersicht

+
+ +
+
+

Anmeldung

+ +
+
+ + +
+ +
+ + +
+ + {state?.error && ( +
+ {state.error} +
+ )} + + +
+
+
+
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index 295f8fd..20ea4bc 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,65 +1,10 @@ -import Image from "next/image"; +import TablettenTable from '@/components/TablettenTable'; +import AppLayout from '@/components/AppLayout'; export default function Home() { return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
-
- - Vercel logomark - Deploy Now - - - Documentation - -
-
-
+ + + ); } diff --git a/components/AppLayout.tsx b/components/AppLayout.tsx new file mode 100644 index 0000000..69b793f --- /dev/null +++ b/components/AppLayout.tsx @@ -0,0 +1,44 @@ +'use client'; + +import LogoutButton from '@/components/LogoutButton'; +import packageJson from '@/package.json'; + +interface AppLayoutProps { + children: React.ReactNode; +} + +export default function AppLayout({ children }: AppLayoutProps) { + const version = packageJson.version; + const buildDate = process.env.NEXT_PUBLIC_BUILD_DATE || new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); + + return ( +
+
+ + {/* Seitentitel */} +

Tabletten-Check

+ +
+ + {/* Logout-Button oben rechts */} +
+ +
+ + {/* Inhaltsbereich */} +
+ {children} +
+ + + +
+
+
+ ); +} diff --git a/components/LogoutButton.tsx b/components/LogoutButton.tsx new file mode 100644 index 0000000..b58d885 --- /dev/null +++ b/components/LogoutButton.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { logout } from '@/app/login/actions'; + +interface LogoutButtonProps { + className?: string; + children?: React.ReactNode; +} + +export default function LogoutButton({ className, children }: LogoutButtonProps) { + const handleLogout = async () => { + await logout(); + }; + + return ( + + ); +} diff --git a/components/TablettenTable.tsx b/components/TablettenTable.tsx new file mode 100644 index 0000000..4dc4df1 --- /dev/null +++ b/components/TablettenTable.tsx @@ -0,0 +1,208 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { Tablette } from '@/types/tablette'; + +const EMPTY: Omit = { + tab: '', + pday: 1, + cnt: 0, + at: '', + rem: '', + order: '', +}; + +type FormData = Omit; + +export default function TablettenTable() { + const [rows, setRows] = useState([]); + const [sortField, setSortField] = useState('until'); + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); + const [modal, setModal] = useState(null); + const [selected, setSelected] = useState(null); + const [form, setForm] = useState({ ...EMPTY }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const res = await fetch(`/api/data?sidx=${sortField}&sord=${sortDir}`); + const json = await res.json(); + setRows(json.values || []); + } catch { + setError('Fehler beim Laden der Daten.'); + } finally { + setLoading(false); + } + }, [sortField, sortDir]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + function handleSort(field: string) { + if (field === sortField) { + setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); + } else { + setSortField(field); + setSortDir('asc'); + } + } + + function openAdd() { + setForm({ ...EMPTY }); + setModal('add'); + } + + function openEdit(row: Tablette) { + setSelected(row); + setForm({ tab: row.tab, pday: row.pday, cnt: row.cnt, at: row.at, rem: row.rem, order: row.order }); + setModal('edit'); + } + + function openDel(row: Tablette) { + setSelected(row); + setModal('del'); + } + + async function handleSave() { + const payload = { ...form, oper: modal === 'add' ? 'add' : 'edit' }; + await fetch('/api/data', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + setModal(null); + fetchData(); + } + + async function handleDelete() { + if (!selected) return; + await fetch('/api/data', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ oper: 'del', tab: selected.tab }), + }); + setModal(null); + fetchData(); + } + + const SortIndicator = ({ field }: { field: string }) => + sortField === field ? (sortDir === 'asc' ? ' ▲' : ' ▼') : ''; + + const colHeader = (label: string, field: string) => ( + handleSort(field)} + className="sortable-header" + > + {label} + + + ); + + return ( +
+ {error &&

{error}

} +
+ + +
+ + + + {colHeader('Tabletten', 'tab')} + {colHeader('pro Tag', 'pday')} + {colHeader('aktuell', 'akt')} + {colHeader('reicht bis', 'until')} + {colHeader('Anzahl…', 'cnt')} + {colHeader('…am', 'at')} + {colHeader('Bemerkungen', 'rem')} + {colHeader('bestellt am', 'order')} + + + + + {loading && ( + + )} + {!loading && rows.map((row) => ( + + + + + + + + + + + + ))} + +
Aktion
Lade…
{row.tab}{row.pday}{row.akt}{row.until}{row.cnt}{row.at}{row.rem}{row.order} + + +
+ + {/* Add/Edit Modal */} + {(modal === 'add' || modal === 'edit') && ( +
setModal(null)}> +
e.stopPropagation()}> +

{modal === 'add' ? 'Neuer Eintrag' : 'Bearbeiten'}

+ + + + + + +
+ + +
+
+
+ )} + + {/* Delete Modal */} + {modal === 'del' && selected && ( +
setModal(null)}> +
e.stopPropagation()}> +

Eintrag löschen

+

Möchtest du {selected.tab} wirklich löschen?

+
+ + +
+
+
+ )} +
+ ); +} diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..c2ff985 --- /dev/null +++ b/compose.yml @@ -0,0 +1,36 @@ +# Docker Compose für Production Server mit Traefik +services: + tabletten-app: + image: docker.citysensor.de/tabletten:latest + container_name: tabletten + restart: unless-stopped + environment: + - NODE_ENV=production + - DB_HOST=${DB_HOST} + - DB_USER=${DB_USER} + - DB_PASS=${DB_PASS} + - DB_NAME=${DB_NAME} + - AUTH_USERS=${AUTH_USERS} + - AUTH_SECRET=${AUTH_SECRET} + labels: + - traefik.enable=true + - traefik.http.routers.tabletten.entrypoints=http + - traefik.http.routers.tabletten.rule=Host(`tabletten.fuerst-stuttgart.de`) + - traefik.http.middlewares.tabletten-https-redirect.redirectscheme.scheme=https + - traefik.http.routers.tabletten.middlewares=tabletten-https-redirect + - traefik.http.routers.tabletten-secure.entrypoints=https + - traefik.http.routers.tabletten-secure.rule=Host(`tabletten.fuerst-stuttgart.de`) + - traefik.http.routers.tabletten-secure.tls=true + - traefik.http.routers.tabletten-secure.tls.certresolver=letsencrypt + - traefik.http.routers.tabletten-secure.service=tabletten + - traefik.http.services.tabletten.loadbalancer.server.port=3000 + networks: + - proxy + - gitea-internal +networks: + proxy: + name: dockge_default + external: true + gitea-internal: + name: gitea_gitea-internal + external: true diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..f0f1846 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,59 @@ +#!/bin/bash + +# Deploy Script für tabletten +# Baut das Docker Image und lädt es zu docker.citysensor.de hoch + +set -e + +# Konfiguration +REGISTRY="docker.citysensor.de" +IMAGE_NAME="tabletten" +TAG="${1:-latest}" # Erster Parameter oder "latest" +FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${TAG}" + +# Build-Datum +BUILD_DATE=$(date +%d.%m.%Y) + +echo "==========================================" +echo "Tabletten Deploy Script" +echo "==========================================" +echo "Registry: ${REGISTRY}" +echo "Image: ${IMAGE_NAME}" +echo "Tag: ${TAG}" +echo "Build-Datum: ${BUILD_DATE}" +echo "==========================================" +echo "" + +# 1. Login zur Registry (falls noch nicht eingeloggt) +echo ">>> Login zu ${REGISTRY}..." +docker login "${REGISTRY}" +echo "" + +# 2. Multiplatform Builder einrichten (docker-container driver erforderlich) +echo ">>> Richte Multiplatform Builder ein..." +if ! docker buildx inspect multiplatform-builder &>/dev/null; then + docker buildx create --name multiplatform-builder --driver docker-container --bootstrap +fi +docker buildx use multiplatform-builder +echo "" + +# 3. Docker Image bauen und pushen (Multiplatform) +echo ">>> Baue Multiplatform Docker Image und pushe zu Registry..." +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --build-arg BUILD_DATE="${BUILD_DATE}" \ + -t "${FULL_IMAGE}" \ + --push \ + . + +echo ">>> Build und Push erfolgreich!" + +echo "" +echo "==========================================" +echo "✓ Deploy erfolgreich abgeschlossen!" +echo "==========================================" +echo "" +echo "Auf dem Server ausführen:" +echo " docker pull ${FULL_IMAGE}" +echo " docker-compose -f docker-compose.prod.yml up -d" +echo "" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..b2d22f4 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,39 @@ +# Docker Compose für Production Server mit Traefik +services: + werte-app: + image: docker.citysensor.de/werte-next:latest + container_name: werte-next-app + restart: unless-stopped + expose: + - 3000 + environment: + - NODE_ENV=production + - DB_HOST=${DB_HOST} + - DB_USER=${DB_USER} + - DB_PASS=${DB_PASS} + - DB_NAME=${DB_NAME} + - AUTH_USERS=${AUTH_USERS} + - AUTH_SECRET=${AUTH_SECRET} + labels: + - traefik.enable=true + - traefik.http.routers.werte.entrypoints=http + - traefik.http.routers.werte.rule=Host(`werte.fuerst-stuttgart.de`) + - traefik.http.middlewares.werte-https-redirect.redirectscheme.scheme=https + - traefik.http.routers.werte.middlewares=werte-https-redirect + - traefik.http.routers.werte-secure.entrypoints=https + - traefik.http.routers.werte-secure.rule=Host(`werte.fuerst-stuttgart.de`) + - traefik.http.routers.werte-secure.tls=true + - traefik.http.routers.werte-secure.tls.certresolver=letsencrypt + - traefik.http.routers.werte-secure.service=werte + - traefik.http.services.werte.loadbalancer.server.port=3000 + networks: + - proxy + - gitea-internal + +networks: + proxy: + name: dockge_default + external: true + gitea-internal: + name: gitea_gitea-internal + external: true diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a911056 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,44 @@ +# docker-compose.yml +# Multiplatform-Build: linux/amd64 + linux/arm64 (z.B. fuer M1/M2-Mac und x86-Server) +# +# Einmaliger Setup: +# docker buildx create --use --name multi +# +# Build & Push (Multi-Arch): +# docker buildx bake --push +# +# Lokaler Start (nur aktuelle Plattform): +# docker compose up --build + +services: + app: + build: + context: . + dockerfile: Dockerfile + platforms: + - linux/amd64 + - linux/arm64 + image: docker.citysensor.de/tabletten_next:latest + restart: unless-stopped + ports: + - "3000:3000" + environment: + # MySQL + MYSQL_HOST: ${MYSQL_HOST} + MYSQL_PORT: ${MYSQL_PORT:-3306} + MYSQL_USER: ${MYSQL_USER} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE} + # E-Mail + SMTP_HOST: ${SMTP_HOST} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USER: ${SMTP_USER} + SMTP_PASS: ${SMTP_PASS} + MAIL_TO: ${MAIL_TO} + # App + VORLAUF: ${VORLAUF:-14} + CHECK_SECRET: ${CHECK_SECRET} + AUTH_USERS: ${AUTH_USERS} + AUTH_SECRET: ${AUTH_SECRET} + env_file: + - .env.local diff --git a/lib/auth.ts b/lib/auth.ts new file mode 100644 index 0000000..d117b19 --- /dev/null +++ b/lib/auth.ts @@ -0,0 +1,36 @@ +/** + * Authentifizierungsbibliothek + * Benutzer via Umgebungsvariable konfigurieren: + * AUTH_USERS=user1:passwort1,user2:passwort2 + */ + +export interface User { + username: string; + password: string; +} + +export function getUsers(): User[] { + const usersString = process.env.AUTH_USERS || ''; + if (!usersString) { + console.warn('AUTH_USERS nicht in .env konfiguriert'); + return []; + } + return usersString + .split(',') + .map((userPair) => { + const [username, password] = userPair.trim().split(':'); + return { username: username?.trim(), password: password?.trim() }; + }) + .filter((user) => user.username && user.password); +} + +export function verifyCredentials(username: string, password: string): boolean { + const users = getUsers(); + const user = users.find(u => u.username === username); + if (!user) return false; + return user.password === password; +} + +export function isAuthEnabled(): boolean { + return !!process.env.AUTH_USERS; +} diff --git a/lib/checkAblauf.ts b/lib/checkAblauf.ts new file mode 100644 index 0000000..558df6d --- /dev/null +++ b/lib/checkAblauf.ts @@ -0,0 +1,26 @@ +import moment from 'moment'; +import { TabletteRaw } from '@/types/tablette'; + +const VORLAUF = parseInt(process.env.VORLAUF || '14', 10); + +export interface AblaufResult { + akt: number; + until: Date; + rtage: number; + warn: boolean; +} + +export function checkAblauf(item: Pick): AblaufResult { + const now = moment(); + const atday = moment(item.at); + const days = moment.duration(now.diff(atday)).asDays(); + const aktAnzahl = item.cnt - Math.floor(days) * item.pday; + const reichtTage = Math.floor(aktAnzahl / item.pday); + const rbis = now.add(reichtTage, 'day').startOf('day'); + return { + akt: aktAnzahl, + until: rbis.toDate(), + rtage: reichtTage, + warn: reichtTage <= VORLAUF, + }; +} diff --git a/lib/mailService.ts b/lib/mailService.ts new file mode 100644 index 0000000..1863e14 --- /dev/null +++ b/lib/mailService.ts @@ -0,0 +1,49 @@ +import nodemailer from 'nodemailer'; +import moment from 'moment'; +import type { RowDataPacket } from 'mysql2'; +import pool from './mysql'; +import { checkAblauf } from './checkAblauf'; + +// SMTP-Konfiguration via Umgebungsvariablen (Passwort niemals hart codieren!) +const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST || 'smtp.1und1.de', + port: parseInt(process.env.SMTP_PORT || '587', 10), + secure: false, + auth: { + user: process.env.SMTP_USER || '', + pass: process.env.SMTP_PASS || '', + }, +}); + +export async function doCheckAndMail(): Promise { + const [rows] = await pool.query( + 'SELECT tab, pday, cnt, at FROM tabletten' + ); + + let body = ''; + + for (const item of rows) { + if (item.pday !== 0) { + const updates = checkAblauf({ cnt: item.cnt, pday: item.pday, at: item.at }); + await pool.execute( + 'UPDATE tabletten SET akt = ?, until = ?, warn = ? WHERE tab = ?', + [updates.akt, moment(updates.until).format('YYYY-MM-DD'), updates.warn ? 1 : 0, item.tab] + ); + if (updates.warn) { + const name = item.tab.split(' ')[0]; + body += `"${name}" wird am ${moment(updates.until).format('YYYY-MM-DD')} (in ${updates.rtage} Tagen) zu Ende sein\n`; + } + } + } + + if (body) { + await transporter.sendMail({ + from: `"Tabletten" <${process.env.SMTP_USER}>`, + to: process.env.MAIL_TO || '', + subject: 'Tabletten gehen zu Ende', + text: body, + }); + } + + return body || 'Kein Warn-Eintrag.'; +} diff --git a/lib/mongodb.ts b/lib/mongodb.ts new file mode 100644 index 0000000..aed65e4 --- /dev/null +++ b/lib/mongodb.ts @@ -0,0 +1,36 @@ +import { MongoClient, MongoClientOptions } from 'mongodb'; + +const MONGOHOST = process.env.MONGOHOST || 'localhost'; +const MONGOPORT = process.env.MONGOPORT || '27017'; +const MONGOAUTH = process.env.MONGOAUTH === 'true'; +const MONGOUSRP = process.env.MONGOUSRP || ''; +export const MONGOBASE = process.env.MONGOBASE || 'medizin'; + +const MONGO_URL = MONGOAUTH + ? `mongodb://${MONGOUSRP}@${MONGOHOST}:${MONGOPORT}/?authSource=admin` + : `mongodb://${MONGOHOST}:${MONGOPORT}`; + + console.log("auth:", MONGOAUTH, "url:", MONGO_URL) +const options: MongoClientOptions = {}; + +let client: MongoClient; +let clientPromise: Promise; + +declare global { + // eslint-disable-next-line no-var + var _mongoClientPromise: Promise | undefined; +} + +if (process.env.NODE_ENV === 'development') { + // In development, use a global variable to preserve the connection across HMR reloads + if (!global._mongoClientPromise) { + client = new MongoClient(MONGO_URL, options); + global._mongoClientPromise = client.connect(); + } + clientPromise = global._mongoClientPromise; +} else { + client = new MongoClient(MONGO_URL, options); + clientPromise = client.connect(); +} + +export default clientPromise; diff --git a/lib/mysql.ts b/lib/mysql.ts new file mode 100644 index 0000000..1b6dde6 --- /dev/null +++ b/lib/mysql.ts @@ -0,0 +1,13 @@ +import mysql from 'mysql2/promise'; + +const pool = mysql.createPool({ + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '3306', 10), + user: process.env.DB_USER || 'root', + password: process.env.DB_PASS || '', + database: 'medizin', + waitForConnections: true, + connectionLimit: 10, +}); + +export default pool; diff --git a/lib/session.ts b/lib/session.ts new file mode 100644 index 0000000..51f4393 --- /dev/null +++ b/lib/session.ts @@ -0,0 +1,70 @@ +import { cookies } from 'next/headers'; +import { SignJWT, jwtVerify } from 'jose'; + +const SESSION_COOKIE_NAME = 'auth_session'; +const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 Tage + +const secretKey = process.env.AUTH_SECRET || 'default-secret-change-in-production'; +const key = new TextEncoder().encode(secretKey); + +export interface SessionData { + username: string; + isAuthenticated: boolean; + expiresAt: number; +} + +async function encrypt(payload: SessionData): Promise { + return await new SignJWT(payload as unknown as Record) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime(new Date(payload.expiresAt)) + .sign(key); +} + +async function decrypt(token: string): Promise { + try { + const { payload } = await jwtVerify(token, key, { + algorithms: ['HS256'], + }); + return { + username: payload.username as string, + isAuthenticated: payload.isAuthenticated as boolean, + expiresAt: payload.expiresAt as number, + }; + } catch { + return null; + } +} + +export async function createSession(username: string): Promise { + const expiresAt = Date.now() + SESSION_DURATION; + const session: SessionData = { username, isAuthenticated: true, expiresAt }; + const encryptedSession = await encrypt(session); + const cookieStore = await cookies(); + cookieStore.set(SESSION_COOKIE_NAME, encryptedSession, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + expires: expiresAt, + sameSite: 'lax', + path: '/', + }); +} + +export async function getSession(): Promise { + const cookieStore = await cookies(); + const cookie = cookieStore.get(SESSION_COOKIE_NAME); + if (!cookie?.value) return null; + const session = await decrypt(cookie.value); + if (!session || session.expiresAt < Date.now()) return null; + return session; +} + +export async function deleteSession(): Promise { + const cookieStore = await cookies(); + cookieStore.delete(SESSION_COOKIE_NAME); +} + +export async function isAuthenticated(): Promise { + const session = await getSession(); + return session?.isAuthenticated ?? false; +} diff --git a/next.config.ts b/next.config.ts index e9ffa30..225e495 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + output: 'standalone', }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index d72ad95..6c22306 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,17 +8,25 @@ "name": "tabletten_next", "version": "0.1.0", "dependencies": { + "jose": "^6.2.1", + "moment": "^2.30.1", + "mysql2": "^3.19.1", "next": "16.1.6", + "node-schedule": "^2.1.1", + "nodemailer": "^8.0.2", "react": "19.2.3", "react-dom": "19.2.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/node-schedule": "^2.1.8", + "@types/nodemailer": "^7.0.11", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.1.6", + "mongodb": "^7.1.0", "tailwindcss": "^4", "typescript": "^5" } @@ -1022,6 +1030,16 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz", + "integrity": "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1550,10 +1568,30 @@ "version": "20.19.37", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node-schedule": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@types/node-schedule/-/node-schedule-2.1.8.tgz", + "integrity": "sha512-k00g6Yj/oUg/CDC+MeLHUzu0+OFxWbIqrFfDiLi6OPKxTujvpv29mHGM8GtKr7B+9Vv92FcK/8mRqi1DK5f3hA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "@types/node": "*" + } + }, + "node_modules/@types/nodemailer": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" } }, "node_modules/@types/react": { @@ -1577,6 +1615,23 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.57.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz", @@ -2409,6 +2464,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/axe-core": { "version": "4.11.1", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", @@ -2507,6 +2571,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bson": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz", + "integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -2644,6 +2718,18 @@ "dev": true, "license": "MIT" }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2788,6 +2874,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3246,6 +3341,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -3667,6 +3763,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -3931,6 +4036,22 @@ "hermes-estree": "0.25.1" } }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4253,6 +4374,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -4440,6 +4567,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz", + "integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4851,6 +4987,18 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/long-timeout": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz", + "integrity": "sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -4874,6 +5022,30 @@ "yallist": "^3.0.2" } }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4894,6 +5066,13 @@ "node": ">= 0.4" } }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "dev": true, + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4941,6 +5120,76 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/mongodb": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.1.0.tgz", + "integrity": "sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^7.1.1", + "mongodb-connection-string-url": "^7.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.806.0", + "@mongodb-js/zstd": "^7.0.0", + "gcp-metadata": "^7.0.1", + "kerberos": "^7.0.0", + "mongodb-client-encryption": ">=7.0.0 <7.1.0", + "snappy": "^7.3.2", + "socks": "^2.8.6" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", + "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^13.0.0", + "whatwg-url": "^14.1.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4948,6 +5197,40 @@ "dev": true, "license": "MIT" }, + "node_modules/mysql2": { + "version": "3.19.1", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.19.1.tgz", + "integrity": "sha512-yn4zh+Uxu5J3Zvi6Ao96lJ7BSBRkspHflWQAmOPND+htbpIKDQw99TTvPzgihKO/QyMickZopO4OsnixnpcUwA==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.3.3" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -5096,6 +5379,29 @@ "dev": true, "license": "MIT" }, + "node_modules/node-schedule": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/node-schedule/-/node-schedule-2.1.1.tgz", + "integrity": "sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==", + "license": "MIT", + "dependencies": { + "cron-parser": "^4.2.0", + "long-timeout": "0.1.1", + "sorted-array-functions": "^1.3.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/nodemailer": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.2.tgz", + "integrity": "sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5643,6 +5949,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -5865,6 +6177,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sorted-array-functions": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz", + "integrity": "sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5874,6 +6192,31 @@ "node": ">=0.10.0" } }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/sql-escaper": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", + "integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -6163,6 +6506,19 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -6361,7 +6717,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { @@ -6440,6 +6795,30 @@ "punycode": "^2.1.0" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index e4fa624..5f57bf8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tabletten_next", - "version": "0.1.0", + "version": "1.0.0", "private": true, "scripts": { "dev": "next dev", @@ -9,17 +9,25 @@ "lint": "eslint" }, "dependencies": { + "jose": "^6.2.1", + "moment": "^2.30.1", + "mysql2": "^3.19.1", "next": "16.1.6", + "node-schedule": "^2.1.1", + "nodemailer": "^8.0.2", "react": "19.2.3", "react-dom": "19.2.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/node-schedule": "^2.1.8", + "@types/nodemailer": "^7.0.11", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.1.6", + "mongodb": "^7.1.0", "tailwindcss": "^4", "typescript": "^5" } diff --git a/proxy.ts b/proxy.ts new file mode 100644 index 0000000..bab5de2 --- /dev/null +++ b/proxy.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { jwtVerify } from 'jose'; + +const SESSION_COOKIE_NAME = 'auth_session'; + +export async function proxy(request: NextRequest) { + const { pathname } = request.nextUrl; + + // Wenn AUTH_USERS nicht gesetzt, alles durchlassen + if (!process.env.AUTH_USERS) { + return NextResponse.next(); + } + + // /login und /api/check sind öffentlich + const publicPaths = ['/login', '/api/check']; + if (publicPaths.some(p => pathname.startsWith(p))) { + return NextResponse.next(); + } + + const sessionCookie = request.cookies.get(SESSION_COOKIE_NAME); + if (!sessionCookie) { + return NextResponse.redirect(new URL('/login', request.url)); + } + + try { + const secretKey = process.env.AUTH_SECRET || 'default-secret-change-in-production'; + const key = new TextEncoder().encode(secretKey); + const { payload } = await jwtVerify(sessionCookie.value, key, { + algorithms: ['HS256'], + }); + + if (payload.expiresAt && (payload.expiresAt as number) < Date.now()) { + const response = NextResponse.redirect(new URL('/login', request.url)); + response.cookies.delete(SESSION_COOKIE_NAME); + return response; + } + + return NextResponse.next(); + } catch { + const response = NextResponse.redirect(new URL('/login', request.url)); + response.cookies.delete(SESSION_COOKIE_NAME); + return response; + } +} + +export const config = { + matcher: [ + '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', + ], +}; diff --git a/scripts/migrate-mongo-to-mysql.mjs b/scripts/migrate-mongo-to-mysql.mjs new file mode 100644 index 0000000..8e4cc93 --- /dev/null +++ b/scripts/migrate-mongo-to-mysql.mjs @@ -0,0 +1,121 @@ +/** + * Migrationsskript: MongoDB → SQL-Dump + * + * Liest alle Einträge aus MongoDB und erzeugt eine Datei + * scripts/tabletten_dump.sql, die du über phpMyAdmin importieren kannst. + * + * Ausführen: + * node scripts/migrate-mongo-to-mysql.mjs + * + * Danach in phpMyAdmin: + * Datenbank "medizin" auswählen → Importieren → tabletten_dump.sql hochladen + */ + +import { readFileSync, writeFileSync } from 'fs'; +import { MongoClient } from 'mongodb'; + +// .env.local manuell einlesen (Next.js lädt es nicht automatisch bei node) +try { + const env = readFileSync(new URL('../.env.local', import.meta.url), 'utf8'); + for (const line of env.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + const key = trimmed.slice(0, eqIdx).trim(); + const val = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, ''); + if (!(key in process.env)) process.env[key] = val; + } +} catch { + console.warn('Kein .env.local gefunden – nutze vorhandene Umgebungsvariablen.'); +} + +// ── MongoDB-Verbindung ────────────────────────────────────────────────────── +const MONGOHOST = process.env.MONGOHOST || 'localhost'; +const MONGOPORT = process.env.MONGOPORT || '27017'; +const MONGOAUTH = process.env.MONGOAUTH === 'true'; +const MONGOUSRP = process.env.MONGOUSRP || ''; +const MONGOBASE = process.env.MONGOBASE || 'medizin'; + +const mongoUrl = MONGOAUTH + ? `mongodb://${MONGOUSRP}@${MONGOHOST}:${MONGOPORT}/?authSource=admin` + : `mongodb://${MONGOHOST}:${MONGOPORT}`; + +// ── Daten aus MongoDB lesen ───────────────────────────────────────────────── +const mongoClient = new MongoClient(mongoUrl); +await mongoClient.connect(); + +const docs = await mongoClient + .db(MONGOBASE) + .collection('tabletten') + .find({}) + .project({ _id: 0 }) + .toArray(); + +await mongoClient.close(); +console.log(`${docs.length} Einträge aus MongoDB gelesen.`); + +// ── SQL-Dump erzeugen ─────────────────────────────────────────────────────── +const lines = []; + +// Spaltenreihenfolge im CREATE TABLE: tab, pday, cnt, at, akt, until, warn, rem, order +lines.push('-- Tabletten-Dump aus MongoDB'); +lines.push(`-- Erstellt: ${new Date().toISOString()}`); +lines.push('-- Importieren in phpMyAdmin: Datenbank auswaehlen -> Importieren -> Datei hochladen'); +lines.push(''); +lines.push('SET NAMES utf8mb4;'); +lines.push('SET SQL_MODE = "";'); +lines.push(''); +// Backticks NUR fuer reservierte Woerter: at, until, order +lines.push('CREATE TABLE IF NOT EXISTS tabletten ('); +lines.push(' tab VARCHAR(255) NOT NULL,'); +lines.push(' pday FLOAT NOT NULL DEFAULT 1,'); +lines.push(' cnt INT NOT NULL DEFAULT 0,'); +lines.push(' `at` DATE,'); +lines.push(' akt FLOAT NOT NULL DEFAULT 0,'); +lines.push(' `until` DATE,'); +lines.push(' warn TINYINT(1) NOT NULL DEFAULT 0,'); +lines.push(' rem VARCHAR(255) NOT NULL DEFAULT \'\','); +lines.push(' `order` VARCHAR(255) NOT NULL DEFAULT \'\','); +lines.push(' PRIMARY KEY (tab)'); +lines.push(') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;'); +lines.push(''); + +for (const d of docs) { + const tab = esc(d.tab ?? ''); + const pday = parseFloat(d.pday) || 1; + const cnt = parseInt(d.cnt, 10) || 0; + const at = sqlDate(d.at); + const akt = parseFloat(d.akt) || 0; + const until = sqlDate(d.until); + const warn = d.warn ? 1 : 0; + const rem = esc(d.rem ?? ''); + const order = esc(d.order ?? ''); + + const atVal = at ? `'${at}'` : 'NULL'; + const untilVal = until ? `'${until}'` : 'NULL'; + + // Positionaler INSERT ohne Spaltennamen-Liste -> keine Backticks noetig + lines.push( + `INSERT INTO tabletten VALUES` + + ` ('${tab}',${pday},${cnt},${atVal},${akt},${untilVal},${warn},'${rem}','${order}');` + ); + console.log(` ✓ ${d.tab}`); +} + +const outPath = new URL('tabletten_dump.sql', import.meta.url); +writeFileSync(outPath, lines.join('\n') + '\n', 'utf8'); +console.log(`\nDump geschrieben: scripts/tabletten_dump.sql`); +console.log('→ In phpMyAdmin: Datenbank "medizin" → Importieren → Datei hochladen'); + +// ── Hilfsfunktionen ───────────────────────────────────────────────────────── +function esc(str) { + return String(str).replace(/\\/g, '\\\\').replace(/'/g, "\\'"); +} + +function sqlDate(val) { + if (!val) return null; + const d = new Date(val); + if (isNaN(d.getTime())) return null; + return d.toISOString().slice(0, 10); +} diff --git a/scripts/tabletten_dump.sql b/scripts/tabletten_dump.sql new file mode 100644 index 0000000..90113d0 --- /dev/null +++ b/scripts/tabletten_dump.sql @@ -0,0 +1,30 @@ +-- Tabletten-Dump aus MongoDB +-- Erstellt: 2026-03-11T13:08:08.278Z + +use medizin; + +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS `tabletten` ( + `tab` VARCHAR(255) NOT NULL, + `pday` FLOAT NOT NULL DEFAULT 1, + `cnt` INT NOT NULL DEFAULT 0, + `at` DATE, + `akt` FLOAT NOT NULL DEFAULT 0, + `until` DATE, + `warn` TINYINT(1) NOT NULL DEFAULT 0, + `rem` VARCHAR(255) NOT NULL DEFAULT '', + `order` VARCHAR(255) NOT NULL DEFAULT '', + PRIMARY KEY (`tab`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO `tabletten` (`tab`,`pday`,`cnt`,`at`,`akt`,`until`,`warn`,`rem`,`order`) VALUES ('Jardiance 25mg',0.5,82,'2026-02-01',63,'2026-07-14',0,'','') ON DUPLICATE KEY UPDATE `pday`=VALUES(`pday`),`cnt`=VALUES(`cnt`),`at`=VALUES(`at`), `akt`=VALUES(`akt`),`until`=VALUES(`until`),`warn`=VALUES(`warn`), `rem`=VALUES(`rem`),`order`=VALUES(`order`); +INSERT INTO `tabletten` (`tab`,`pday`,`cnt`,`at`,`akt`,`until`,`warn`,`rem`,`order`) VALUES ('Repaglinid 2mg',2,237,'2026-02-01',161,'2026-05-29',0,'nach Bedarf','') ON DUPLICATE KEY UPDATE `pday`=VALUES(`pday`),`cnt`=VALUES(`cnt`),`at`=VALUES(`at`), `akt`=VALUES(`akt`),`until`=VALUES(`until`),`warn`=VALUES(`warn`), `rem`=VALUES(`rem`),`order`=VALUES(`order`); +INSERT INTO `tabletten` (`tab`,`pday`,`cnt`,`at`,`akt`,`until`,`warn`,`rem`,`order`) VALUES ('ASS 100',1,126,'2026-02-01',88,'2026-06-06',0,'','') ON DUPLICATE KEY UPDATE `pday`=VALUES(`pday`),`cnt`=VALUES(`cnt`),`at`=VALUES(`at`), `akt`=VALUES(`akt`),`until`=VALUES(`until`),`warn`=VALUES(`warn`), `rem`=VALUES(`rem`),`order`=VALUES(`order`); +INSERT INTO `tabletten` (`tab`,`pday`,`cnt`,`at`,`akt`,`until`,`warn`,`rem`,`order`) VALUES ('Hygroton 25mg',1,135,'2026-02-01',97,'2026-06-15',0,'','') ON DUPLICATE KEY UPDATE `pday`=VALUES(`pday`),`cnt`=VALUES(`cnt`),`at`=VALUES(`at`), `akt`=VALUES(`akt`),`until`=VALUES(`until`),`warn`=VALUES(`warn`), `rem`=VALUES(`rem`),`order`=VALUES(`order`); +INSERT INTO `tabletten` (`tab`,`pday`,`cnt`,`at`,`akt`,`until`,`warn`,`rem`,`order`) VALUES ('Atorvastatin 20mg',1,162,'2026-02-01',124,'2026-07-12',0,'','') ON DUPLICATE KEY UPDATE `pday`=VALUES(`pday`),`cnt`=VALUES(`cnt`),`at`=VALUES(`at`), `akt`=VALUES(`akt`),`until`=VALUES(`until`),`warn`=VALUES(`warn`), `rem`=VALUES(`rem`),`order`=VALUES(`order`); +INSERT INTO `tabletten` (`tab`,`pday`,`cnt`,`at`,`akt`,`until`,`warn`,`rem`,`order`) VALUES ('Irbesartan 300',1,87,'2026-02-01',49,'2026-04-28',0,'','') ON DUPLICATE KEY UPDATE `pday`=VALUES(`pday`),`cnt`=VALUES(`cnt`),`at`=VALUES(`at`), `akt`=VALUES(`akt`),`until`=VALUES(`until`),`warn`=VALUES(`warn`), `rem`=VALUES(`rem`),`order`=VALUES(`order`); +INSERT INTO `tabletten` (`tab`,`pday`,`cnt`,`at`,`akt`,`until`,`warn`,`rem`,`order`) VALUES ('Dekristol 1000 I.E.',1,113,'2026-02-01',75,'2026-05-24',0,'','') ON DUPLICATE KEY UPDATE `pday`=VALUES(`pday`),`cnt`=VALUES(`cnt`),`at`=VALUES(`at`), `akt`=VALUES(`akt`),`until`=VALUES(`until`),`warn`=VALUES(`warn`), `rem`=VALUES(`rem`),`order`=VALUES(`order`); +INSERT INTO `tabletten` (`tab`,`pday`,`cnt`,`at`,`akt`,`until`,`warn`,`rem`,`order`) VALUES ('Nadeln',1,71,'2026-02-01',33,'2026-04-12',0,'','') ON DUPLICATE KEY UPDATE `pday`=VALUES(`pday`),`cnt`=VALUES(`cnt`),`at`=VALUES(`at`), `akt`=VALUES(`akt`),`until`=VALUES(`until`),`warn`=VALUES(`warn`), `rem`=VALUES(`rem`),`order`=VALUES(`order`); +INSERT INTO `tabletten` (`tab`,`pday`,`cnt`,`at`,`akt`,`until`,`warn`,`rem`,`order`) VALUES ('Tresiba',11,3410,'2026-02-01',2992,'2026-12-07',0,'','') ON DUPLICATE KEY UPDATE `pday`=VALUES(`pday`),`cnt`=VALUES(`cnt`),`at`=VALUES(`at`), `akt`=VALUES(`akt`),`until`=VALUES(`until`),`warn`=VALUES(`warn`), `rem`=VALUES(`rem`),`order`=VALUES(`order`); +INSERT INTO `tabletten` (`tab`,`pday`,`cnt`,`at`,`akt`,`until`,`warn`,`rem`,`order`) VALUES ('Vildagliptin / Sitagliptin 50mg/1000mg',2,357,'2026-02-01',281,'2026-07-28',0,'','') ON DUPLICATE KEY UPDATE `pday`=VALUES(`pday`),`cnt`=VALUES(`cnt`),`at`=VALUES(`at`), `akt`=VALUES(`akt`),`until`=VALUES(`until`),`warn`=VALUES(`warn`), `rem`=VALUES(`rem`),`order`=VALUES(`order`); diff --git a/types/tablette.ts b/types/tablette.ts new file mode 100644 index 0000000..b94658f --- /dev/null +++ b/types/tablette.ts @@ -0,0 +1,30 @@ +export interface Tablette { + tab: string; + pday: number; + cnt: number; + at: string; // ISO date string "YYYY-MM-DD" or "" + akt: number; + until: string; // ISO date string "YYYY-MM-DD" or "" + warn: boolean; + rem: string; + order: string; +} + +export interface TabletteRaw { + tab: string; + pday: number; + cnt: number; + at: Date | string; + akt: number; + until: Date | string; + warn: boolean; + rem: string; + order: string; +} + +export interface DataResponse { + total: number; + page: number; + records: number; + values: Tablette[]; +}