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
13 changed files with 359 additions and 123 deletions
+1
View File
@@ -4,6 +4,7 @@ on:
push:
branches:
- main
- feature/*
workflow_dispatch:
jobs:
-5
View File
@@ -1,10 +1,5 @@
Beer-inventory project for home use.
The project has three aspects.
- The website, which people can visit to see the current inventory of drinks. They can see in which section a drink is stored.
- The Admin website, which is part of the main website but only to logged in admin users. Which allows updating drinks, inventory, and adding or editing data.
- The scanner, which is a raspberry pi with touchscreen display and a USB barcode scanner. This is next to the fridge, and people use it to scan a drink to remove it from the inventory.
Used technology:
- remix.run (now known as react router)
- prisma
+2 -1
View File
@@ -13,9 +13,10 @@ COPY . .
ENV NODE_ENV=production
ENV DATABASE_URL="REQUIRED"
RUN chmod +x ./migrate.sh
RUN npx zenstack generate
EXPOSE 3000
VOLUME /usr/src/app/public
CMD ["/bin/sh", "-c", "npx prisma db push && npm start"]
CMD ["/bin/sh", "-c", "./migrate.sh && npm start"]
+38 -97
View File
@@ -2,7 +2,7 @@
import { useSubmit } from '@remix-run/react';
import { BeerStyle, Manufacturer, WineStyle } from '@zenstackhq/runtime/models';
import { useRef, useState } from 'react';
import { Button, Form, Col, Row } from 'react-bootstrap';
import { Form, Col, Row } from 'react-bootstrap';
interface Arguments {
beerStyles: BeerStyle[];
@@ -20,28 +20,11 @@ function DrinkFilter(arg : Arguments) {
const showSodaFilter = arg.searchParams.has("drink", "Soda");
const showCocktailFilter = arg.searchParams.has("drink", "Cocktail");
const form = useRef<HTMLFormElement | null>(null);
const drinkTypes = ["Beer", "Wine", "Soda", "Cocktail"];
const [beerStyleExpanded, setBeerStyleExpanded] = useState(false);
const [wineStyleExpanded, setWineStyleExpanded] = useState(false);
const form = useRef(null);
const toggleDrinkType = (drinkType: string) => {
const params = new URLSearchParams(arg.searchParams.toString());
const values = params.getAll("drink");
if (values.includes(drinkType)) {
params.delete("drink");
values.filter((value) => value !== drinkType).forEach((value) => params.append("drink", value));
} else {
params.append("drink", drinkType);
}
if (params.get("abv") === "all") {
params.delete("abv");
}
submit(params, { method: "get", replace: true });
};
const submitAfterRender = function(){
setTimeout(() => submit(form.current), 10);
}
const handleFormChange = (event: React.ChangeEvent<HTMLFormElement>) => {
const formData = new FormData(event.currentTarget);
@@ -54,8 +37,6 @@ function DrinkFilter(arg : Arguments) {
}
const abvSelection = arg.searchParams.get("abv") || "all";
const selectedBeerStyleCount = arg.searchParams.getAll("beerstyle").length;
const selectedWineStyleCount = arg.searchParams.getAll("winestyle").length;
const abvOptions = [
{ value: "all", label: "All" },
{ value: "alcohol-free", label: "Alcohol free" },
@@ -106,24 +87,17 @@ function DrinkFilter(arg : Arguments) {
</Form.Group>
<Form.Group controlId="drink-type" className='bg-body-secondary rounded p-3 mt-3'>
<Form.Label className='fw-bold'>Drink Type</Form.Label>
<div className='row row-cols-2 g-2'>
{drinkTypes.map((key) => (
<div className='col' key={key}>
<Button
type="button"
variant={arg.searchParams.has("drink", key) ? "primary" : "outline-primary"}
className='w-100 text-start'
aria-pressed={arg.searchParams.has("drink", key)}
onClick={() => toggleDrinkType(key)}
>
{key}
</Button>
</div>
))}
</div>
{arg.searchParams.getAll("drink").map((drinkValue) => (
<input key={drinkValue} type="hidden" name="drink" value={drinkValue} />
{ ["Beer", "Wine", "Soda", "Cocktail"].map(key => (
<Form.Check
id={"drink-" + key}
type="checkbox"
label={key}
name="drink"
value={key}
defaultChecked={arg.searchParams.has("drink", key) ? true : false}
/>
))}
</Form.Group>
<Form.Group controlId="drink-abv" className='bg-body-secondary rounded p-3 mt-3'>
<Form.Label className='fw-bold'>Alcohol Percentage</Form.Label>
@@ -145,67 +119,34 @@ function DrinkFilter(arg : Arguments) {
{ showBeerFilter ? (
<>
<Form.Group controlId="beer-style" className='bg-body-secondary rounded p-3 mt-3'>
<div className='d-flex justify-content-between align-items-center'>
<Form.Label className='fw-bold mb-0'>Beer style {selectedBeerStyleCount > 0 ? ( <span className="badge text-bg-dark">{selectedBeerStyleCount} selected</span>) : ''}</Form.Label>
<Button
type='button'
variant='outline-secondary'
size='sm'
onClick={() => setBeerStyleExpanded((current) => !current)}
>
{beerStyleExpanded ? 'Collapse' : 'Expand'}
</Button>
</div>
{beerStyleExpanded ? (
<div className='mt-3'>
{ arg.beerStyles.map(style => (
<Form.Check
key={style.id}
id={"beerstyle-" + style.name}
type="checkbox"
label={style.name}
name="beerstyle"
value={style.id}
defaultChecked={arg.searchParams.has("beerstyle", style.id.toString()) ? true : false}
/>
))}
</div>
) : (
<div className='text-muted mt-3'>Tap expand to show beer styles</div>
)}
<Form.Label className='fw-bold mt-3'>Beer style</Form.Label>
{ arg.beerStyles.map(style => (
<Form.Check
id={"beerstyle-" + style.name}
type="checkbox"
label={style.name}
name="beerstyle"
value={style.id}
defaultChecked={arg.searchParams.has("beerstyle", style.id.toString()) ? true : false}
/>
))}
</Form.Group>
</>
) : ""}
{ showWineFilter ? (
<Form.Group controlId="wine-style" className='bg-body-secondary rounded p-3 mt-3'>
<div className='d-flex justify-content-between align-items-center'>
<Form.Label className='fw-bold mb-0'>Wine style {selectedWineStyleCount > 0 ? ( <span className="badge text-bg-dark">{selectedWineStyleCount} selected</span>) : ''}</Form.Label>
<Button
type='button'
variant='outline-secondary'
size='sm'
onClick={() => setWineStyleExpanded((current) => !current)}
>
{wineStyleExpanded ? 'Collapse' : 'Expand'}
</Button>
</div>
{wineStyleExpanded ? (
<div className='mt-3'>
{ arg.wineStyles.map(style => (
<Form.Check
id={"winestyle-" + style.name}
type="checkbox"
label={style.name}
name="winestyle"
value={style.id}
defaultChecked={arg.searchParams.has("winestyle", style.id.toString()) ? true : false}
/>
))}
</div>
) : (
<div className='text-muted mt-3'>Tap expand to show wine styles</div>
)}
</Form.Group>
<Form.Label className='fw-bold'>Wine style</Form.Label>
{ arg.wineStyles.map(style => (
<Form.Check
id={"winestyle-" + style.name}
type="checkbox"
label={style.name}
name="winestyle"
value={style.id}
defaultChecked={arg.searchParams.has("winestyle", style.id.toString()) ? true : false}
/>
))}
</Form.Group>
) : ""}
{ showSodaFilter ? (
<Form.Group controlId="soda-carbonated" className='bg-body-secondary rounded p-3 mt-3'>
+3 -3
View File
@@ -83,7 +83,7 @@ export function ErrorBoundary() {
const hourly = window.setInterval(() => {
tryRefresh();
}, 15 * 60 * 1000); // Every 15 minutes
}, 60 * 60 * 1000);
return () => {
window.removeEventListener("online", handleOnline);
@@ -120,7 +120,7 @@ export function ErrorBoundary() {
>
Try refresh now
</button>
<div className="mt-2">Status: {isOnline ? "Online" : "Offline"}. Will retry automatically every 15 minutes.</div>
<div className="mt-2">Status: {isOnline ? "Online" : "Offline"}. Will retry automatically every hour.</div>
</div>
</div>
</Col>
@@ -159,7 +159,7 @@ export function ErrorBoundary() {
>
Try refresh now
</button>
<div className="mt-2">Status: {isOnline ? "Online" : "Offline"}. Will retry automatically every 15 minutes.</div>
<div className="mt-2">Status: {isOnline ? "Online" : "Offline"}. Will retry automatically every hour.</div>
</div>
</div>
</Col>
+1 -1
View File
@@ -41,7 +41,7 @@ export default function InventoryLayout() {
setCheckouts(newCheckouts);
}
}, 30000);
}, 5000);
return () => clearTimeout(timer);
});
+11 -16
View File
@@ -1,8 +1,8 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form as RemixForm, useActionData, useLoaderData } from "@remix-run/react";
import { useActionData, useLoaderData } from "@remix-run/react";
import { useEffect, useRef, useState } from "react";
import { Col, Container, Form as BootstrapForm, Row, Table } from "react-bootstrap";
import { Col, Container, Form, Row, Table } from "react-bootstrap";
import timeAgo from "~/utils/datetime";
import { enhance } from "~/utils/db.server";
@@ -16,7 +16,7 @@ export async function loader({
return redirect("/");
}
const history = await dbe.history.findMany({select: {id: true, checkoutAt: true, inventoryAfter: true, container: {select: {drink: true}}}, take: 5, orderBy: {checkoutAt: "desc"}});
const history = await dbe.history.findMany({select: {id: true, checkoutAt: true, inventoryAfter: true, container: {select: {drink: true}}}, take: 10, orderBy: {checkoutAt: "desc"}});
return json({history});
}
@@ -116,10 +116,6 @@ export default function ScanRoute() {
return;
}
if (inputRef.current) {
inputRef.current.value = "";
}
setLastScanned(aData.lastScanned);
setShowLastScanned(true);
@@ -141,7 +137,7 @@ export default function ScanRoute() {
<tr>
<th>Drink</th>
<th>When</th>
<th># Left</th>
<th>Amount left</th>
</tr>
</thead>
<tbody>
@@ -162,15 +158,14 @@ export default function ScanRoute() {
<Col>
{/* Hidden fullscreen toggle button - triggered by clicking the h3 headers */}
<button
type="button"
ref={fullscreenBtnRef}
onClick={toggleFullscreen}
style={{ display: 'none' }}
aria-hidden="true"
/>
<RemixForm method="post" action="/scan?index" noValidate>
<BootstrapForm.Group>
<BootstrapForm.Control
<Form method="post" action="/scan?index" noValidate>
<Form.Group>
<Form.Control
ref={inputRef}
type="text"
name="barcode"
@@ -180,11 +175,11 @@ export default function ScanRoute() {
inputMode="text"
style={{ height: '3rem', fontSize: '1.8rem', textAlign: 'center' }}
/>
<BootstrapForm.Control.Feedback type="invalid">
<Form.Control.Feedback type="invalid">
{aData?.error}
</BootstrapForm.Control.Feedback>
</BootstrapForm.Group>
</RemixForm>
</Form.Control.Feedback>
</Form.Group>
</Form>
</Col>
</Row>
<Row>
+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"
+9
View File
@@ -40,6 +40,8 @@ model Drink {
slug String @unique()
manufacturer_id Int
manufacturer Manufacturer @relation(fields: [manufacturer_id], references: [id])
brand_id Int
brand Brand @relation(fields: [brand_id], references: [id])
type DrinkType
name String @unique()
description String
@@ -137,6 +139,13 @@ model Manufacturer {
drinks Drink[]
}
model Brand {
id Int @id() @default(autoincrement())
name String @unique()
image String?
drinks Drink[]
}
model Country {
code String @id()
name String
+15
View File
@@ -40,6 +40,9 @@ model Drink {
manufacturer_id Int
manufacturer Manufacturer @relation(fields: [manufacturer_id], references: [id])
brand_id Int
brand Brand @relation(fields: [brand_id], references: [id])
type DrinkType
name String @unique
@@ -177,6 +180,18 @@ model Manufacturer {
@@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 {
code String @id
name String