5 Commits
Author SHA1 Message Date
kennyboy55 062bdbd16e Add support for Brands: introduce prisma migrate
Build dev docker image / build (push) Successful in 25s
2026-08-07 11:01:27 +02:00
kennyboy55 7f7ae8e1ac AI Changes: offline handling, fullscreen handling
Build dev docker image / build (push) Successful in 32s
Build dev docker image / build (push) Successful in 47s
Build dev docker image / release (push) Successful in 1s
2026-08-03 17:56:28 +02:00
kennyboy55 c816af19a5 AI Changes: new scan page, add inventory filter
Build dev docker image / build (push) Successful in 43s
Build dev docker image / release (push) Successful in 1s
2026-08-03 12:48:09 +02:00
kennyboy55 734c52c898 Bugfix with usertype
Build dev docker image / release (push) Successful in 1s
Build dev docker image / build (push) Successful in 36s
2026-08-01 12:04:21 +02:00
kennyboy55 e4c93eb024 AI Changes: add user management and hashed passwords, admin dashboard, report fixes
Build dev docker image / build (push) Successful in 34s
Build dev docker image / release (push) Successful in 1s
2026-08-01 11:47:21 +02:00
22 changed files with 1086 additions and 107 deletions
+3 -5
View File
@@ -2,8 +2,6 @@ name: Build dev docker image
on: on:
push: push:
branches:
- main
tags: tags:
- '*' - '*'
workflow_dispatch: workflow_dispatch:
@@ -84,6 +82,6 @@ jobs:
tag_name: ${{ gitea.ref_name }} tag_name: ${{ gitea.ref_name }}
name: ${{ gitea.ref_name }} name: ${{ gitea.ref_name }}
body: | body: |
"## Docker images" ## Docker images
"`gitea.furb.it/${{ gitea.repository }}:${{ gitea.ref_name }}`" `gitea.furb.it/${{ gitea.repository }}:${{ gitea.ref_name }}`
"`gitea.furb.it/${{ gitea.repository }}:latest`" `gitea.furb.it/${{ gitea.repository }}:latest`
+36
View File
@@ -0,0 +1,36 @@
name: Build dev docker image
on:
push:
branches:
- main
- feature/*
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
# Checkout repository for build
- name: Checkout beer-inventory repository
uses: actions/checkout@v4
with:
ref: ${{ gitea.ref }}
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
# Install NPM packages
- name: Install NPM packages
run: npm ci
# Build project
- name: Build project
run: |
npx zenstack generate
npx remix vite:build
+5
View File
@@ -18,3 +18,8 @@ Architecture:
- user (can view the website on their phone) - user (can view the website on their phone)
- scanner (the physical raspberry pi with barcode scanner at the fridge) - scanner (the physical raspberry pi with barcode scanner at the fridge)
- admin (me, the developer that can access and update everything) - admin (me, the developer that can access and update everything)
Building and validation:
`npx remix vite:build` to build the project and check for errors
`npx remix vite:dev` to run the project in live dev mode
`npx remix routes` to validate all routes are present and correct
+2 -1
View File
@@ -13,9 +13,10 @@ COPY . .
ENV NODE_ENV=production ENV NODE_ENV=production
ENV DATABASE_URL="REQUIRED" ENV DATABASE_URL="REQUIRED"
RUN chmod +x ./migrate.sh
RUN npx zenstack generate RUN npx zenstack generate
EXPOSE 3000 EXPOSE 3000
VOLUME /usr/src/app/public VOLUME /usr/src/app/public
CMD ["/bin/sh", "-c", "npx prisma db push && npm start"] CMD ["/bin/sh", "-c", "./migrate.sh && npm start"]
+60 -2
View File
@@ -1,9 +1,67 @@
import { createHash, timingSafeEqual } from "node:crypto";
import { db } from "~/utils/db.server"; import { db } from "~/utils/db.server";
function normalizePassword(password: string) {
return password.trim();
}
export function hashPassword(password: string) {
const normalizedPassword = normalizePassword(password);
return createHash("sha256").update(normalizedPassword).digest("hex");
}
function isHashedPassword(value: string) {
return /^[a-f0-9]{64}$/i.test(value);
}
export function verifyPassword(password: string, storedPassword: string | null | undefined) {
if (!storedPassword) {
return false;
}
const normalizedPassword = normalizePassword(password);
const hashedPassword = hashPassword(normalizedPassword);
if (isHashedPassword(storedPassword)) {
const storedBuffer = Buffer.from(storedPassword);
const hashedBuffer = Buffer.from(hashedPassword);
if (hashedBuffer.length !== storedBuffer.length) {
return false;
}
return timingSafeEqual(hashedBuffer, storedBuffer);
}
return normalizePassword(storedPassword) === normalizedPassword;
}
export async function validateCredentials( export async function validateCredentials(
username: string, username: string,
password: string password: string
) { ) {
var user = await db.user.findUnique({where:{username: username, password: password}, select:{id: true}}); const user = await db.user.findUnique({
return user?.id; where: { username },
select: { id: true, password: true },
});
if (!user) {
return null;
}
const passwordMatches = verifyPassword(password, user.password);
if (!passwordMatches) {
return null;
}
if (!isHashedPassword(user.password ?? "")) {
await db.user.update({
where: { id: user.id },
data: { password: hashPassword(password) },
});
}
return user.id;
} }
+7 -7
View File
@@ -69,10 +69,10 @@ function DrinkFilter(arg : Arguments) {
["name", "Name"], ["name", "Name"],
["new", "New"], ["new", "New"],
["popular", "Popularity"], ["popular", "Popularity"],
["abv-asc", "ABV Low -> High"], ["abv-asc", "Alcohol (Low -> High)"],
["abv-desc", "ABV High -> Low"], ["abv-desc", "Alcohol (High -> Low)"],
["inv-asc", "Inventoy Low -> High"], ["inv-asc", "Inventory (Low -> High)"],
["inv-desc", "Inventory High -> Low"] ["inv-desc", "Inventory (High -> Low)"]
].map((pair) => ( ].map((pair) => (
<Form.Check <Form.Check
id={"sort-" + pair[0]} id={"sort-" + pair[0]}
@@ -86,7 +86,7 @@ function DrinkFilter(arg : Arguments) {
</Form.Group> </Form.Group>
<Form.Group controlId="drink-type" className='bg-body-secondary rounded p-3 mt-3'> <Form.Group controlId="drink-type" className='bg-body-secondary rounded p-3 mt-3'>
<Form.Label className='fw-bold'>Drink type</Form.Label> <Form.Label className='fw-bold'>Drink Type</Form.Label>
{ ["Beer", "Wine", "Soda", "Cocktail"].map(key => ( { ["Beer", "Wine", "Soda", "Cocktail"].map(key => (
<Form.Check <Form.Check
id={"drink-" + key} id={"drink-" + key}
@@ -100,8 +100,8 @@ function DrinkFilter(arg : Arguments) {
</Form.Group> </Form.Group>
<Form.Group controlId="drink-abv" className='bg-body-secondary rounded p-3 mt-3'> <Form.Group controlId="drink-abv" className='bg-body-secondary rounded p-3 mt-3'>
<Form.Label className='fw-bold'>ABV</Form.Label> <Form.Label className='fw-bold'>Alcohol Percentage</Form.Label>
<div className='d-grid gap-2'>{abvOptionsMarkup}</div> <div className='d-grid'>{abvOptionsMarkup}</div>
</Form.Group> </Form.Group>
{/* <Form.Group controlId="manufacturer" className='bg-body-secondary rounded p-3 mt-3'> {/* <Form.Group controlId="manufacturer" className='bg-body-secondary rounded p-3 mt-3'>
<Form.Label className='fw-bold'>Manufacturer</Form.Label> <Form.Label className='fw-bold'>Manufacturer</Form.Label>
+1
View File
@@ -41,6 +41,7 @@ function Header(data: Arguments) {
</Nav> </Nav>
{ loggedin ? ( { loggedin ? (
<Nav> <Nav>
<Nav.Link as={Link} to="/users">Users</Nav.Link>
<Nav.Link as={Link} to="/logout">Logout ({username})</Nav.Link> <Nav.Link as={Link} to="/logout">Logout ({username})</Nav.Link>
</Nav> </Nav>
) : ( ) : (
+78 -1
View File
@@ -8,7 +8,7 @@ import {
isRouteErrorResponse, isRouteErrorResponse,
useRouteError useRouteError
} from "@remix-run/react"; } from "@remix-run/react";
import type { PropsWithChildren } from "react"; import React, { useEffect, useState, type PropsWithChildren } from "react";
import stylesheet from "bootstrap/dist/css/bootstrap.min.css?url"; import stylesheet from "bootstrap/dist/css/bootstrap.min.css?url";
@@ -53,6 +53,45 @@ export default function App() {
export function ErrorBoundary() { export function ErrorBoundary() {
const error = useRouteError(); const error = useRouteError();
const [isOnline, setIsOnline] = useState<boolean>(
typeof navigator !== "undefined" ? navigator.onLine : true
);
const tryRefresh = async () => {
if (typeof window === "undefined") return;
try {
const res = await fetch(window.location.href, { method: "GET", cache: "no-store" });
if (res && res.ok) {
window.location.reload();
}
} catch (e) {
// network still down; ignore, we'll retry on the next interval or when online
}
};
useEffect(() => {
if (typeof window === "undefined") return;
const handleOnline = () => {
setIsOnline(true);
tryRefresh();
};
const handleOffline = () => setIsOnline(false);
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
const hourly = window.setInterval(() => {
tryRefresh();
}, 60 * 60 * 1000);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
window.clearInterval(hourly);
};
}, []);
if (isRouteErrorResponse(error)) { if (isRouteErrorResponse(error)) {
return ( return (
<Document <Document
@@ -64,6 +103,25 @@ export function ErrorBoundary() {
<div className="h-100 p-5 text-bg-dark rounded-3"> <div className="h-100 p-5 text-bg-dark rounded-3">
<h1>{error.status}</h1> <h1>{error.status}</h1>
{error.statusText} {error.statusText}
<div className="mt-3">
<button
className="btn btn-primary me-2"
onClick={() => {
if (typeof window !== "undefined") window.location.reload();
}}
>
Reload page
</button>
<button
className="btn btn-outline-secondary"
onClick={() => {
tryRefresh();
}}
>
Try refresh now
</button>
<div className="mt-2">Status: {isOnline ? "Online" : "Offline"}. Will retry automatically every hour.</div>
</div>
</div> </div>
</Col> </Col>
</Row> </Row>
@@ -84,6 +142,25 @@ export function ErrorBoundary() {
<div className="h-100 p-5 text-bg-dark rounded-3"> <div className="h-100 p-5 text-bg-dark rounded-3">
<h1>App Error</h1> <h1>App Error</h1>
{errorMessage} {errorMessage}
<div className="mt-3">
<button
className="btn btn-primary me-2"
onClick={() => {
if (typeof window !== "undefined") window.location.reload();
}}
>
Reload page
</button>
<button
className="btn btn-outline-secondary"
onClick={() => {
tryRefresh();
}}
>
Try refresh now
</button>
<div className="mt-2">Status: {isOnline ? "Online" : "Offline"}. Will retry automatically every hour.</div>
</div>
</div> </div>
</Col> </Col>
</Row> </Row>
+26 -3
View File
@@ -1,8 +1,10 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node"; import { json } from "@remix-run/node";
import { useMemo, useState } from "react";
import { useLoaderData } from "@remix-run/react"; import { useLoaderData } from "@remix-run/react";
import { Button, Form } from "react-bootstrap"; import { Button, Form } from "react-bootstrap";
import { containerTypeToString } from "~/models/types"; import { containerTypeToString } from "~/models/types";
import { volume } from '~/utils/conversions';
import { enhance } from "~/utils/db.server"; import { enhance } from "~/utils/db.server";
@@ -12,7 +14,7 @@ export async function loader({
}: LoaderFunctionArgs) { }: LoaderFunctionArgs) {
const { dbe } = await enhance(request); const { dbe } = await enhance(request);
const containers = await dbe.container.findMany({select: {id: true, drink: true, type: true, inventory: true}, orderBy: {drink: {name: "asc"}}}); const containers = await dbe.container.findMany({select: {id: true, drink: true, type: true, inventory: true, volume: true}, orderBy: {drink: {name: "asc"}}});
return json({ containers }); return json({ containers });
}; };
@@ -44,16 +46,37 @@ export async function action({
export default function EditInventoryRoute() { export default function EditInventoryRoute() {
const lData = useLoaderData<typeof loader>(); const lData = useLoaderData<typeof loader>();
const [search, setSearch] = useState("");
const filteredContainers = useMemo(() => {
const searchText = search.trim().toLowerCase();
if (!searchText) return lData.containers;
return lData.containers.filter((container) => {
const label = `${container.drink.name} ${containerTypeToString(container.type)} ${container.inventory}`.toLowerCase();
return label.includes(searchText);
});
}, [lData.containers, search]);
return ( return (
<div> <div>
<h2>Update inventory</h2> <h2>Update inventory</h2>
<Form method="post"> <Form method="post">
<Form.Group className='mb-2'>
<Form.Label>Filter containers</Form.Label>
<Form.Control
type="search"
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
placeholder="Type drink name, type or inventory"
autoComplete="off"
/>
</Form.Group>
<Form.Group className='mb-2'> <Form.Group className='mb-2'>
<Form.Label>Drink container</Form.Label> <Form.Label>Drink container</Form.Label>
<Form.Select name="id"> <Form.Select name="id">
{ lData.containers.map((container) => ( { filteredContainers.map((container) => (
<option value={container.id}>{container.drink.name} - {containerTypeToString(container.type)} ({container.inventory})</option> <option key={container.id} value={container.id}>{container.drink.name} - {containerTypeToString(container.type)} - {volume(container.volume)} ({container.inventory})</option>
))} ))}
</Form.Select> </Form.Select>
</Form.Group> </Form.Group>
+11 -7
View File
@@ -17,23 +17,27 @@ export const action = async ({
const dateEnd = String(form.get("dateEnd")) + ":00.000z" || Date.now().toString(); const dateEnd = String(form.get("dateEnd")) + ":00.000z" || Date.now().toString();
try{ try{
const lastIdEntries = await dbe.report.findMany({select: {id: true}, take: 1, orderBy:{createdAt: "desc"}})
const containersWithHistoryAndDrink = await dbe.container.findMany({ const containersWithHistoryAndDrink = await dbe.container.findMany({
where: {checkouts: {some:{AND: [{checkoutAt: {gte: dateStart}}, {checkoutAt: {lte: dateEnd}}]}}}, where: {checkouts: {some:{AND: [{checkoutAt: {gte: dateStart}}, {checkoutAt: {lte: dateEnd}}]}}},
include: {drink: true, checkouts: {where:{AND: [{checkoutAt: {gte: dateStart}}, {checkoutAt: {lte: dateEnd}}]}}} include: {drink: true, checkouts: {where:{AND: [{checkoutAt: {gte: dateStart}}, {checkoutAt: {lte: dateEnd}}]}}}
}); });
const reportId = String(lastIdEntries[0] ? lastIdEntries[0].id + 1 : 0).padStart(8, "0"); const report = await dbe.report.create({ data: {
name: name,
dateStart: dateStart,
dateEnd: dateEnd
}
});
const reportId = String(report.id).padStart(8, "0");
const filepath = await createPdf(slugify(name), reportId, dateStart, dateEnd, containersWithHistoryAndDrink); const filepath = await createPdf(slugify(name), reportId, dateStart, dateEnd, containersWithHistoryAndDrink);
await dbe.report.create({ data: { await dbe.report.update({ data: {
name: name,
dateStart: dateStart,
dateEnd: dateEnd,
file: filepath file: filepath
} },
where: {id: report.id}
}); });
} }
+237 -4
View File
@@ -1,10 +1,173 @@
import { Link } from "@remix-run/react"; import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { ListGroup } from "react-bootstrap"; import { json, redirect } from "@remix-run/node";
import { Form, Link, useActionData, useLoaderData } from "@remix-run/react";
import { Alert, Button, Card, Col, Form as BootstrapForm, ListGroup, Row } from "react-bootstrap";
import { hashPassword } from "~/auth/validate";
import { enhance } from "~/utils/db.server";
export async function loader({ request }: LoaderFunctionArgs) {
const { dbe } = await enhance(request);
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
const [containersWithoutBarcode, checkoutsLastHour, latestCheckout, users] = await Promise.all([
dbe.container.count({ where: { barcode: null } }),
dbe.history.count({ where: { checkoutAt: { gte: oneHourAgo } } }),
dbe.history.findFirst({
orderBy: { checkoutAt: "desc" },
select: {
id: true,
checkoutAt: true,
container: {
select: {
drink: {
select: {
name: true,
},
},
},
},
},
}),
dbe.user.findMany({
select: { id: true, username: true, type: true },
orderBy: { username: "asc" },
}),
]);
return json({
containersWithoutBarcode,
checkoutsLastHour,
latestCheckout,
users,
});
}
export async function action({ request }: ActionFunctionArgs) {
const { dbe } = await enhance(request);
const form = await request.formData();
const intent = form.get("intent")?.toString();
if (intent === "undo-last-checkout") {
const checkoutId = Number(form.get("checkoutId"));
if (!checkoutId) {
return json({ error: "No checkout selected." });
}
const checkout = await dbe.history.findUnique({
where: { id: checkoutId },
select: { container_id: true },
});
if (checkout) {
await dbe.container.update({
where: { id: checkout.container_id },
data: { inventory: { increment: 1 } },
});
await dbe.history.delete({ where: { id: checkoutId } });
}
return redirect("/admin");
}
if (intent === "add-user") {
const username = form.get("username")?.toString().trim();
const password = form.get("password")?.toString() ?? "";
const type = form.get("type")?.toString();
if (!username || !password) {
return json({ error: "Username and password are required." });
}
const normalizedType = (type === "Admin" || type === "Scanner") ? type : "User";
try {
await dbe.user.create({
data: {
username,
password: hashPassword(password),
type: normalizedType,
},
});
return json({ success: "User added" });
} catch {
return json({ error: "That username is already in use." });
}
}
if (intent === "set-password") {
const userId = Number(form.get("userId"));
const password = form.get("password")?.toString() ?? "";
if (!userId || !password) {
return json({ error: "A user and a password are required." });
}
const existingUser = await dbe.user.findUnique({ where: { id: userId }, select: { id: true } });
if (!existingUser) {
return json({ error: "User not found." });
}
await dbe.user.update({
where: { id: userId },
data: { password: hashPassword(password) },
});
return json({ success: "Password set" });
}
return json({ error: "Unknown action." });
}
export default function AdminRoute() { export default function AdminRoute() {
const loaderData = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
return ( return (
<div> <div>
<h1>Admin</h1> <h1>Admin</h1>
<h2 className="mt-4">Dashboard</h2>
<Row className="g-3">
<Col md={4}>
<Card className="h-100">
<Card.Body>
<Card.Title>Containers without barcode</Card.Title>
<Card.Text className="display-6">{loaderData.containersWithoutBarcode}</Card.Text>
</Card.Body>
</Card>
</Col>
<Col md={4}>
<Card className="h-100">
<Card.Body>
<Card.Title>Checkouts in the last hour</Card.Title>
<Card.Text className="display-6">{loaderData.checkoutsLastHour}</Card.Text>
</Card.Body>
</Card>
</Col>
<Col md={4}>
<Card className="h-100">
<Card.Body>
<Card.Title>Last checked out</Card.Title>
<Card.Text>
{loaderData.latestCheckout?.container.drink.name ?? "No recent checkouts"}
</Card.Text>
{loaderData.latestCheckout ? (
<Form method="post">
<input type="hidden" name="intent" value="undo-last-checkout" />
<input type="hidden" name="checkoutId" value={loaderData.latestCheckout.id} />
<Button type="submit" variant="outline-danger">Undo</Button>
</Form>
) : null}
</Card.Body>
</Card>
</Col>
</Row>
<h2 className="mt-4">Quick actions</h2>
<ListGroup> <ListGroup>
<ListGroup.Item><Link to="/admin/new">Add new items</Link></ListGroup.Item> <ListGroup.Item><Link to="/admin/new">Add new items</Link></ListGroup.Item>
<ListGroup.Item><Link to="/admin/edit">Edit or remove items</Link></ListGroup.Item> <ListGroup.Item><Link to="/admin/edit">Edit or remove items</Link></ListGroup.Item>
@@ -12,10 +175,80 @@ export default function AdminRoute() {
<ListGroup.Item><Link to="/admin/manage/undo-checkout">Undo checkout</Link></ListGroup.Item> <ListGroup.Item><Link to="/admin/manage/undo-checkout">Undo checkout</Link></ListGroup.Item>
</ListGroup> </ListGroup>
<h1>Reports</h1> <h2 className="mt-4">Reports</h2>
<ListGroup> <ListGroup>
<ListGroup.Item><Link to="/admin/reports">Reports</Link></ListGroup.Item> <ListGroup.Item><Link to="/admin/reports">Reports</Link></ListGroup.Item>
</ListGroup> </ListGroup>
<h2 className="mt-4">User management</h2>
{actionData?.error ? <Alert variant="danger" className="mb-3">{actionData.error}</Alert> : null}
{actionData?.success ? <Alert variant="success" className="mb-3">{actionData.success}</Alert> : null}
<Row className="g-3 mb-4">
<Col lg={6}>
<Card>
<Card.Body>
<Card.Title>Add user</Card.Title>
<Form method="post">
<input type="hidden" name="intent" value="add-user" />
<BootstrapForm.Group className="mb-3">
<BootstrapForm.Label>Username</BootstrapForm.Label>
<BootstrapForm.Control name="username" required />
</BootstrapForm.Group>
<BootstrapForm.Group className="mb-3">
<BootstrapForm.Label>Password</BootstrapForm.Label>
<BootstrapForm.Control type="password" name="password" required />
</BootstrapForm.Group>
<BootstrapForm.Group className="mb-3">
<BootstrapForm.Label>Role</BootstrapForm.Label>
<BootstrapForm.Select name="type" defaultValue="User">
<option value="User">User</option>
<option value="Scanner">Scanner</option>
<option value="Admin">Admin</option>
</BootstrapForm.Select>
</BootstrapForm.Group>
<Button type="submit">Create user</Button>
</Form>
</Card.Body>
</Card>
</Col>
<Col lg={6}>
<Card>
<Card.Body>
<Card.Title>Set password</Card.Title>
<Form method="post">
<input type="hidden" name="intent" value="set-password" />
<BootstrapForm.Group className="mb-3">
<BootstrapForm.Label>User</BootstrapForm.Label>
<BootstrapForm.Select name="userId" defaultValue={loaderData.users[0]?.id ?? ""}>
{loaderData.users.map((user) => (
<option key={user.id} value={user.id}>{user.username} ({user.type})</option>
))}
</BootstrapForm.Select>
</BootstrapForm.Group>
<BootstrapForm.Group className="mb-3">
<BootstrapForm.Label>New password</BootstrapForm.Label>
<BootstrapForm.Control type="password" name="password" required />
</BootstrapForm.Group>
<Button type="submit" variant="outline-primary">Update password</Button>
</Form>
</Card.Body>
</Card>
</Col>
</Row>
<Card className="mb-4">
<Card.Body>
<Card.Title>Existing users</Card.Title>
<ListGroup variant="flush">
{loaderData.users.map((user) => (
<ListGroup.Item key={user.id} className="d-flex justify-content-between align-items-center">
<span>{user.username}</span>
<span className="text-muted">{user.type}</span>
</ListGroup.Item>
))}
</ListGroup>
</Card.Body>
</Card>
</div> </div>
); );
} }
+34 -23
View File
@@ -1,7 +1,6 @@
import { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; import { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { Outlet, json, useFetcher, useLoaderData, useRevalidator } from "@remix-run/react"; import { Outlet, json, useLoaderData, useRevalidator } from "@remix-run/react";
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { getSession } from "~/auth/session";
import Header, { HeaderData } from "~/components/header"; import Header, { HeaderData } from "~/components/header";
import { enhance } from "~/utils/db.server"; import { enhance } from "~/utils/db.server";
@@ -32,18 +31,16 @@ export default function InventoryLayout() {
let revalidator = useRevalidator(); let revalidator = useRevalidator();
const [checkouts, setCheckouts] = useState(data.checkouts); const [checkouts, setCheckouts] = useState(data.checkouts);
let fetcher = useFetcher(); const shouldFetchRef = useRef(true);
let shouldFetch = true;
// User has switched back to the tab // User has switched back to the tab
const onFocus = () => { const onFocus = () => {
shouldFetch = true; shouldFetchRef.current = true;
}; };
// User has switched away from the tab (AKA tab is hidden) // User has switched away from the tab (AKA tab is hidden)
const onBlur = () => { const onBlur = () => {
shouldFetch = false; shouldFetchRef.current = false;
}; };
useEffect(() => { useEffect(() => {
@@ -59,30 +56,44 @@ export default function InventoryLayout() {
}, []); }, []);
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const pollCheckouts = async () => {
if (!shouldFetchRef.current) {
return;
}
if(shouldFetch){ try {
fetcher.load("/resource/checkouts"); const response = await fetch("/resource/checkouts", { credentials: "same-origin" });
if (!response.ok) {
return;
}
if(fetcher.state === "idle"){ const payload = await response.json() as { checkouts?: number | string | null };
if(!fetcher.data) return; const newCheckouts = Number(payload.checkouts);
let newCheckouts = Number(fetcher.data.checkouts); if (!Number.isFinite(newCheckouts)) {
if(newCheckouts != checkouts){ return;
}
setCheckouts(newCheckouts); if (newCheckouts !== checkouts) {
setCheckouts(newCheckouts);
if (revalidator.state === "idle") { if (revalidator.state === "idle") {
revalidator.revalidate(); revalidator.revalidate();
}
} }
} }
} catch {
// Ignore transient network errors while the connection is down.
} }
};
const timer = window.setInterval(() => {
void pollCheckouts();
}, 5000); }, 5000);
return () => clearTimeout(timer); void pollCheckouts();
});
return () => window.clearInterval(timer);
}, [checkouts, revalidator]);
return ( return (
<> <>
+151 -45
View File
@@ -1,6 +1,7 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node"; import { json, redirect } from "@remix-run/node";
import { useActionData, useLoaderData } from "@remix-run/react"; import { useActionData, useLoaderData } from "@remix-run/react";
import { useEffect, useRef, useState } from "react";
import { Col, Container, Form, Row, Table } from "react-bootstrap"; import { Col, Container, Form, Row, Table } from "react-bootstrap";
import timeAgo from "~/utils/datetime"; import timeAgo from "~/utils/datetime";
@@ -27,9 +28,13 @@ export async function action({
const form = await request.formData(); const form = await request.formData();
const barcode = form.get("barcode")?.toString() || ""; const barcode = form.get("barcode")?.toString() || "";
const container = await dbe.container.findUnique({ const container = await dbe.container.findUnique({
select: {id: true, inventory: true, drink_id: true}, select: {
where: {barcode: barcode} id: true,
inventory: true,
drink: { select: { name: true, image: true } },
},
where: { barcode: barcode },
}); });
// No drink found for barcode, do nothing // No drink found for barcode, do nothing
@@ -48,58 +53,159 @@ export async function action({
// Log entry // Log entry
await dbe.history.create({data: {container: {connect: {id: container.id}}, inventoryAfter: newInventory}}); await dbe.history.create({data: {container: {connect: {id: container.id}}, inventoryAfter: newInventory}});
return json({error: ""}); return json({
error: "",
lastScanned: {
name: container.drink.name,
image: container.drink.image ?? null,
},
});
}; };
export default function ScanRoute() { export default function ScanRoute() {
const aData = useActionData<typeof action>(); const aData = useActionData<typeof action>();
const lData = useLoaderData<typeof loader>(); const lData = useLoaderData<typeof loader>();
const fullscreenBtnRef = useRef<HTMLButtonElement | null>(null);
const [lastScanned, setLastScanned] = useState<{name:string;image:string|null} | null>(null);
const [showLastScanned, setShowLastScanned] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const hasError = aData?.error ? aData.error.length > 0 : false; const hasError = aData?.error ? aData.error.length > 0 : false;
const focusInput = () => {
window.setTimeout(() => inputRef.current?.focus(), 0);
};
const toggleFullscreen = async () => {
if (typeof document === "undefined") return;
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else {
await document.documentElement.requestFullscreen();
}
} catch (e) {
// ignore errors (user gesture required in some contexts)
}
};
useEffect(() => {
focusInput();
}, []);
useEffect(() => {
focusInput();
}, [aData]);
useEffect(() => {
const handleBlur = () => {
focusInput();
};
const input = inputRef.current;
input?.addEventListener("blur", handleBlur);
return () => {
input?.removeEventListener("blur", handleBlur);
};
}, []);
useEffect(() => {
if (!aData?.lastScanned) {
return;
}
setLastScanned(aData.lastScanned);
setShowLastScanned(true);
const timer = window.setTimeout(() => {
setShowLastScanned(false);
}, 5000);
return () => window.clearTimeout(timer);
}, [aData?.lastScanned]);
return ( return (
<Container> <Container fluid className="mt-3">
<Row className="mt-3"> <Row>
<Col xs={4}> <Col xs={12} md={6} className="mb-3">
<Form method="post" action="/scan?index" noValidate> <h3 onClick={() => fullscreenBtnRef.current?.click()} style={{cursor: 'pointer'}}>History</h3>
<Form.Group> <div>
<Form.Label> <Table striped size="sm">
Scan barcode: <thead>
</Form.Label> <tr>
<Form.Control <th>Drink</th>
type="text" <th>When</th>
name="barcode" <th>Amount left</th>
isInvalid={hasError} </tr>
autoFocus </thead>
/> <tbody>
<Form.Control.Feedback type="invalid"> {lData.history.map((entry) => (
{aData?.error} <tr key={entry.id}>
</Form.Control.Feedback> <td>{entry.container.drink.name}</td>
</Form.Group> <td>{timeAgo(new Date(entry.checkoutAt))}</td>
</Form> <td>{entry.inventoryAfter}</td>
</tr>
))}
</tbody>
</Table>
</div>
</Col> </Col>
</Row> <Col xs={12} md={6}>
<Row className="mt-3"> <h3 onClick={() => fullscreenBtnRef.current?.click()} style={{cursor: 'pointer'}}>Scan Barcode</h3>
<Col xs={12}> <Row className="mb-3">
<h3>History</h3> <Col>
<Table striped> {/* Hidden fullscreen toggle button - triggered by clicking the h3 headers */}
<thead> <button
<tr> ref={fullscreenBtnRef}
<th>Drink</th> onClick={toggleFullscreen}
<th>When</th> style={{ display: 'none' }}
<th>Amount left</th> aria-hidden="true"
</tr> />
</thead> <Form method="post" action="/scan?index" noValidate>
<tbody> <Form.Group>
{lData.history.map((entry) => ( <Form.Control
<tr key={entry.id}> ref={inputRef}
<td>{entry.container.drink.name}</td> type="text"
<td>{timeAgo(new Date(entry.checkoutAt))}</td> name="barcode"
<td>{entry.inventoryAfter}</td> isInvalid={hasError}
</tr> autoComplete="off"
))} spellCheck={false}
</tbody> inputMode="text"
</Table> style={{ height: '3rem', fontSize: '1.8rem', textAlign: 'center' }}
/>
<Form.Control.Feedback type="invalid">
{aData?.error}
</Form.Control.Feedback>
</Form.Group>
</Form>
</Col>
</Row>
<Row>
<Col>
<div className="p-4 border rounded" style={{ minHeight: '50vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', textAlign: 'center' }}>
{showLastScanned && lastScanned ? (
<>
<h1 style={{ fontSize: '2rem', marginBottom: '1rem' }}>{lastScanned.name}</h1>
{lastScanned.image ? (
<img
src={lastScanned.image}
alt={lastScanned.name}
style={{ width: '100%', maxHeight: '40vh', objectFit: 'contain' }}
/>
) : (
<div className="text-muted" style={{ fontSize: '1.5rem' }}>No image available</div>
)}
</>
) : (
<div className="text-muted" style={{ fontSize: '2rem', fontWeight: 600 }}>
<h1>Please scan your drink</h1>
</div>
)}
</div>
</Col>
</Row>
</Col> </Col>
</Row> </Row>
</Container> </Container>
+118
View File
@@ -0,0 +1,118 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useActionData, useLoaderData } from "@remix-run/react";
import { Alert, Button, Card, Col, Container, Form as BootstrapForm, Row } from "react-bootstrap";
import { hashPassword, verifyPassword } from "~/auth/validate";
import { enhance } from "~/utils/db.server";
export async function loader({ request }: LoaderFunctionArgs) {
const { session, dbe } = await enhance(request);
if (!session.has("user_id")) {
return redirect("/login");
}
const user = await dbe.user.findUnique({
where: { id: Number(session.get("user_id")) },
select: { id: true, username: true, type: true },
});
if (!user) {
return redirect("/logout");
}
return json({ user });
}
export async function action({ request }: ActionFunctionArgs) {
const { session, dbe } = await enhance(request);
if (!session.has("user_id")) {
return redirect("/login");
}
const userId = Number(session.get("user_id"));
const form = await request.formData();
const currentPassword = form.get("currentPassword")?.toString() ?? "";
const newPassword = form.get("newPassword")?.toString() ?? "";
const confirmPassword = form.get("confirmPassword")?.toString() ?? "";
const user = await dbe.user.findUnique({
where: { id: userId },
select: { id: true, password: true },
});
if (!user) {
return json({ error: "User not found." }, { status: 404 });
}
const passwordMatches = verifyPassword(currentPassword, user.password);
if (!passwordMatches) {
return json({ error: "Current password is incorrect." });
}
if (newPassword.length < 4) {
return json({ error: "New password must be at least 4 characters long." });
}
if (newPassword !== confirmPassword) {
return json({ error: "New passwords do not match." });
}
await dbe.user.update({
where: { id: user.id },
data: { password: hashPassword(newPassword) },
});
return json({ success: "Password updated successfully." });
}
export default function UsersRoute() {
const loaderData = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
return (
<Container className="py-3">
<Row className="justify-content-center">
<Col md={8} lg={6}>
<Card>
<Card.Body>
<Card.Title>Account settings</Card.Title>
<Card.Subtitle className="mb-3 text-muted">
Manage the password for {loaderData.user.username}
</Card.Subtitle>
{actionData?.error ? (
<Alert variant="danger">{actionData.error}</Alert>
) : null}
{actionData?.success ? (
<Alert variant="success">{actionData.success}</Alert>
) : null}
<Form method="post">
<BootstrapForm.Group className="mb-3">
<BootstrapForm.Label>Current password</BootstrapForm.Label>
<BootstrapForm.Control type="password" name="currentPassword" required />
</BootstrapForm.Group>
<BootstrapForm.Group className="mb-3">
<BootstrapForm.Label>New password</BootstrapForm.Label>
<BootstrapForm.Control type="password" name="newPassword" required />
</BootstrapForm.Group>
<BootstrapForm.Group className="mb-3">
<BootstrapForm.Label>Confirm new password</BootstrapForm.Label>
<BootstrapForm.Control type="password" name="confirmPassword" required />
</BootstrapForm.Group>
<Button type="submit">Save password</Button>
</Form>
</Card.Body>
</Card>
</Col>
</Row>
</Container>
);
}
+4 -4
View File
@@ -34,15 +34,15 @@ export async function createPdf(filename: string, reportId: string, dateStart: s
doc.setFontSize(10); doc.setFontSize(10);
doc.text("Selected date range: " + dateStart.slice(0, -8).replace("T", "") + " - " + dateEnd.slice(0,-8).replace("T", ""), 15, 35); doc.text("Selected date range: " + dateStart.slice(0, -8).replace("T", "") + " - " + dateEnd.slice(0,-8).replace("T", ""), 15, 35);
doc.autoTable({ const table = doc.autoTable({
startY: 40, startY: 40,
head: [['Drink', 'Price', 'Amount', 'Total']], head: [['Drink', 'Price', 'Amount', 'Total']],
body: body, body: body,
foot: [["", "", "Total", "€" + (Math.round(total*100)/100)]] foot: [["", "", "Total", "€" + (Math.round(total*100)/100)]]
}); });
let finalY = doc.autoTable.previous.finalY; const finalY = table?.lastAutoTable?.finalY ?? 40;
doc.text("Generated on " + new Date(Date.now()).toString(), 15, finalY + 10); doc.text("Generated on " + new Date(Date.now()).toString(), 15, finalY + 10);
const filepath = "/reports/" + filename + ".pdf"; const filepath = "/reports/" + filename + ".pdf";
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
npx prisma migrate resolve --applied 0_init
npx prisma migrate deploy
+245
View File
@@ -0,0 +1,245 @@
-- CreateEnum
CREATE TYPE "ContainerType" AS ENUM ('BeerBottle', 'WineBottle', 'PlasticBottle', 'Can', 'Carton', 'Keg');
-- CreateEnum
CREATE TYPE "DrinkType" AS ENUM ('Drink', 'Beer', 'Wine', 'Soda', 'Cocktail');
-- CreateEnum
CREATE TYPE "UserType" AS ENUM ('User', 'Scanner', 'Admin');
-- CreateTable
CREATE TABLE "Beer" (
"id" INTEGER NOT NULL,
"style_id" INTEGER NOT NULL,
"ibu" DOUBLE PRECISION,
"glass" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "Beer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BeerStyle" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"color" TEXT NOT NULL,
CONSTRAINT "BeerStyle_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Cocktail" (
"id" INTEGER NOT NULL,
"mix" BOOLEAN NOT NULL,
CONSTRAINT "Cocktail_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Container" (
"id" SERIAL NOT NULL,
"barcode" TEXT,
"drink_id" INTEGER NOT NULL,
"section_id" INTEGER NOT NULL,
"type" "ContainerType" NOT NULL,
"volume" INTEGER NOT NULL,
"portions" INTEGER,
"price" DOUBLE PRECISION NOT NULL DEFAULT 0.0,
"inventory" INTEGER NOT NULL DEFAULT 0,
"lastAdded" TIMESTAMP(3),
CONSTRAINT "Container_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Country" (
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "Country_pkey" PRIMARY KEY ("code")
);
-- CreateTable
CREATE TABLE "Drink" (
"id" SERIAL NOT NULL,
"slug" TEXT NOT NULL,
"manufacturer_id" INTEGER NOT NULL,
"type" "DrinkType" NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL,
"abv" DOUBLE PRECISION NOT NULL,
"image" TEXT,
"link" TEXT,
"gluten" BOOLEAN NOT NULL,
"lactose" BOOLEAN NOT NULL,
"organic" BOOLEAN NOT NULL,
"sugar" BOOLEAN NOT NULL DEFAULT true,
"addedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Drink_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "History" (
"id" SERIAL NOT NULL,
"container_id" INTEGER NOT NULL,
"checkoutAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"inventoryAfter" INTEGER NOT NULL,
CONSTRAINT "History_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Manufacturer" (
"id" SERIAL NOT NULL,
"country_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"image" TEXT,
CONSTRAINT "Manufacturer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Report" (
"id" SERIAL NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"dateStart" TIMESTAMP(3) NOT NULL,
"dateEnd" TIMESTAMP(3) NOT NULL,
"file" TEXT,
"name" TEXT NOT NULL,
CONSTRAINT "Report_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Section" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "Section_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"user_id" INTEGER,
"data" TEXT NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Soda" (
"id" INTEGER NOT NULL,
"carbonated" BOOLEAN NOT NULL,
CONSTRAINT "Soda_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Suggestion" (
"id" SERIAL NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"name" TEXT,
"content" TEXT NOT NULL,
"resolved" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "Suggestion_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"username" TEXT NOT NULL,
"password" TEXT NOT NULL,
"type" "UserType" NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Wine" (
"id" INTEGER NOT NULL,
"style_id" INTEGER NOT NULL,
"heavy_score" INTEGER,
"tannine_score" INTEGER,
"dry_score" INTEGER,
"fresh_score" INTEGER,
"notes" TEXT,
CONSTRAINT "Wine_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WineStyle" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"color" TEXT NOT NULL,
CONSTRAINT "WineStyle_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "BeerStyle_name_key" ON "BeerStyle"("name" ASC);
-- CreateIndex
CREATE UNIQUE INDEX "Container_barcode_key" ON "Container"("barcode" ASC);
-- CreateIndex
CREATE UNIQUE INDEX "Drink_name_key" ON "Drink"("name" ASC);
-- CreateIndex
CREATE UNIQUE INDEX "Drink_slug_key" ON "Drink"("slug" ASC);
-- CreateIndex
CREATE UNIQUE INDEX "Manufacturer_name_key" ON "Manufacturer"("name" ASC);
-- CreateIndex
CREATE UNIQUE INDEX "Report_name_key" ON "Report"("name" ASC);
-- CreateIndex
CREATE UNIQUE INDEX "Section_name_key" ON "Section"("name" ASC);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username" ASC);
-- CreateIndex
CREATE UNIQUE INDEX "WineStyle_name_key" ON "WineStyle"("name" ASC);
-- AddForeignKey
ALTER TABLE "Beer" ADD CONSTRAINT "Beer_id_fkey" FOREIGN KEY ("id") REFERENCES "Drink"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Beer" ADD CONSTRAINT "Beer_style_id_fkey" FOREIGN KEY ("style_id") REFERENCES "BeerStyle"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Cocktail" ADD CONSTRAINT "Cocktail_id_fkey" FOREIGN KEY ("id") REFERENCES "Drink"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Container" ADD CONSTRAINT "Container_drink_id_fkey" FOREIGN KEY ("drink_id") REFERENCES "Drink"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Container" ADD CONSTRAINT "Container_section_id_fkey" FOREIGN KEY ("section_id") REFERENCES "Section"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Drink" ADD CONSTRAINT "Drink_manufacturer_id_fkey" FOREIGN KEY ("manufacturer_id") REFERENCES "Manufacturer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "History" ADD CONSTRAINT "History_container_id_fkey" FOREIGN KEY ("container_id") REFERENCES "Container"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Manufacturer" ADD CONSTRAINT "Manufacturer_country_id_fkey" FOREIGN KEY ("country_id") REFERENCES "Country"("code") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Soda" ADD CONSTRAINT "Soda_id_fkey" FOREIGN KEY ("id") REFERENCES "Drink"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Wine" ADD CONSTRAINT "Wine_id_fkey" FOREIGN KEY ("id") REFERENCES "Drink"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Wine" ADD CONSTRAINT "Wine_style_id_fkey" FOREIGN KEY ("style_id") REFERENCES "WineStyle"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,28 @@
/*
Warnings:
- Added the required column `brand_id` to the `Drink` table without a default value. This is not possible if the table is not empty.
*/
-- CreateTable
CREATE TABLE "Brand" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"image" TEXT,
CONSTRAINT "Brand_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Brand_name_key" ON "Brand"("name");
-- Add default brand
INSERT INTO "Brand" ("name") VALUES ('Default');
-- AlterTable
ALTER TABLE "Drink" ADD COLUMN "brand_id" INTEGER;
UPDATE "Drink" SET "brand_id" = (SELECT "id" FROM "Brand" WHERE "name" = 'Default');
ALTER TABLE "Drink" ALTER COLUMN "brand_id" SET NOT NULL;
-- AddForeignKey
ALTER TABLE "Drink" ADD CONSTRAINT "Drink_brand_id_fkey" FOREIGN KEY ("brand_id") REFERENCES "Brand"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+10 -1
View File
@@ -40,6 +40,8 @@ model Drink {
slug String @unique() slug String @unique()
manufacturer_id Int manufacturer_id Int
manufacturer Manufacturer @relation(fields: [manufacturer_id], references: [id]) manufacturer Manufacturer @relation(fields: [manufacturer_id], references: [id])
brand_id Int
brand Brand @relation(fields: [brand_id], references: [id])
type DrinkType type DrinkType
name String @unique() name String @unique()
description String description String
@@ -137,6 +139,13 @@ model Manufacturer {
drinks Drink[] drinks Drink[]
} }
model Brand {
id Int @id() @default(autoincrement())
name String @unique()
image String?
drinks Drink[]
}
model Country { model Country {
code String @id() code String @id()
name String name String
@@ -181,5 +190,5 @@ model Report {
name String @unique() name String @unique()
dateStart DateTime dateStart DateTime
dateEnd DateTime dateEnd DateTime
file String file String?
} }
+7 -2
View File
@@ -1,13 +1,18 @@
import { ContainerType, PrismaClient, UserType } from "@prisma/client"; import { ContainerType, PrismaClient, UserType } from "@prisma/client";
import { enhance } from "@zenstackhq/runtime"; import { enhance } from "@zenstackhq/runtime";
import { BeerStyle, Country, Manufacturer, Section, Soda, User, Wine, WineStyle } from "@zenstackhq/runtime/models"; import { BeerStyle, Country, Manufacturer, Section, Soda, User, Wine, WineStyle } from "@zenstackhq/runtime/models";
import { hashPassword } from "../app/auth/validate";
const pr = new PrismaClient(); const pr = new PrismaClient();
async function seed() { async function seed() {
await pr.user.upsert({ create: {username: "kenneth", password: "asdf1239", type: UserType.Admin}, update: {password: "asdf1239"}, where: {username: "kenneth"} }); const initialPassword = hashPassword("asdf1239");
await pr.user.upsert({ create: {username: "scanner", password: "asdf1239", type: UserType.Scanner}, update: {password: "asdf1239"}, where: {username: "scanner"} });
await pr.user.upsert({ create: {username: "kenneth", password: initialPassword, type: UserType.Admin}, update: {password: initialPassword}, where: {username: "kenneth"} });
await pr.user.upsert({ create: {username: "scanner", password: initialPassword, type: UserType.Scanner}, update: {password: initialPassword}, where: {username: "scanner"} });
const user = await pr.user.findUnique({where: {username: "kenneth"}}) || undefined; const user = await pr.user.findUnique({where: {username: "kenneth"}}) || undefined;
// Delete all sessions // Delete all sessions
+17 -2
View File
@@ -40,6 +40,9 @@ model Drink {
manufacturer_id Int manufacturer_id Int
manufacturer Manufacturer @relation(fields: [manufacturer_id], references: [id]) manufacturer Manufacturer @relation(fields: [manufacturer_id], references: [id])
brand_id Int
brand Brand @relation(fields: [brand_id], references: [id])
type DrinkType type DrinkType
name String @unique name String @unique
@@ -177,6 +180,18 @@ model Manufacturer {
@@allow('all', auth().type == Admin) @@allow('all', auth().type == Admin)
} }
model Brand {
id Int @id @default(autoincrement())
name String @unique
image String?
drinks Drink[]
@@allow('read', true)
@@allow('all', auth().type == Admin)
}
model Country { model Country {
code String @id code String @id
name String name String
@@ -205,7 +220,7 @@ model History {
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
username String @unique username String @unique
password String @password @omit password String
type UserType type UserType
sessions Session[] sessions Session[]
@@ -243,7 +258,7 @@ model Report {
dateStart DateTime dateStart DateTime
dateEnd DateTime dateEnd DateTime
file String file String?
@@allow('all', auth().type == Admin) @@allow('all', auth().type == Admin)
} }