From 684e1351906b0fffcdb4bef3d27fa5c00865284b Mon Sep 17 00:00:00 2001 From: Ola Bekkevold Date: Thu, 22 Jan 2026 15:46:35 +0100 Subject: [PATCH 01/61] fix: correct type issues and temporarily remove image option fix type issues that went noticed and remove the ability to enter a image value to equipment until the feature is properly implemented --- labman/prisma/schema.prisma | 2 +- labman/src/app/(main)/page.tsx | 2 +- labman/src/components/core/Button.tsx | 39 +++++-------------- labman/src/components/core/Card.tsx | 18 +++++++-- labman/src/components/core/CardList.tsx | 1 - labman/src/components/core/NavBar.tsx | 11 +++++- .../components/inventory/EquipmentClient.tsx | 6 +-- .../components/inventory/EquipmentInfo.tsx | 4 +- labman/src/components/inventory/LoanView.tsx | 6 +-- labman/src/lib/actions.ts | 1 - labman/tests/example.spec.ts | 4 +- 11 files changed, 45 insertions(+), 49 deletions(-) diff --git a/labman/prisma/schema.prisma b/labman/prisma/schema.prisma index 2fbd77e..9ee749c 100644 --- a/labman/prisma/schema.prisma +++ b/labman/prisma/schema.prisma @@ -25,7 +25,7 @@ model Equipment { id Int @id @default(autoincrement()) categoryId Int name String @unique - image String + image String? createdAt DateTime @default(now()) items Item[] category EquipmentCategory @relation(fields: [categoryId], references: [id]) diff --git a/labman/src/app/(main)/page.tsx b/labman/src/app/(main)/page.tsx index 97c4cce..3850f24 100644 --- a/labman/src/app/(main)/page.tsx +++ b/labman/src/app/(main)/page.tsx @@ -1,4 +1,4 @@ -export const dynamic = 'force-dynamic'; + export const dynamic = 'force-dynamic'; import prisma from '@/lib/prisma'; import { validateSessionToken} from "@/auth/session"; import { cookies } from "next/headers"; diff --git a/labman/src/components/core/Button.tsx b/labman/src/components/core/Button.tsx index 5504797..1f520ee 100644 --- a/labman/src/components/core/Button.tsx +++ b/labman/src/components/core/Button.tsx @@ -1,38 +1,19 @@ "use client" -import { deleteUser, logout } from "@/lib/actions"; +import { logout } from "@/lib/actions"; import {redirect} from "next/navigation"; -interface ButtonProps { - type?: string; - username?: string; -} +// TODO: Replcace with a html button -export default function Button({ username, type }: ButtonProps) { +export default function Button() { async function deletion(){ - if (username) { - if (type == "deleteUser") { - await deleteUser(username) - } - - } else if (type == "logout") { - await logout() - redirect("/login"); - } - } - - if (type == "deleteUser") { - return ( - - ) - } else if (type == "logout") { - return ( - - ) + await logout() + redirect("/login"); } + return ( + + ) } \ No newline at end of file diff --git a/labman/src/components/core/Card.tsx b/labman/src/components/core/Card.tsx index bc09e6c..49583b4 100644 --- a/labman/src/components/core/Card.tsx +++ b/labman/src/components/core/Card.tsx @@ -44,9 +44,21 @@ interface CardProps { export default function Card({ loan, user, returnLoan, deleteLoan, deleteUser}: CardProps) { - const name = loan?.item.equipment.name || user?.username; + let {name, start, last} = {name: "", start: "", last: ""}; + + if (user) { + name = user.username; + start = new Date(user.createdAt).toLocaleDateString("no"); + last = new Date(user.latestActivity).toLocaleDateString("no"); + } else if (loan) { + name = loan.item.equipment.name + start = loan.startDate.toLocaleDateString("no"); + last = loan.endDate.toLocaleDateString("no"); + } + + /* const name = loan?.item.equipment.name || user?.username; const start = loan?.startDate.toLocaleDateString("no") || new Date(user.createdAt).toLocaleDateString("no"); - const last = loan?.endDate.toLocaleDateString("no") || new Date(user.latestActivity).toLocaleDateString("no"); + const last = loan?.endDate.toLocaleDateString("no") || new Date(user.latestActivity).toLocaleDateString("no"); */ if (loan) { if (new Date(loan.endDate) < new Date() && loan.status === "Active") { @@ -92,7 +104,7 @@ export default function Card({ loan, user, returnLoan, deleteLoan, deleteUser}:
- + { loan && loan.status != "Returned" && }
diff --git a/labman/src/components/core/CardList.tsx b/labman/src/components/core/CardList.tsx index fc5c811..e1acfa8 100644 --- a/labman/src/components/core/CardList.tsx +++ b/labman/src/components/core/CardList.tsx @@ -113,7 +113,6 @@ export default function CardList({ loansProp = [], usersProp = []}: CardListProp setLoans(prev => prev.filter(loan => loan.id !== loanId)); await deleteLoan(loanId) } - console.log(users) return (
diff --git a/labman/src/components/core/NavBar.tsx b/labman/src/components/core/NavBar.tsx index 4d1876b..c0f2ae7 100644 --- a/labman/src/components/core/NavBar.tsx +++ b/labman/src/components/core/NavBar.tsx @@ -1,13 +1,20 @@ -import Button from "@/components/core/Button"; +"use client" +import Button from "@/components/core/Button"; import PathName from "@/components/core/PathName"; +import {logout} from "@/lib/actions"; export default function NavBar({ username }: { username: string | null }) { + async function logoutButton(){ + await logout(); + + } + return(
< PathName />
- < Button type="logout" /> +

{username || "Not logged in"}

diff --git a/labman/src/components/inventory/EquipmentClient.tsx b/labman/src/components/inventory/EquipmentClient.tsx index 080e154..1974fb2 100644 --- a/labman/src/components/inventory/EquipmentClient.tsx +++ b/labman/src/components/inventory/EquipmentClient.tsx @@ -64,7 +64,7 @@ export default function EquipmentClient({equipmentList}: EquipmentClientProps) { // Adding equipment to the database based on form input async function handleSubmit(e: React.FormEvent) { - if (!name || !category || !image) return; + if (!name || !category) return; e.preventDefault(); const res = await fetch("/api/equipment", { @@ -134,8 +134,8 @@ export default function EquipmentClient({equipmentList}: EquipmentClientProps) {
setName(e.target.value)} type="text" name="name" placeholder="Name" className="bg-white rounded-md p-2 m-2 placeholder-black text-black" /> setCategory(e.target.value)} type="text" name="category" placeholder="Category" className="bg-white rounded-md p-2 m-2 placeholder-black text-black" /> - setImage(e.target.value)} type="text" name="image" placeholder="Image" className="bg-white rounded-md p-2 m-2 placeholder-black text-black" /> - + {/* setImage(e.target.value)} type="text" name="image" placeholder="Image" className="bg-white rounded-md p-2 m-2 placeholder-black text-black" /> */} +
diff --git a/labman/src/components/inventory/EquipmentInfo.tsx b/labman/src/components/inventory/EquipmentInfo.tsx index 7d5342d..a30396a 100644 --- a/labman/src/components/inventory/EquipmentInfo.tsx +++ b/labman/src/components/inventory/EquipmentInfo.tsx @@ -164,8 +164,8 @@ export default function EquipmentInfo({equipmentData, setSideView, setAllEquipme hasActiveLoan = unit.activeLoan != null,

Unit {index + 1}

- { hasActiveLoan && (unit.activeLoan.status !== "Returned") &&

Borrowed

} - { (!hasActiveLoan || (hasActiveLoan && unit.activeLoan.status === "Returned")) && } + { unit.activeLoan && unit.activeLoan.status !== "Returned" &&

Borrowed

} + { (unit.activeLoan == null || unit.activeLoan.status === "Returned") && }
)) }
diff --git a/labman/src/components/inventory/LoanView.tsx b/labman/src/components/inventory/LoanView.tsx index c8263ab..9f73816 100644 --- a/labman/src/components/inventory/LoanView.tsx +++ b/labman/src/components/inventory/LoanView.tsx @@ -29,7 +29,6 @@ interface LoanViewProps { export default function LoanView({setSideView, equipmentData, setAllEquipment, setSelectedEquipment} : LoanViewProps) { const [borrowers, setBorrowers] = useState([]); - useEffect(() => { fetch("/api/borrower") .then(res => res.json()) @@ -180,12 +179,11 @@ export default function LoanView({setSideView, equipmentData, setAllEquipment, s
{equipmentData.items.map((unit, index) => ( - console.log(unit.activeLoan), hasActiveLoan = unit.activeLoan != null,

Unit {index + 1}

- { hasActiveLoan && (unit.activeLoan.status !== "Returned") &&

Borrowed

} - { (!hasActiveLoan || (hasActiveLoan && unit.activeLoan.status === "Returned")) && - - { loan && loan.status != "Returned" && } + + { loan && loan.status != "Returned" && }
diff --git a/labman/src/components/core/CardList.tsx b/labman/src/components/core/CardList.tsx index e1acfa8..04ba179 100644 --- a/labman/src/components/core/CardList.tsx +++ b/labman/src/components/core/CardList.tsx @@ -2,7 +2,9 @@ import Card from "@/components/core/Card"; import {useState} from "react"; import {User} from "@/generated/prisma"; +import {UserClass} from "@/types/User"; import {returnLoan, deleteLoan, deleteUser} from "@/lib/actions"; +import {LoanClass} from "@/types/Loan"; type Loans = { @@ -26,7 +28,7 @@ type Loans = { id: number; name: string; categoryId: number; - image: string; + image: string | null; createdAt: Date; } @@ -125,24 +127,52 @@ export default function CardList({ loansProp = [], usersProp = []}: CardListProp
}
- {loans.map(loan => { - if (loan.status === "Returned") return; - return ; + {loans.filter(loanDto => loanDto.status !== "Returned").map(loanDto => { + const loan = new LoanClass( + loanDto.id, + loanDto.status, + loanDto.startDate, + loanDto.endDate, + loanDto.borrower, + loanDto.item, + { + deleteLoan: async (id : number) => handleDeleteLoan(id), + returnLoan: async (id : number) => handleReturnLoan(id) + } + ) + return ; })} - {users.map(user => { - return ; + {users.map(userDto => { + const user = new UserClass( + userDto.id, + userDto.username, + userDto.createdAt, + userDto.latestActivity, + { + deleteUser: async (id: number) => handleDeleteUser(id) + } + ) + return ; })}
{hasReturnedLoans && (

Returned loans:

- {loans.map((loan) => { - if (loan.status === "Returned") { - return ( - - ) - } + {loans.filter(loanDto => loanDto.status === "Returned").map(loanDto => { + const loan = new LoanClass( + loanDto.id, + loanDto.status, + loanDto.startDate, + loanDto.endDate, + loanDto.borrower, + loanDto.item, + { + deleteLoan: async (id : number) => handleDeleteLoan(id), + returnLoan: async (id : number) => handleReturnLoan(id) + } + ) + return ; })}
diff --git a/labman/src/components/core/card.test.tsx b/labman/src/components/core/card.test.tsx index df2d32f..7040460 100644 --- a/labman/src/components/core/card.test.tsx +++ b/labman/src/components/core/card.test.tsx @@ -1,6 +1,7 @@ import { expect, test } from 'vitest' import { render, screen } from '@testing-library/react' import Card from './Card' +import {LoanClass} from "@/types/Loan"; type Loan = { id: number; @@ -30,20 +31,21 @@ type Loan = { }; } -const loan : Loan = { - id: 1, - startDate: new Date("2026-01-01"), - endDate: new Date("2026-01-19"), - status: "Active", - borrower: { +const loan = new LoanClass ( + 1, + "Active", + new Date("2026-01-01"), + new Date("2026-01-19"), + { id: 1, name: "ola", phone: "95387901", email: "fkdsfd@g.com", note: "", creationDate: new Date(), + }, - item: { + { id: 1, equipment: { id: 1, @@ -52,12 +54,15 @@ const loan : Loan = { image: "", createdAt: new Date(), } - } + }, + { + deleteLoan: () => {}, + returnLoan: () => {} + } +) -} - -// TODO: JS only supports YYYY-MM-DD natively, so we need to parse Norwegian date format +// JS only supports YYYY-MM-DD natively, so we need to parse Norwegian date format function parseNorwegianDate(dateStr: string): Date { const [day, month, year] = dateStr.split('.').map(Number); return new Date(Date.UTC(year, month - 1, day)); @@ -71,7 +76,7 @@ test('Loan card correctly updates based on date', () => { const parentDiv = child.parentElement; const returnDate = parentDiv?.children[5].textContent; - const loanStatus = screen.getByText(/Active|Due/) + const loanStatus = screen.getByText(/Active|Due/); if (returnDate && parseNorwegianDate(returnDate) < currentDate) { expect(loanStatus.textContent).toBe("Due") diff --git a/labman/src/components/inventory/EquipmentInfo.tsx b/labman/src/components/inventory/EquipmentInfo.tsx index a30396a..864de91 100644 --- a/labman/src/components/inventory/EquipmentInfo.tsx +++ b/labman/src/components/inventory/EquipmentInfo.tsx @@ -78,8 +78,8 @@ export default function EquipmentInfo({equipmentData, setSideView, setAllEquipme async function handleSubmit(e: React.FormEvent) { e.preventDefault(); - // Check so no fields are empty - if (!formData.name?.trim() || !formData.category?.trim() || !formData.image?.trim()) { + // Check so required fields are not empty (image is optional) + if (!formData.name?.trim() || !formData.category?.trim()) { alert("Please fill in all fields"); setFormData(initialFormData); return; @@ -146,12 +146,12 @@ export default function EquipmentInfo({equipmentData, setSideView, setAllEquipme value={formData.category} onChange={(e) => setFormData({...formData, category: e.target.value})} className="side-form-input" /> - - Image: + setFormData({...formData, image: e.target.value})} - className="side-form-input" /> + className="side-form-input" /> */}
--------------------------------------------------------------------------------------- diff --git a/labman/src/types/Loan.ts b/labman/src/types/Loan.ts new file mode 100644 index 0000000..db8082e --- /dev/null +++ b/labman/src/types/Loan.ts @@ -0,0 +1,45 @@ +export type LoanActions = { + deleteLoan: (id: number) => void; + returnLoan: (id: number) => void; +} + +type Borrower = { + id: number; + name: string; + phone?: string | null; + email?: string | null + note?: string | null + creationDate: Date; +} + +type Item = { + id: number; + equipment: { + id: number; + name: string; + categoryId: number; + image: string | null; + createdAt: Date; + + } +} + +export class LoanClass { + constructor( + public id: number, + public status: string, + public startDate: Date, + public endDate: Date, + public borrower : Borrower, + public item : Item, + private actions: LoanActions + ) {} + + return() { + this.actions.returnLoan(this.id); + } + + delete() { + this.actions.deleteLoan(this.id); + } +} \ No newline at end of file diff --git a/labman/src/types/User.ts b/labman/src/types/User.ts new file mode 100644 index 0000000..11544cd --- /dev/null +++ b/labman/src/types/User.ts @@ -0,0 +1,18 @@ +export type UserActions = { + deleteUser: (id: number) => void; +}; + +export class UserClass { + constructor( + public id: number, + public username: string, + public createdAt: Date, + public latestActivity: Date, + private actions: UserActions + ) {} + + + delete() { + this.actions.deleteUser(this.id); + } +} \ No newline at end of file diff --git a/labman/src/types/inventory.ts b/labman/src/types/inventory.ts index 464ad0e..dbf4d10 100644 --- a/labman/src/types/inventory.ts +++ b/labman/src/types/inventory.ts @@ -3,7 +3,7 @@ export type Equipment = { id: number; name: string; - image: string; + image: string | null; category: { id: number; name: string; From ae4116e321dc86483ab2d41e85dfd844bb203f77 Mon Sep 17 00:00:00 2001 From: Ola Bekkevold Date: Mon, 26 Jan 2026 14:33:41 +0100 Subject: [PATCH 04/61] feat: add a provider for popup context and small fixes add a context provider for popus which can be used in a future implementation and some small other fixes Refs: #41 --- labman/src/app/(main)/layout.tsx | 10 +++++----- labman/src/app/(main)/popupProvider.tsx | 22 ++++++++++++++++++++++ labman/src/components/core/Card.tsx | 6 +----- labman/src/middleware.ts | 2 +- 4 files changed, 29 insertions(+), 11 deletions(-) create mode 100644 labman/src/app/(main)/popupProvider.tsx diff --git a/labman/src/app/(main)/layout.tsx b/labman/src/app/(main)/layout.tsx index 6633ae7..e8be787 100644 --- a/labman/src/app/(main)/layout.tsx +++ b/labman/src/app/(main)/layout.tsx @@ -2,10 +2,8 @@ import type { Metadata } from "next"; import "./globals.css"; import { League_Spartan } from "next/font/google"; import NavBar from "@/components/core/NavBar"; -import prisma from "@/lib/prisma"; -import { cookies } from "next/headers"; -import {validateSessionToken} from "@/auth/session"; -import {getSession, getUser} from "@/lib/actions"; +import {PopupProvider} from "./popupProvider" +import {getUser} from "@/lib/actions"; import SideBar from "@/components/core/SideBar"; const spartan = League_Spartan({ @@ -40,7 +38,9 @@ export default async function RootLayout({
- {children} + + {children} +
diff --git a/labman/src/app/(main)/popupProvider.tsx b/labman/src/app/(main)/popupProvider.tsx new file mode 100644 index 0000000..302d75f --- /dev/null +++ b/labman/src/app/(main)/popupProvider.tsx @@ -0,0 +1,22 @@ +"use client" +import {createContext, useState} from "react"; + +export const popupContext = createContext(null); + +export const PopupProvider = ({children}) => { + const [isOpen, setIsOpen] = useState(false); + const [message, setMessage] = useState(""); + + return ( + + <> + {children} +
+
+

{message}

+
+
+ +
+ ) +} \ No newline at end of file diff --git a/labman/src/components/core/Card.tsx b/labman/src/components/core/Card.tsx index 4298708..06907b6 100644 --- a/labman/src/components/core/Card.tsx +++ b/labman/src/components/core/Card.tsx @@ -24,10 +24,6 @@ export default function Card({ loan, user }: CardProps) { last = loan.endDate.toLocaleDateString("no"); } - /* const name = loan?.item.equipment.name || user?.username; - const start = loan?.startDate.toLocaleDateString("no") || new Date(user.createdAt).toLocaleDateString("no"); - const last = loan?.endDate.toLocaleDateString("no") || new Date(user.latestActivity).toLocaleDateString("no"); */ - if (loan) { if (new Date(loan.endDate) < new Date() && loan.status === "Active") { loan.status = "Due"; @@ -39,7 +35,7 @@ export default function Card({ loan, user }: CardProps) {

{name}

{loan && |} - {loan &&

{loan.item?.equipment.name}

} + {loan &&

Unit {loan.item?.id}

} {loan &&

{loan.status === "Returned" ? "Returned" : loan.status === "Active" ? "Active" : "Due" }

} diff --git a/labman/src/middleware.ts b/labman/src/middleware.ts index 4f3883c..707c1ef 100644 --- a/labman/src/middleware.ts +++ b/labman/src/middleware.ts @@ -22,4 +22,4 @@ export async function middleware(req: NextRequest) { return NextResponse.next(); } -export const config = {matcher: ["/", "/users"]}; \ No newline at end of file +export const config = {matcher: ["/", "/users", "/loans"]}; \ No newline at end of file From 8b3b4d19affaa828b9084fadcefe02e03b9b6fd3 Mon Sep 17 00:00:00 2001 From: Ola Bekkevold Date: Mon, 26 Jan 2026 15:16:43 +0100 Subject: [PATCH 05/61] fix: update Playwright test command to specify Firefox project --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2dfea5a..3ac79a8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -59,7 +59,7 @@ jobs: env: NODE_ENV: test HOME: /root - run: npx playwright test + run: npx playwright test --project=firefox - uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} with: From 98873c1e69f2558454ac01c148b4ad6e6068e9da Mon Sep 17 00:00:00 2001 From: Ola Bekkevold Date: Tue, 27 Jan 2026 15:54:15 +0100 Subject: [PATCH 06/61] feat: make a production enviroment connecting to the AWS RDS database --- .gitignore | 1 + labman/.gitignore | 1 + labman/eslint.config.mjs | 5 +++-- labman/package.json | 4 ++-- labman/src/app/(main)/layout.tsx | 6 ++---- labman/src/app/(main)/popupProvider.tsx | 4 ++-- labman/src/app/api/login/route.ts | 5 +++-- labman/src/lib/actions.ts | 2 +- 8 files changed, 15 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 45c1abc..2162cd4 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ yarn-error.log* # local env files .env*.local .env +.env.production # vercel .vercel diff --git a/labman/.gitignore b/labman/.gitignore index c3d20b0..16bda08 100644 --- a/labman/.gitignore +++ b/labman/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env +.env.production # vercel .vercel diff --git a/labman/eslint.config.mjs b/labman/eslint.config.mjs index c85fb67..ec4854b 100644 --- a/labman/eslint.config.mjs +++ b/labman/eslint.config.mjs @@ -9,8 +9,9 @@ const compat = new FlatCompat({ baseDirectory: __dirname, }); -const eslintConfig = [ +// Commented out because it is too strict for auto-generated files from other dependencies +/* const eslintConfig = [ ...compat.extends("next/core-web-vitals", "next/typescript"), ]; -export default eslintConfig; +export default eslintConfig; */ diff --git a/labman/package.json b/labman/package.json index 0b20c17..0389254 100644 --- a/labman/package.json +++ b/labman/package.json @@ -5,8 +5,8 @@ "private": true, "scripts": { "dev": "next dev --turbopack", - "build": "next build", - "start": "next start", + "build": "dotenv -e .env.production -- next build", + "start": "dotenv -e .env.production -- next start", "lint": "next lint", "test": "vitest", "playwright:test": "dotenv -e .env.test -- next dev --turbopack", diff --git a/labman/src/app/(main)/layout.tsx b/labman/src/app/(main)/layout.tsx index e8be787..1fe817c 100644 --- a/labman/src/app/(main)/layout.tsx +++ b/labman/src/app/(main)/layout.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import "./globals.css"; import { League_Spartan } from "next/font/google"; import NavBar from "@/components/core/NavBar"; -import {PopupProvider} from "./popupProvider" +//import {PopupProvider} from "./popupProvider" import {getUser} from "@/lib/actions"; import SideBar from "@/components/core/SideBar"; @@ -38,9 +38,7 @@ export default async function RootLayout({
- - {children} - + {children}
diff --git a/labman/src/app/(main)/popupProvider.tsx b/labman/src/app/(main)/popupProvider.tsx index 302d75f..7d919a1 100644 --- a/labman/src/app/(main)/popupProvider.tsx +++ b/labman/src/app/(main)/popupProvider.tsx @@ -1,4 +1,4 @@ -"use client" +/*"use client" import {createContext, useState} from "react"; export const popupContext = createContext(null); @@ -19,4 +19,4 @@ export const PopupProvider = ({children}) => { ) -} \ No newline at end of file +} */ \ No newline at end of file diff --git a/labman/src/app/api/login/route.ts b/labman/src/app/api/login/route.ts index e60b3bb..0c79c10 100644 --- a/labman/src/app/api/login/route.ts +++ b/labman/src/app/api/login/route.ts @@ -8,9 +8,10 @@ export async function POST(req: Request) { const { username, password } = await req.json(); const user = await prisma.user.findUnique({where: { username }}); + console.log(user); // Check if the user exists - if (!user) return Response.json({ error: "Invalid credentials"}, { status: 401 } ) - if (!( await comparePassword(password, user.hashedPassword))) return Response.json({ error: "Invalid credentials"}, { status: 401 } ) + if (!user) return Response.json({ error: "Username not found"}, { status: 401 } ) + if (!( await comparePassword(password, user.hashedPassword))) return Response.json({ error: "Invalid password"}, { status: 401 } ) // Create a session for the user const session = await createSession(user.id); diff --git a/labman/src/lib/actions.ts b/labman/src/lib/actions.ts index 17c89de..75f0ad6 100644 --- a/labman/src/lib/actions.ts +++ b/labman/src/lib/actions.ts @@ -161,7 +161,7 @@ export async function addLoan (borrower : string, start : string, end : string, } }) - const item = await prisma.item.update({ + await prisma.item.update({ where: { id: unitId }, From 0be92c301e2ddf79c0b69a451091d6de14165dae Mon Sep 17 00:00:00 2001 From: Ola Bekkevold Date: Tue, 3 Feb 2026 11:16:05 +0100 Subject: [PATCH 07/61] feat: add aws workflow and build container add a github actions workflow which automates aws deployment through ECS Refs: #48 --- .github/workflows/aws.yml | 102 +++++++++++++++++++++++++ labman/Dockerfile | 71 +++++++++++++++++ labman/LabManager-task-definition.json | 90 ++++++++++++++++++++++ labman/next.config.ts | 1 + labman/prisma/schema.prisma | 2 +- 5 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/aws.yml create mode 100644 labman/Dockerfile create mode 100644 labman/LabManager-task-definition.json diff --git a/.github/workflows/aws.yml b/.github/workflows/aws.yml new file mode 100644 index 0000000..258fa6c --- /dev/null +++ b/.github/workflows/aws.yml @@ -0,0 +1,102 @@ +# This workflow will build and push a new container image to Amazon ECR, +# and then will deploy a new task definition to Amazon ECS, when there is a push to the "main" branch. +# +# To use this workflow, you will need to complete the following set-up steps: +# +# 1. Create an ECR repository to store your images. +# For example: `aws ecr create-repository --repository-name my-ecr-repo --region us-east-2`. +# Replace the value of the `ECR_REPOSITORY` environment variable in the workflow below with your repository's name. +# Replace the value of the `AWS_REGION` environment variable in the workflow below with your repository's region. +# +# 2. Create an ECS task definition, an ECS cluster, and an ECS service. +# For example, follow the Getting Started guide on the ECS console: +# https://us-east-2.console.aws.amazon.com/ecs/home?region=us-east-2#/firstRun +# Replace the value of the `ECS_SERVICE` environment variable in the workflow below with the name you set for the Amazon ECS service. +# Replace the value of the `ECS_CLUSTER` environment variable in the workflow below with the name you set for the cluster. +# +# 3. Store your ECS task definition as a JSON file in your repository. +# The format should follow the output of `aws ecs register-task-definition --generate-cli-skeleton`. +# Replace the value of the `ECS_TASK_DEFINITION` environment variable in the workflow below with the path to the JSON file. +# Replace the value of the `CONTAINER_NAME` environment variable in the workflow below with the name of the container +# in the `containerDefinitions` section of the task definition. +# +# 4. Store an IAM user access key in GitHub Actions secrets named `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`. +# See the documentation for each action used below for the recommended IAM policies for this IAM user, +# and best practices on handling the access key credentials. + +name: Deploy to Amazon ECS + +on: + push: + branches: [ "main", "backend_server" ] + +env: + AWS_REGION: eu-north-1 # set this to your preferred AWS region, e.g. us-west-1 + ECR_REPOSITORY: lab-repository # set this to your Amazon ECR repository name + ECS_SERVICE: LabManager-task-service-first # set this to your Amazon ECS service name + ECS_CLUSTER: excited-fish # set this to your Amazon ECS cluster name + ECS_TASK_DEFINITION: ../../labman/LabManager-task-definition.json # set this to the path to your Amazon ECS task definition + # file, e.g. .aws/task-definition.json + CONTAINER_NAME: nextjs-lab # set this to the name of the container in the + # containerDefinitions section of your task definition + DATABASE_URL: ${{secrets.DATABASE_URL}} + +permissions: + contents: read + id-token: write + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + environment: production + + steps: + - name: Checkout + uses: actions/checkout@v4 + + #- name: Configure AWS credentials + # uses: aws-actions/configure-aws-credentials@v1 + #with: + # aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + #aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + #aws-region: ${{ env.AWS_REGION }} + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@main + with: + audience: sts.amazonaws.com + aws-region: ${{ env.AWS_REGION }} + role-to-assume: arn:aws:iam::861353196312:role/ghRole + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v1 + + - name: Build, tag, and push image to Amazon ECR + id: build-image + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + IMAGE_TAG: ${{ github.sha }} + run: | + # Build a docker container and + # push it to ECR so that it can + # be deployed to ECS. + docker build --build-arg DATABASE_URL="$DATABASE_URL" -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . + docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT + + - name: Fill in the new image ID in the Amazon ECS task definition + id: task-def + uses: aws-actions/amazon-ecs-render-task-definition@v1 + with: + task-definition: ${{ env.ECS_TASK_DEFINITION }} + container-name: ${{ env.CONTAINER_NAME }} + image: ${{ steps.build-image.outputs.image }} + + - name: Deploy Amazon ECS task definition + uses: aws-actions/amazon-ecs-deploy-task-definition@v1 + with: + task-definition: ${{ steps.task-def.outputs.task-definition }} + service: ${{ env.ECS_SERVICE }} + cluster: ${{ env.ECS_CLUSTER }} + wait-for-service-stability: true diff --git a/labman/Dockerfile b/labman/Dockerfile new file mode 100644 index 0000000..d8a242a --- /dev/null +++ b/labman/Dockerfile @@ -0,0 +1,71 @@ +# syntax=docker.io/docker/dockerfile:1 + +FROM node:20-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ +RUN \ + if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ + elif [ -f package-lock.json ]; then npm ci; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ + else echo "Lockfile not found." && exit 1; \ + fi + + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Next.js collects completely anonymous telemetry data about general usage. +# Learn more here: https://nextjs.org/telemetry +# Uncomment the following line in case you want to disable telemetry during the build. +# ENV NEXT_TELEMETRY_DISABLED=1 + +ARG DATABASE_URL +ENV DATABASE_URL=${DATABASE_URL} + +RUN npx prisma generate + +RUN \ + if [ -f yarn.lock ]; then yarn run build; \ + elif [ -f package-lock.json ]; then npm run build; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ + else echo "Lockfile not found." && exit 1; \ + fi + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + + +# Uncomment the following line in case you want to disable telemetry during runtime. +# ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 + +# server.js is created by next build from the standalone output +# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output +ENV HOSTNAME="0.0.0.0" +CMD ["node", "server.js"] diff --git a/labman/LabManager-task-definition.json b/labman/LabManager-task-definition.json new file mode 100644 index 0000000..dc701bf --- /dev/null +++ b/labman/LabManager-task-definition.json @@ -0,0 +1,90 @@ +{ + "taskDefinitionArn": "arn:aws:ecs:eu-north-1:861353196312:task-definition/LabManager-task:1", + "containerDefinitions": [ + { + "name": "nextjs-lab", + "image": "861353196312.dkr.ecr.eu-north-1.amazonaws.com/lab-repository@sha256:e7cdbf6f8318d5c7a55d534faa467df83a3dd9f9e9663068abef74fd19ff41d1", + "cpu": 0, + "portMappings": [ + { + "name": "main", + "containerPort": 3000, + "hostPort": 3000, + "protocol": "tcp", + "appProtocol": "http" + } + ], + "essential": true, + "environment": [], + "environmentFiles": [], + "mountPoints": [], + "volumesFrom": [], + "ulimits": [], + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": "/ecs/LabManager-task", + "awslogs-create-group": "true", + "awslogs-region": "eu-north-1", + "awslogs-stream-prefix": "ecs" + }, + "secretOptions": [] + }, + "systemControls": [] + } + ], + "family": "LabManager-task", + "executionRoleArn": "arn:aws:iam::861353196312:role/ecsTaskExecutionRole", + "networkMode": "awsvpc", + "revision": 1, + "volumes": [], + "status": "ACTIVE", + "requiresAttributes": [ + { + "name": "com.amazonaws.ecs.capability.logging-driver.awslogs" + }, + { + "name": "ecs.capability.execution-role-awslogs" + }, + { + "name": "com.amazonaws.ecs.capability.ecr-auth" + }, + { + "name": "com.amazonaws.ecs.capability.docker-remote-api.1.19" + }, + { + "name": "com.amazonaws.ecs.capability.docker-remote-api.1.21" + }, + { + "name": "ecs.capability.execution-role-ecr-pull" + }, + { + "name": "com.amazonaws.ecs.capability.docker-remote-api.1.18" + }, + { + "name": "ecs.capability.task-eni" + }, + { + "name": "com.amazonaws.ecs.capability.docker-remote-api.1.29" + } + ], + "placementConstraints": [], + "compatibilities": [ + "EC2", + "FARGATE", + "MANAGED_INSTANCES" + ], + "requiresCompatibilities": [ + "FARGATE" + ], + "cpu": "1024", + "memory": "3072", + "runtimePlatform": { + "cpuArchitecture": "X86_64", + "operatingSystemFamily": "LINUX" + }, + "registeredAt": "2026-01-29T14:16:22.647Z", + "registeredBy": "arn:aws:iam::861353196312:root", + "enableFaultInjection": false, + "tags": [] +} \ No newline at end of file diff --git a/labman/next.config.ts b/labman/next.config.ts index e9ffa30..3a5d1e9 100644 --- a/labman/next.config.ts +++ b/labman/next.config.ts @@ -2,6 +2,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ + output: "standalone", }; export default nextConfig; diff --git a/labman/prisma/schema.prisma b/labman/prisma/schema.prisma index 9ee749c..cdbca87 100644 --- a/labman/prisma/schema.prisma +++ b/labman/prisma/schema.prisma @@ -6,7 +6,7 @@ generator client { provider = "prisma-client-js" - binaryTargets = ["native", "debian-openssl-3.0.x"] + binaryTargets = ["native", "debian-openssl-3.0.x", "linux-musl-openssl-3.0.x"] output = "../src/generated/prisma" } From cbac290c36e4f31746e0968e8cd2305bde87e36c Mon Sep 17 00:00:00 2001 From: Ola Bekkevold Date: Tue, 3 Feb 2026 11:22:01 +0100 Subject: [PATCH 08/61] fix: correct path to Dockerfile --- .github/workflows/aws.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/aws.yml b/.github/workflows/aws.yml index 258fa6c..e3c121d 100644 --- a/.github/workflows/aws.yml +++ b/.github/workflows/aws.yml @@ -81,7 +81,7 @@ jobs: # Build a docker container and # push it to ECR so that it can # be deployed to ECS. - docker build --build-arg DATABASE_URL="$DATABASE_URL" -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . + docker build --build-arg DATABASE_URL="$DATABASE_URL" -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG ../../labman docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT From 2cc8c0079dc137bb6f8a5b516ae0f78b702e1d45 Mon Sep 17 00:00:00 2001 From: Ola Bekkevold Date: Tue, 3 Feb 2026 11:48:32 +0100 Subject: [PATCH 09/61] fix: small correction --- .github/workflows/aws.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/aws.yml b/.github/workflows/aws.yml index e3c121d..bd9b023 100644 --- a/.github/workflows/aws.yml +++ b/.github/workflows/aws.yml @@ -35,7 +35,7 @@ env: ECR_REPOSITORY: lab-repository # set this to your Amazon ECR repository name ECS_SERVICE: LabManager-task-service-first # set this to your Amazon ECS service name ECS_CLUSTER: excited-fish # set this to your Amazon ECS cluster name - ECS_TASK_DEFINITION: ../../labman/LabManager-task-definition.json # set this to the path to your Amazon ECS task definition + ECS_TASK_DEFINITION: labman/LabManager-task-definition.json # set this to the path to your Amazon ECS task definition # file, e.g. .aws/task-definition.json CONTAINER_NAME: nextjs-lab # set this to the name of the container in the # containerDefinitions section of your task definition @@ -81,7 +81,7 @@ jobs: # Build a docker container and # push it to ECR so that it can # be deployed to ECS. - docker build --build-arg DATABASE_URL="$DATABASE_URL" -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG ../../labman + docker build --build-arg DATABASE_URL="$DATABASE_URL" -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG /labman docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT From 76cd200924652e1a3dacb0bef9824f8654431533 Mon Sep 17 00:00:00 2001 From: Ola Bekkevold Date: Tue, 3 Feb 2026 11:53:55 +0100 Subject: [PATCH 10/61] test --- .github/workflows/aws.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/aws.yml b/.github/workflows/aws.yml index bd9b023..d6c82f1 100644 --- a/.github/workflows/aws.yml +++ b/.github/workflows/aws.yml @@ -81,7 +81,8 @@ jobs: # Build a docker container and # push it to ECR so that it can # be deployed to ECS. - docker build --build-arg DATABASE_URL="$DATABASE_URL" -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG /labman + pwd + docker build --build-arg DATABASE_URL="$DATABASE_URL" -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG ./labman docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT From 75526be517c21a8571a461ba1507a2dc54fd15e1 Mon Sep 17 00:00:00 2001 From: Ola Bekkevold Date: Tue, 3 Feb 2026 12:09:46 +0100 Subject: [PATCH 11/61] fix: update ecs-deploy-action version --- .github/workflows/aws.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/aws.yml b/.github/workflows/aws.yml index d6c82f1..42fb73d 100644 --- a/.github/workflows/aws.yml +++ b/.github/workflows/aws.yml @@ -95,7 +95,7 @@ jobs: image: ${{ steps.build-image.outputs.image }} - name: Deploy Amazon ECS task definition - uses: aws-actions/amazon-ecs-deploy-task-definition@v1 + uses: aws-actions/amazon-ecs-deploy-task-definition@v2 with: task-definition: ${{ steps.task-def.outputs.task-definition }} service: ${{ env.ECS_SERVICE }} From 5e50b3cfa773947592fe9893f92ccb3462edca40 Mon Sep 17 00:00:00 2001 From: Ola Bekkevold Date: Tue, 3 Feb 2026 13:51:56 +0100 Subject: [PATCH 12/61] test deployment --- labman/src/app/(auth)/login/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/labman/src/app/(auth)/login/page.tsx b/labman/src/app/(auth)/login/page.tsx index cbf5500..f51a5a7 100644 --- a/labman/src/app/(auth)/login/page.tsx +++ b/labman/src/app/(auth)/login/page.tsx @@ -37,7 +37,7 @@ export default function Home() {
setUsername(e.target.value)} type="text" name="username" placeholder="Username" className="bg-white rounded-md p-2 m-2 placeholder-black text-black" /> setPassword(e.target.value)} type="password" name="password" placeholder="Password" className="bg-white rounded-md p-2 m-2 placeholder-black text-black" /> - +
From 92f2a15ae2cb9baed0d37a80bb18c586968343dd Mon Sep 17 00:00:00 2001 From: Ola Bekkevold Date: Thu, 5 Feb 2026 15:13:21 +0100 Subject: [PATCH 13/61] test github secrets --- .github/workflows/aws.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/aws.yml b/.github/workflows/aws.yml index 42fb73d..1af04d7 100644 --- a/.github/workflows/aws.yml +++ b/.github/workflows/aws.yml @@ -40,6 +40,7 @@ env: CONTAINER_NAME: nextjs-lab # set this to the name of the container in the # containerDefinitions section of your task definition DATABASE_URL: ${{secrets.DATABASE_URL}} + TEST: ${{secrets.TEST}} permissions: contents: read @@ -61,6 +62,8 @@ jobs: # aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} #aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} #aws-region: ${{ env.AWS_REGION }} + - name: test env secrets + run: echo "$TEST" - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@main with: From e71b3163d39b1fda62a3c59e664e911cb6904e2d Mon Sep 17 00:00:00 2001 From: Ola Bekkevold Date: Fri, 6 Feb 2026 11:50:04 +0100 Subject: [PATCH 14/61] use aws secrets --- labman/LabManager-task-definition.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/labman/LabManager-task-definition.json b/labman/LabManager-task-definition.json index dc701bf..5ce8100 100644 --- a/labman/LabManager-task-definition.json +++ b/labman/LabManager-task-definition.json @@ -30,7 +30,14 @@ }, "secretOptions": [] }, - "systemControls": [] + "systemControls": [], + "secrets": [ + { + "name": "DATABASE_URL", + "valueFrom": "arn:aws:secretsmanager:eu-north-1:861353196312:secret:lab/prod/db-connection-3F8gP9" + + } + ] } ], "family": "LabManager-task", From 9c139df055d9bb315a360672b726f26be8b589bf Mon Sep 17 00:00:00 2001 From: Ola Bekkevold Date: Fri, 6 Feb 2026 13:50:45 +0100 Subject: [PATCH 15/61] specifiy correct secret key --- labman/LabManager-task-definition.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/labman/LabManager-task-definition.json b/labman/LabManager-task-definition.json index 5ce8100..1d33584 100644 --- a/labman/LabManager-task-definition.json +++ b/labman/LabManager-task-definition.json @@ -34,7 +34,7 @@ "secrets": [ { "name": "DATABASE_URL", - "valueFrom": "arn:aws:secretsmanager:eu-north-1:861353196312:secret:lab/prod/db-connection-3F8gP9" + "valueFrom": "arn:aws:secretsmanager:eu-north-1:861353196312:secret:lab/prod/db-connection-3F8gP9:DATABASE_URL::" } ] From 871c303fb4631b72b293665df6670e6c14e424c7 Mon Sep 17 00:00:00 2001 From: Ola Bekkevold Date: Fri, 6 Feb 2026 14:30:52 +0100 Subject: [PATCH 16/61] debug secret key --- .github/workflows/aws.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/aws.yml b/.github/workflows/aws.yml index 1af04d7..47cf870 100644 --- a/.github/workflows/aws.yml +++ b/.github/workflows/aws.yml @@ -40,7 +40,6 @@ env: CONTAINER_NAME: nextjs-lab # set this to the name of the container in the # containerDefinitions section of your task definition DATABASE_URL: ${{secrets.DATABASE_URL}} - TEST: ${{secrets.TEST}} permissions: contents: read @@ -62,8 +61,7 @@ jobs: # aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} #aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} #aws-region: ${{ env.AWS_REGION }} - - name: test env secrets - run: echo "$TEST" + - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@main with: From 3af43aea703e5975410a07bae412127c7c9e4c0a Mon Sep 17 00:00:00 2001 From: Ola Bekkevold Date: Mon, 9 Feb 2026 09:33:25 +0100 Subject: [PATCH 17/61] temporarly disable https production requirment --- labman/src/app/api/login/route.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/labman/src/app/api/login/route.ts b/labman/src/app/api/login/route.ts index 0c79c10..afba11e 100644 --- a/labman/src/app/api/login/route.ts +++ b/labman/src/app/api/login/route.ts @@ -18,7 +18,6 @@ export async function POST(req: Request) { // Set the session cookie (await cookies()).set("session", session.token, { httpOnly: true, - secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: 60 * 60 * 24, path: "/" From fed65fd0bce45da9270b773068a96dc5032a4468 Mon Sep 17 00:00:00 2001 From: Ola Bekkevold Date: Tue, 17 Feb 2026 10:46:33 +0100 Subject: [PATCH 18/61] fix: set phone and email inputs to null when empty and make deployment exclusive to main fix a bug were the adding of a user with empty phone/email would fail due to being sent as empty strings instead of null values. And the project will only deploy after successful tests on main Refs: #49 --- .github/workflows/aws.yml | 11 ++++++++-- labman/src/lib/actions.ts | 44 ++++++++++++++++++++++++++++++--------- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/.github/workflows/aws.yml b/.github/workflows/aws.yml index 47cf870..ceb1a6c 100644 --- a/.github/workflows/aws.yml +++ b/.github/workflows/aws.yml @@ -27,8 +27,9 @@ name: Deploy to Amazon ECS on: - push: - branches: [ "main", "backend_server" ] + workflow_run: + workflows: ["Tests"] + types: [completed] env: AWS_REGION: eu-north-1 # set this to your preferred AWS region, e.g. us-west-1 @@ -47,6 +48,10 @@ permissions: jobs: deploy: + if: > + ${{ github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_branch == 'main' && + github.event.workflow_run.event == 'push'}} name: Deploy runs-on: ubuntu-latest environment: production @@ -54,6 +59,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} #- name: Configure AWS credentials # uses: aws-actions/configure-aws-credentials@v1 diff --git a/labman/src/lib/actions.ts b/labman/src/lib/actions.ts index 75f0ad6..005802b 100644 --- a/labman/src/lib/actions.ts +++ b/labman/src/lib/actions.ts @@ -126,22 +126,46 @@ export async function updateEquipment (equipmentId: number, name: string, catego return equipment; } -export async function addLoan (borrower : string, start : string, end : string, unitId : number, phone? : string, email? : string) { +export async function addLoan (borrowerName : string, start : string, end : string, unitId : number, phone : string | null, email : string | null) { const dateStart = new Date(start); const dateEnd = new Date(end); const user = await getUser(); if (!user) {alert("Could not find a valid user"); return} - let borrowerUser = await prisma.borrower.findUnique({ - where: { - phone: phone - } - }) - if (!borrowerUser) { - borrowerUser = await prisma.borrower.create({ + let borrower; + + // If phone or email is actually empty, set it to null + if (phone?.trim() === "") {phone = null} + + if (email?.trim() === "") {email = null} + + if (phone) { + borrower = await prisma.borrower.findUnique({where:{phone: phone}}) + /* if (borrower && borrower.name !== borrowerName) { + if (window.confirm(`A borrower with the same phone number already exists with a different name (${borrower.name}). Click OK to assign this loan to ${borrowerName}. Click Cancel to assign it to ${borrower.name} instead.`)) { + borrower = null; + } + } */ + + } else if (email) { + borrower = await prisma.borrower.findUnique({where:{email: email}}) + /*if (borrower && borrower.name !== borrowerName) { + if (window.confirm(`A borrower with the same email already exists with a different name (${borrower.name}). Click OK to assign this loan to ${borrowerName}. Click Cancel to assign it to ${borrower.name} instead.`)) { + borrower = null; + } + } */ + } else { + alert("No borrower phone/email provided"); return; + } + + + + + if (!borrower) { + borrower = await prisma.borrower.create({ data: { - name: borrower, + name: borrowerName, phone: phone, email: email, note: "", @@ -155,7 +179,7 @@ export async function addLoan (borrower : string, start : string, end : string, startDate: dateStart, endDate: dateEnd, status: "Active", - borrowerId: borrowerUser.id, + borrowerId: borrower.id, userId: user.id, itemId: unitId } From 462a6b92ea805adea4daedde3f1914b6fb3050c1 Mon Sep 17 00:00:00 2001 From: OlaBekkevold <145664302+OlaBekkevold@users.noreply.github.com> Date: Wed, 18 Feb 2026 19:20:25 +0100 Subject: [PATCH 19/61] test deployment --- .github/workflows/aws.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/aws.yml b/.github/workflows/aws.yml index ceb1a6c..d0dce73 100644 --- a/.github/workflows/aws.yml +++ b/.github/workflows/aws.yml @@ -50,7 +50,6 @@ jobs: deploy: if: > ${{ github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.event == 'push'}} name: Deploy runs-on: ubuntu-latest From 7f8ad8cda0f3b04776c01e3400f12308ecf5fefe Mon Sep 17 00:00:00 2001 From: OlaBekkevold <145664302+OlaBekkevold@users.noreply.github.com> Date: Wed, 18 Feb 2026 19:30:05 +0100 Subject: [PATCH 20/61] Test older workflow version --- .github/workflows/aws.yml | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/aws.yml b/.github/workflows/aws.yml index d0dce73..06c845a 100644 --- a/.github/workflows/aws.yml +++ b/.github/workflows/aws.yml @@ -1,4 +1,4 @@ -# This workflow will build and push a new container image to Amazon ECR, +# This workflow will build and push a new container image to Amazon ECR, # and then will deploy a new task definition to Amazon ECS, when there is a push to the "main" branch. # # To use this workflow, you will need to complete the following set-up steps: @@ -27,9 +27,9 @@ name: Deploy to Amazon ECS on: - workflow_run: - workflows: ["Tests"] - types: [completed] + push: + branches: [ "main", "develop" ] + env: AWS_REGION: eu-north-1 # set this to your preferred AWS region, e.g. us-west-1 @@ -48,9 +48,10 @@ permissions: jobs: deploy: - if: > - ${{ github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push'}} + + + + name: Deploy runs-on: ubuntu-latest environment: production @@ -58,8 +59,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - with: - ref: ${{ github.event.workflow_run.head_sha }} + + #- name: Configure AWS credentials # uses: aws-actions/configure-aws-credentials@v1 From 467ad00eab576be4181173f1f2311a2172f5cb4a Mon Sep 17 00:00:00 2001 From: Ola Bekkevold Date: Fri, 20 Feb 2026 15:38:26 +0100 Subject: [PATCH 21/61] feat: loan and borrower editing add editing functionality to loans and borrowers which works pretty similar to how equipment editing is already implemented Refs: #46 --- labman/src/components/core/Card.tsx | 11 +- labman/src/components/core/CardList.tsx | 13 +- .../components/inventory/EquipmentInfo.tsx | 4 +- labman/src/components/inventory/LoanView.tsx | 12 +- labman/src/components/loans/EditLoan.tsx | 236 ++++++++++++++++++ labman/src/lib/actions.ts | 86 +++++-- 6 files changed, 334 insertions(+), 28 deletions(-) create mode 100644 labman/src/components/loans/EditLoan.tsx diff --git a/labman/src/components/core/Card.tsx b/labman/src/components/core/Card.tsx index 06907b6..da4f473 100644 --- a/labman/src/components/core/Card.tsx +++ b/labman/src/components/core/Card.tsx @@ -6,11 +6,13 @@ import {LoanClass} from "@/types/Loan"; interface CardProps { user?: UserClass loan?: LoanClass; + setSideView?: (view: string) => void; + setSelectedLoanId?: (id: number | null) => void; } // TODO: Could have a button to reactivate a loan, not a priority right now -export default function Card({ loan, user }: CardProps) { +export default function Card({ loan, user, setSelectedLoanId, setSideView }: CardProps) { let {name, start, last} = {name: "", start: "", last: ""}; @@ -67,7 +69,12 @@ export default function Card({ loan, user }: CardProps) { }
- + { loan && loan.status != "Returned" && }
diff --git a/labman/src/components/core/CardList.tsx b/labman/src/components/core/CardList.tsx index 04ba179..50cd954 100644 --- a/labman/src/components/core/CardList.tsx +++ b/labman/src/components/core/CardList.tsx @@ -5,6 +5,7 @@ import {User} from "@/generated/prisma"; import {UserClass} from "@/types/User"; import {returnLoan, deleteLoan, deleteUser} from "@/lib/actions"; import {LoanClass} from "@/types/Loan"; +import EditLoan from "@/components/loans/EditLoan"; type Loans = { @@ -51,6 +52,10 @@ export default function CardList({ loansProp = [], usersProp = []}: CardListProp const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); + const [sideView, setSideView] = useState(""); + const [selectedLoanId, setSelectedLoanId] = useState(null); + + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); const res = await fetch("/api/register", { @@ -118,6 +123,12 @@ export default function CardList({ loansProp = [], usersProp = []}: CardListProp return (
+ { sideView == "loanEdit" && selectedLoanId && loan.id === selectedLoanId)!} + setSideView={setSideView} + setLoans={setLoans} + + />} { users.length !== 0 &&
setUsername(e.target.value)} type="text" name="username" placeholder="Username" className="bg-white rounded-md p-2 m-2 placeholder-black text-black" /> @@ -140,7 +151,7 @@ export default function CardList({ loansProp = [], usersProp = []}: CardListProp returnLoan: async (id : number) => handleReturnLoan(id) } ) - return ; + return ; })} {users.map(userDto => { const user = new UserClass( diff --git a/labman/src/components/inventory/EquipmentInfo.tsx b/labman/src/components/inventory/EquipmentInfo.tsx index 864de91..6bfdc80 100644 --- a/labman/src/components/inventory/EquipmentInfo.tsx +++ b/labman/src/components/inventory/EquipmentInfo.tsx @@ -91,7 +91,7 @@ export default function EquipmentInfo({equipmentData, setSideView, setAllEquipme const updatedEq = await updateEquipment(equipmentData!.id, formData.name!, formData.category!, formData.image!) const updatedEquipment = { - ...equipmentData!, + ...equipmentData, name: updatedEq.name, category: { id: updatedEq.category.id, @@ -128,7 +128,7 @@ export default function EquipmentInfo({equipmentData, setSideView, setAllEquipme

Equipment information

- +
diff --git a/labman/src/components/inventory/LoanView.tsx b/labman/src/components/inventory/LoanView.tsx index 9f73816..64f90ff 100644 --- a/labman/src/components/inventory/LoanView.tsx +++ b/labman/src/components/inventory/LoanView.tsx @@ -110,11 +110,14 @@ export default function LoanView({setSideView, equipmentData, setAllEquipment, s type="text" className="side-form-input" onChange={(e) => { - console.log(e.target.value); - setFormData({...formData, borrower: e.target.value}) - console.log(formData); const borrower = borrowers.find(borrower => borrower.name === e.target.value); - if (borrower) setFormData({...formData, borrowerPhone: borrower.phone, borrowerEmail: borrower.email}); + // TODO: Better to use functional updates because React hasn't re-rendered the component yet and should not do two state changes at once due to batching + setFormData(prev => ({ + ...prev, + borrower: e.target.value, + borrowerPhone: borrower?.phone || "", + borrowerMail: borrower?.email || "" + })) }} /> @@ -127,7 +130,6 @@ export default function LoanView({setSideView, equipmentData, setAllEquipment, s type="date" required value={formData.startDate} - min={today} className="side-form-input" onChange={(e) => { const selected = e.target.value; diff --git a/labman/src/components/loans/EditLoan.tsx b/labman/src/components/loans/EditLoan.tsx new file mode 100644 index 0000000..97ddbf6 --- /dev/null +++ b/labman/src/components/loans/EditLoan.tsx @@ -0,0 +1,236 @@ +"use client" + +import {loanCount} from "@/utils/inventoryUtils"; +import {useEffect, useState} from "react"; +import {updateLoan} from "@/lib/actions"; + +type Loan = { + id: number; + startDate: Date; + endDate: Date; + status: string; + + borrower: { + id: number; + name: string; + phone?: string | null; + email?: string | null + note?: string | null + creationDate: Date; + + } + item: { + id: number; + equipment: { + id: number; + name: string; + categoryId: number; + image: string | null; + createdAt: Date; + + } + } +} + +type Borrower = { + id: number; + name: string; + phone: string; + email: string; +} + +interface EditLoanProps { + loan: Loan; + setSideView: (view: string) => void; + setLoans: React.Dispatch>; +} + +export default function EditLoan({loan, setSideView, setLoans}: EditLoanProps) { + + const [initialFormData, setInitialFormData] = useState({borrower: loan.borrower.name, startDate: loan.startDate, endDate: loan.endDate, borrowerPhone: loan.borrower.phone, borrowerMail: loan.borrower.email}) + const [formData, setFormData] = useState(initialFormData); + + const phoneRequired = formData.borrowerMail?.trim() === ""; + const emailRequired = formData.borrowerPhone?.trim() === ""; + + const [borrowers, setBorrowers] = useState([]); + useEffect(() => { + fetch("/api/borrower") + .then(res => res.json()) + .then(data => setBorrowers(data)) + }, []); + + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + + if ( + !formData.borrower.trim() + || !formData.startDate + || !formData.endDate + || (!formData.borrowerPhone?.trim() && !formData.borrowerMail?.trim()) + ) { + alert("Please fill in all required fields"); + return; + } + + const updatedLoan = await updateLoan( + loan.id, + formData.startDate, + formData.endDate, + formData.borrower, + loan.borrower.id, + formData.borrowerPhone, + formData.borrowerMail + ); + if (!updatedLoan) return; + + setLoans(prev => + prev.map(loan => loan.id === updatedLoan.id ? updatedLoan : loan) + + ) + + setInitialFormData(formData); + + + + } + + return( + <> + {/* Dark backdrop */} +
setSideView("")} + /> + + {/* Right-side panel */} +
+ + {/* Vertical split */} +
+ {/* Left side of a panel */} +
+

Edit loan

+
+ + + +
+ +
+ + + { + const borrower = borrowers.find(borrower => borrower.name === e.target.value); + // TODO: Better to use functional updates because React hasn't re-rendered the component yet and should not do two state changes at once due to batching + setFormData(prev => ({ + ...prev, + borrower: e.target.value, + borrowerPhone: borrower?.phone || "", + borrowerMail: borrower?.email || "" + })) + }} + /> + + {borrowers.map(borrower => )} + + + { + const selected = e.target.value; + setFormData({...formData, startDate: new Date(selected)}) + }} + /> + + { + const selected = e.target.value; + if (selected < formData.startDate.toLocaleDateString()) return; + setFormData({...formData, endDate: new Date(selected)}); + + }} + /> + + { + console.log(e.target.value); + setFormData({...formData, borrowerPhone: e.target.value}) + console.log(formData); + }} + /> + + { + setFormData({...formData, borrowerMail: e.target.value}) + }} + /> + +
+ --------------------------------------------------------------------------------------- +

Items

+
+
+ {/*equipmentData.items.map((unit, index) => ( + hasActiveLoan = unit.activeLoan != null, +
+

Unit {index + 1}

+ { unit.activeLoan && (unit.activeLoan.status !== "Returned") &&

Borrowed

} + { (unit.activeLoan == null || unit.activeLoan.status === "Returned") && } + +
+ )) */} +
+
+
+ + {/* Right side of panel */} +
+
+ +
+
+ {/*
+

{equipmentData.name}

+

{equipmentData.category.name}

+

{equipmentData.items.length - loanCount(equipmentData)}/{equipmentData.items.length} Available

+
*/} + ---------------------------------------------------------------------------------- +

History

+
+
+
+
+ + ) +} \ No newline at end of file diff --git a/labman/src/lib/actions.ts b/labman/src/lib/actions.ts index 005802b..ec17ef7 100644 --- a/labman/src/lib/actions.ts +++ b/labman/src/lib/actions.ts @@ -3,6 +3,7 @@ import prisma from "@/lib/prisma" import {revalidatePath} from "next/cache"; import {deleteSession, validateSessionToken} from "@/auth/session" import {cookies} from "next/headers"; +import {Borrower} from "@/generated/prisma"; export async function deleteUser(userId : number) { @@ -126,27 +127,41 @@ export async function updateEquipment (equipmentId: number, name: string, catego return equipment; } -export async function addLoan (borrowerName : string, start : string, end : string, unitId : number, phone : string | null, email : string | null) { - const dateStart = new Date(start); - const dateEnd = new Date(end); +export async function addBorrower(name: string, phone?: string | null, email?: string | null, borrowerId?: number) : Promise { const user = await getUser(); + if (!user) {alert("Could not find a valid user"); return null} + let borrower : Borrower | null = null; - if (!user) {alert("Could not find a valid user"); return} - - let borrower; // If phone or email is actually empty, set it to null if (phone?.trim() === "") {phone = null} - if (email?.trim() === "") {email = null} + // If borrowerId is provided, update the borrower with the provided information + if (borrowerId) { + borrower = await prisma.borrower.findUnique({where:{id: borrowerId}}) + /* TODO: Potentially unsafe. The function can in theory be called with an unrelated id updating the wrong borrower + Since the unique values phone and mail can change they can't be used to verify the borrower. + A possible solution is to compare the old values with what is currently stored in the database. + But it shouldn't really be a big deal as there is now way for the client to abuse it.*/ + if (!borrower) {alert("Could not find borrower with id " + borrowerId); return null} + + borrower = await prisma.borrower.update( + { + where: {id: borrowerId}, + data: {name: name, phone: phone, email: email} + } + ) + return borrower; + } + if (phone) { borrower = await prisma.borrower.findUnique({where:{phone: phone}}) - /* if (borrower && borrower.name !== borrowerName) { - if (window.confirm(`A borrower with the same phone number already exists with a different name (${borrower.name}). Click OK to assign this loan to ${borrowerName}. Click Cancel to assign it to ${borrower.name} instead.`)) { - borrower = null; - } - } */ + /* if (borrower && borrower.name !== borrowerName) { + if (window.confirm(`A borrower with the same phone number already exists with a different name (${borrower.name}). Click OK to assign this loan to ${borrowerName}. Click Cancel to assign it to ${borrower.name} instead.`)) { + borrower = null; + } + } */ } else if (email) { borrower = await prisma.borrower.findUnique({where:{email: email}}) @@ -156,16 +171,13 @@ export async function addLoan (borrowerName : string, start : string, end : stri } } */ } else { - alert("No borrower phone/email provided"); return; + alert("No borrower phone/email provided"); return null; } - - - if (!borrower) { borrower = await prisma.borrower.create({ data: { - name: borrowerName, + name: name, phone: phone, email: email, note: "", @@ -173,6 +185,42 @@ export async function addLoan (borrowerName : string, start : string, end : stri } }) } + return borrower; +} + +export async function updateLoan (loanId: number, start : Date, end : Date, borrowerName : string, borrowerId : number, phone? : string | null, email? : string | null) { + if (await getUser() === null) {alert("Could not find a valid user"); return} + + const borrower = await addBorrower(borrowerName, phone, email, borrowerId) + if (!borrower) {return} + + const loan = await prisma.loan.update({ + where: { + id: loanId + }, + data: { + startDate: start, + endDate: end, + borrowerId: borrower.id + }, + include: { + borrower: true, + item: { include: { equipment: true }} + } + }) + revalidatePath("/loans"); + return (loan) + +} + +export async function addLoan (borrowerName : string, start : string, end : string, unitId : number, phone : string | null, email : string | null) { + const dateStart = new Date(start); + const dateEnd = new Date(end); + const user = await getUser(); + if (!user) {alert("Could not find a valid user"); return} + + const borrower = await addBorrower(borrowerName, phone, email) + if (!borrower) {return} const loan = await prisma.loan.create({ data: { @@ -209,7 +257,9 @@ export async function getUser() { } }) return tSession?.user; - } + } else { + return null; + } } From 469cfd2327ecdf32677f566026e777dd606098a1 Mon Sep 17 00:00:00 2001 From: Ola Bekkevold Date: Wed, 25 Feb 2026 09:17:08 +0100 Subject: [PATCH 22/61] feat: improve input and error handling make user inputs safer to correct the user before they can submit and add proper error handling for possible contradictions when updating a loan or borrower Refs: #46 #30 #38 --- labman/src/components/loans/EditLoan.tsx | 76 +++++++++++------------- labman/src/lib/actions.ts | 46 +++++++++----- labman/src/types/Loan.ts | 28 +++++++++ 3 files changed, 94 insertions(+), 56 deletions(-) diff --git a/labman/src/components/loans/EditLoan.tsx b/labman/src/components/loans/EditLoan.tsx index 97ddbf6..e14a3ba 100644 --- a/labman/src/components/loans/EditLoan.tsx +++ b/labman/src/components/loans/EditLoan.tsx @@ -3,34 +3,8 @@ import {loanCount} from "@/utils/inventoryUtils"; import {useEffect, useState} from "react"; import {updateLoan} from "@/lib/actions"; +import {Loan} from "@/types/Loan"; -type Loan = { - id: number; - startDate: Date; - endDate: Date; - status: string; - - borrower: { - id: number; - name: string; - phone?: string | null; - email?: string | null - note?: string | null - creationDate: Date; - - } - item: { - id: number; - equipment: { - id: number; - name: string; - categoryId: number; - image: string | null; - createdAt: Date; - - } - } -} type Borrower = { id: number; @@ -58,7 +32,7 @@ export default function EditLoan({loan, setSideView, setLoans}: EditLoanProps) { fetch("/api/borrower") .then(res => res.json()) .then(data => setBorrowers(data)) - }, []); + }, [initialFormData]); async function handleSubmit(e: React.FormEvent) { @@ -73,17 +47,24 @@ export default function EditLoan({loan, setSideView, setLoans}: EditLoanProps) { alert("Please fill in all required fields"); return; } - - const updatedLoan = await updateLoan( - loan.id, - formData.startDate, - formData.endDate, - formData.borrower, - loan.borrower.id, - formData.borrowerPhone, - formData.borrowerMail - ); - if (!updatedLoan) return; + let updatedLoan: Loan; + const res = await updateLoan( + loan.id, + formData.startDate, + formData.endDate, + formData.borrower, + loan.borrower.id, + formData.borrowerPhone, + formData.borrowerMail + ); + console.log(res); + + if (res.type === "error") { + alert(res.message); + return; + } else if (res.type === "success") { + updatedLoan = res.data; + } setLoans(prev => prev.map(loan => loan.id === updatedLoan.id ? updatedLoan : loan) @@ -176,9 +157,15 @@ export default function EditLoan({loan, setSideView, setLoans}: EditLoanProps) { pattern="[0-9]{8}" className="side-form-input" onChange={(e) => { - console.log(e.target.value); + e.target.setCustomValidity(""); + if (e.target.value.length === 8) { + if (borrowers.find(borrower => borrower.phone === e.target.value && borrower.id !== loan.borrower.id)) { + e.target.setCustomValidity("A borrower with the same phone number already exists"); + e.target.reportValidity(); + } + } setFormData({...formData, borrowerPhone: e.target.value}) - console.log(formData); + }} /> @@ -188,6 +175,13 @@ export default function EditLoan({loan, setSideView, setLoans}: EditLoanProps) { type="email" className="side-form-input" onChange={(e) => { + e.target.setCustomValidity(""); + if (e.target.checkValidity()) { + if (borrowers.find(borrower => borrower.email === e.target.value && borrower.id !== loan.borrower.id)) { + e.target.setCustomValidity("A borrower with the same email already exists"); + e.target.reportValidity(); + } + } setFormData({...formData, borrowerMail: e.target.value}) }} /> diff --git a/labman/src/lib/actions.ts b/labman/src/lib/actions.ts index ec17ef7..a147ae1 100644 --- a/labman/src/lib/actions.ts +++ b/labman/src/lib/actions.ts @@ -4,6 +4,9 @@ import {revalidatePath} from "next/cache"; import {deleteSession, validateSessionToken} from "@/auth/session" import {cookies} from "next/headers"; import {Borrower} from "@/generated/prisma"; +import {Loan} from "@/types/Loan"; + +type ActionResult = | { type: "success"; data: T} | { type: "confirm"; message: string} | { type: "error"; message: string} export async function deleteUser(userId : number) { @@ -127,9 +130,9 @@ export async function updateEquipment (equipmentId: number, name: string, catego return equipment; } -export async function addBorrower(name: string, phone?: string | null, email?: string | null, borrowerId?: number) : Promise { +export async function addBorrower(name: string, phone?: string | null, email?: string | null, borrowerId?: number) : Promise> { const user = await getUser(); - if (!user) {alert("Could not find a valid user"); return null} + if (!user) {return {type: "error", message: "Could not find a valid user"}} let borrower : Borrower | null = null; @@ -144,7 +147,20 @@ export async function addBorrower(name: string, phone?: string | null, email?: s Since the unique values phone and mail can change they can't be used to verify the borrower. A possible solution is to compare the old values with what is currently stored in the database. But it shouldn't really be a big deal as there is now way for the client to abuse it.*/ - if (!borrower) {alert("Could not find borrower with id " + borrowerId); return null} + if (!borrower) {return {type: "error", message: "Could not find borrower with id " + borrowerId}} + + + if (phone && borrower.phone !== phone) { + console.log("Phone number has changed, checking for duplicates") + if (await prisma.borrower.findUnique({where:{phone: phone}})) { + console.log("Borrower with phone number already exists") + + return {type: "error", message: `A borrower with the same phone number already exists.`} + } + } else if (email && borrower.email !== email) { + if (await prisma.borrower.findUnique({where:{email: email}})) + return {type: "error", message: `A borrower with the same email already exists.`} + } borrower = await prisma.borrower.update( { @@ -152,7 +168,7 @@ export async function addBorrower(name: string, phone?: string | null, email?: s data: {name: name, phone: phone, email: email} } ) - return borrower; + return {type: "success", data: borrower}; } if (phone) { @@ -171,7 +187,7 @@ export async function addBorrower(name: string, phone?: string | null, email?: s } } */ } else { - alert("No borrower phone/email provided"); return null; + return {type: "error", message: "No borrower phone/email provided"} } if (!borrower) { @@ -185,14 +201,14 @@ export async function addBorrower(name: string, phone?: string | null, email?: s } }) } - return borrower; + return {type: "success", data: borrower}; } -export async function updateLoan (loanId: number, start : Date, end : Date, borrowerName : string, borrowerId : number, phone? : string | null, email? : string | null) { - if (await getUser() === null) {alert("Could not find a valid user"); return} +export async function updateLoan (loanId: number, start : Date, end : Date, borrowerName : string, borrowerId : number, phone? : string | null, email? : string | null) : Promise> { + if (await getUser() === null) {return {type:"error", message: "Could not find a valid user"}} - const borrower = await addBorrower(borrowerName, phone, email, borrowerId) - if (!borrower) {return} + const res = await addBorrower(borrowerName, phone, email, borrowerId) + if (res.type !== "success") {return {type: "error", message: res.message}} const loan = await prisma.loan.update({ where: { @@ -201,7 +217,7 @@ export async function updateLoan (loanId: number, start : Date, end : Date, borr data: { startDate: start, endDate: end, - borrowerId: borrower.id + borrowerId: res.data.id }, include: { borrower: true, @@ -209,7 +225,7 @@ export async function updateLoan (loanId: number, start : Date, end : Date, borr } }) revalidatePath("/loans"); - return (loan) + return {type: "success", data: loan}; } @@ -219,15 +235,15 @@ export async function addLoan (borrowerName : string, start : string, end : stri const user = await getUser(); if (!user) {alert("Could not find a valid user"); return} - const borrower = await addBorrower(borrowerName, phone, email) - if (!borrower) {return} + const res = await addBorrower(borrowerName, phone, email) + if (res.type !== "success") {return} const loan = await prisma.loan.create({ data: { startDate: dateStart, endDate: dateEnd, status: "Active", - borrowerId: borrower.id, + borrowerId: res.data.id, userId: user.id, itemId: unitId } diff --git a/labman/src/types/Loan.ts b/labman/src/types/Loan.ts index db8082e..c8ef8b1 100644 --- a/labman/src/types/Loan.ts +++ b/labman/src/types/Loan.ts @@ -3,6 +3,34 @@ returnLoan: (id: number) => void; } +export type Loan = { + id: number; + startDate: Date; + endDate: Date; + status: string; + + borrower: { + id: number; + name: string; + phone?: string | null; + email?: string | null + note?: string | null + creationDate: Date; + + } + item: { + id: number; + equipment: { + id: number; + name: string; + categoryId: number; + image: string | null; + createdAt: Date; + + } + } +} + type Borrower = { id: number; name: string; From 5ab9c6a709dbb581efadde8974ffc53c43ae0bbb Mon Sep 17 00:00:00 2001 From: Ola Bekkevold Date: Thu, 26 Feb 2026 16:41:16 +0100 Subject: [PATCH 23/61] feat: global side view context and early refactoring of sideview Refs: #52 --- labman/src/app/(main)/layout.tsx | 27 +-- labman/src/app/(main)/popupProvider.tsx | 22 -- labman/src/app/sideViewContext.tsx | 26 +++ labman/src/components/core/CardList.tsx | 5 +- labman/src/components/core/NavBar.tsx | 1 - labman/src/components/core/SideView.tsx | 108 ++++++++++ labman/src/components/loans/EditLoan.tsx | 243 +++++++++-------------- 7 files changed, 241 insertions(+), 191 deletions(-) delete mode 100644 labman/src/app/(main)/popupProvider.tsx create mode 100644 labman/src/app/sideViewContext.tsx create mode 100644 labman/src/components/core/SideView.tsx diff --git a/labman/src/app/(main)/layout.tsx b/labman/src/app/(main)/layout.tsx index 1fe817c..53b28b1 100644 --- a/labman/src/app/(main)/layout.tsx +++ b/labman/src/app/(main)/layout.tsx @@ -5,6 +5,7 @@ import NavBar from "@/components/core/NavBar"; //import {PopupProvider} from "./popupProvider" import {getUser} from "@/lib/actions"; import SideBar from "@/components/core/SideBar"; +import {SideViewProvider} from "@/app/sideViewContext"; const spartan = League_Spartan({ subsets: ["latin"], @@ -27,20 +28,20 @@ export default async function RootLayout({ return ( - - -
- - - -
- - {children} -
-
+ +
+ + + +
+ + {children} +
+
+
); diff --git a/labman/src/app/(main)/popupProvider.tsx b/labman/src/app/(main)/popupProvider.tsx deleted file mode 100644 index 7d919a1..0000000 --- a/labman/src/app/(main)/popupProvider.tsx +++ /dev/null @@ -1,22 +0,0 @@ -/*"use client" -import {createContext, useState} from "react"; - -export const popupContext = createContext(null); - -export const PopupProvider = ({children}) => { - const [isOpen, setIsOpen] = useState(false); - const [message, setMessage] = useState(""); - - return ( - - <> - {children} -
-
-

{message}

-
-
- -
- ) -} */ \ No newline at end of file diff --git a/labman/src/app/sideViewContext.tsx b/labman/src/app/sideViewContext.tsx new file mode 100644 index 0000000..a304d85 --- /dev/null +++ b/labman/src/app/sideViewContext.tsx @@ -0,0 +1,26 @@ +"use client"; + +import React, { createContext, useContext, useState } from "react"; + +type SideViewCtx = { + sideView: string; + setSideView: React.Dispatch>; +}; + +const SideViewContext = createContext(null); + +export function SideViewProvider({ children, initialType = "",}: { children: React.ReactNode; initialType?: string; }) { + const [sideView, setSideView] = useState(initialType); + + return ( + + {children} + + ); +} + +export function useSideView() { + const ctx = useContext(SideViewContext); + if (!ctx) throw new Error("useString must be used within "); + return ctx; +} \ No newline at end of file diff --git a/labman/src/components/core/CardList.tsx b/labman/src/components/core/CardList.tsx index 50cd954..62675bd 100644 --- a/labman/src/components/core/CardList.tsx +++ b/labman/src/components/core/CardList.tsx @@ -6,6 +6,7 @@ import {UserClass} from "@/types/User"; import {returnLoan, deleteLoan, deleteUser} from "@/lib/actions"; import {LoanClass} from "@/types/Loan"; import EditLoan from "@/components/loans/EditLoan"; +import {useSideView} from "@/app/sideViewContext"; type Loans = { @@ -52,9 +53,10 @@ export default function CardList({ loansProp = [], usersProp = []}: CardListProp const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); - const [sideView, setSideView] = useState(""); const [selectedLoanId, setSelectedLoanId] = useState(null); + const { sideView, setSideView } = useSideView(); + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -125,7 +127,6 @@ export default function CardList({ loansProp = [], usersProp = []}: CardListProp
{ sideView == "loanEdit" && selectedLoanId && loan.id === selectedLoanId)!} - setSideView={setSideView} setLoans={setLoans} />} diff --git a/labman/src/components/core/NavBar.tsx b/labman/src/components/core/NavBar.tsx index c0f2ae7..127d9bf 100644 --- a/labman/src/components/core/NavBar.tsx +++ b/labman/src/components/core/NavBar.tsx @@ -1,5 +1,4 @@ "use client" -import Button from "@/components/core/Button"; import PathName from "@/components/core/PathName"; import {logout} from "@/lib/actions"; diff --git a/labman/src/components/core/SideView.tsx b/labman/src/components/core/SideView.tsx new file mode 100644 index 0000000..234842d --- /dev/null +++ b/labman/src/components/core/SideView.tsx @@ -0,0 +1,108 @@ +import {JSX} from "react"; +import {useSideView} from "@/app/sideViewContext"; + + +export default function SideView( {children}: {children: JSX.Element}) { + const {sideView, setSideView} = useSideView(); + return ( + <> + {/* Dark backdrop */} +
setSideView("")} + /> + + {/* Right-side panel */} +
+ + {/* Vertical split */} +
+ {/* Left side of a panel */} +
+

Title

+
+ {/* + + + */} +
+ +
+ {children} + {/* +
+ + + + + + + + + + +
*/} +
+ --------------------------------------------------------------------------------------- +

Items

+ {/*
+
+ {equipmentData.items.map((unit, index) => ( + hasActiveLoan = unit.activeLoan != null, +
+

Unit {index + 1}

+ { unit.activeLoan && (unit.activeLoan.status !== "Returned") &&

Borrowed

} + { (unit.activeLoan == null || unit.activeLoan.status === "Returned") && } + +
+ )) } +
+
*/} +
+ + {/* Right side of panel */} +
+
+ +
+
+ {/*
+

{equipmentData.name}

+

{equipmentData.category.name}

+

{equipmentData.items.length - loanCount(equipmentData)}/{equipmentData.items.length} Available

+
*/} + ---------------------------------------------------------------------------------- +

History

+
+
+
+
+ + ) +} \ No newline at end of file diff --git a/labman/src/components/loans/EditLoan.tsx b/labman/src/components/loans/EditLoan.tsx index e14a3ba..c7e9c5b 100644 --- a/labman/src/components/loans/EditLoan.tsx +++ b/labman/src/components/loans/EditLoan.tsx @@ -4,6 +4,7 @@ import {loanCount} from "@/utils/inventoryUtils"; import {useEffect, useState} from "react"; import {updateLoan} from "@/lib/actions"; import {Loan} from "@/types/Loan"; +import SideView from "@/components/core/SideView"; type Borrower = { @@ -15,11 +16,10 @@ type Borrower = { interface EditLoanProps { loan: Loan; - setSideView: (view: string) => void; setLoans: React.Dispatch>; } -export default function EditLoan({loan, setSideView, setLoans}: EditLoanProps) { +export default function EditLoan({loan, setLoans}: EditLoanProps) { const [initialFormData, setInitialFormData] = useState({borrower: loan.borrower.name, startDate: loan.startDate, endDate: loan.endDate, borrowerPhone: loan.borrower.phone, borrowerMail: loan.borrower.email}) const [formData, setFormData] = useState(initialFormData); @@ -34,7 +34,6 @@ export default function EditLoan({loan, setSideView, setLoans}: EditLoanProps) { .then(data => setBorrowers(data)) }, [initialFormData]); - async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -70,161 +69,99 @@ export default function EditLoan({loan, setSideView, setLoans}: EditLoanProps) { prev.map(loan => loan.id === updatedLoan.id ? updatedLoan : loan) ) - setInitialFormData(formData); - - - } return( <> - {/* Dark backdrop */} -
setSideView("")} - /> - - {/* Right-side panel */} -
- - {/* Vertical split */} -
- {/* Left side of a panel */} -
-

Edit loan

-
- - - -
- -
-
- - { - const borrower = borrowers.find(borrower => borrower.name === e.target.value); - // TODO: Better to use functional updates because React hasn't re-rendered the component yet and should not do two state changes at once due to batching - setFormData(prev => ({ - ...prev, - borrower: e.target.value, - borrowerPhone: borrower?.phone || "", - borrowerMail: borrower?.email || "" - })) - }} - /> - - {borrowers.map(borrower => )} - - - { - const selected = e.target.value; - setFormData({...formData, startDate: new Date(selected)}) - }} - /> - - { - const selected = e.target.value; - if (selected < formData.startDate.toLocaleDateString()) return; - setFormData({...formData, endDate: new Date(selected)}); - - }} - /> - - { - e.target.setCustomValidity(""); - if (e.target.value.length === 8) { - if (borrowers.find(borrower => borrower.phone === e.target.value && borrower.id !== loan.borrower.id)) { - e.target.setCustomValidity("A borrower with the same phone number already exists"); - e.target.reportValidity(); - } - } - setFormData({...formData, borrowerPhone: e.target.value}) - - }} - /> - - { - e.target.setCustomValidity(""); - if (e.target.checkValidity()) { - if (borrowers.find(borrower => borrower.email === e.target.value && borrower.id !== loan.borrower.id)) { - e.target.setCustomValidity("A borrower with the same email already exists"); - e.target.reportValidity(); - } - } - setFormData({...formData, borrowerMail: e.target.value}) - }} - /> -
-
- --------------------------------------------------------------------------------------- -

Items

-
-
- {/*equipmentData.items.map((unit, index) => ( - hasActiveLoan = unit.activeLoan != null, -
-

Unit {index + 1}

- { unit.activeLoan && (unit.activeLoan.status !== "Returned") &&

Borrowed

} - { (unit.activeLoan == null || unit.activeLoan.status === "Returned") && } - -
- )) */} -
-
-
- - {/* Right side of panel */} -
-
- -
-
- {/*
-

{equipmentData.name}

-

{equipmentData.category.name}

-

{equipmentData.items.length - loanCount(equipmentData)}/{equipmentData.items.length} Available

-
*/} - ---------------------------------------------------------------------------------- -

History

-
-
-
-
+ +
+ + { + const borrower = borrowers.find(borrower => borrower.name === e.target.value); + // TODO: Better to use functional updates because React hasn't re-rendered the component yet and should not do two state changes at once due to batching + setFormData(prev => ({ + ...prev, + borrower: e.target.value, + borrowerPhone: borrower?.phone || "", + borrowerMail: borrower?.email || "" + })) + }} + /> + + {borrowers.map(borrower => )} + + + { + const selected = e.target.value; + setFormData({...formData, startDate: new Date(selected)}) + }} + /> + + { + const selected = e.target.value; + if (selected < formData.startDate.toLocaleDateString()) return; + setFormData({...formData, endDate: new Date(selected)}); + + }} + /> + + { + e.target.setCustomValidity(""); + if (e.target.value.length === 8) { + if (borrowers.find(borrower => borrower.phone === e.target.value && borrower.id !== loan.borrower.id)) { + e.target.setCustomValidity("A borrower with the same phone number already exists"); + e.target.reportValidity(); + } + } + setFormData({...formData, borrowerPhone: e.target.value}) + + }} + /> + + { + e.target.setCustomValidity(""); + if (e.target.checkValidity()) { + if (borrowers.find(borrower => borrower.email === e.target.value && borrower.id !== loan.borrower.id)) { + e.target.setCustomValidity("A borrower with the same email already exists"); + e.target.reportValidity(); + } + } + setFormData({...formData, borrowerMail: e.target.value}) + }} + /> +
+
) } \ No newline at end of file From e1246ed22b8aa019818160f7a4d7518045ac65fd Mon Sep 17 00:00:00 2001 From: OlaBekkevold Date: Thu, 26 Feb 2026 22:36:18 +0100 Subject: [PATCH 24/61] feat: mostly complete refactor of loan edit Refs: #52 --- .../core/{ => SideView}/SideView.tsx | 4 +- labman/src/components/loans/EditLoan.tsx | 187 +++++++++--------- 2 files changed, 101 insertions(+), 90 deletions(-) rename labman/src/components/core/{ => SideView}/SideView.tsx (97%) diff --git a/labman/src/components/core/SideView.tsx b/labman/src/components/core/SideView/SideView.tsx similarity index 97% rename from labman/src/components/core/SideView.tsx rename to labman/src/components/core/SideView/SideView.tsx index 234842d..33c77cb 100644 --- a/labman/src/components/core/SideView.tsx +++ b/labman/src/components/core/SideView/SideView.tsx @@ -19,7 +19,7 @@ export default function SideView( {children}: {children: JSX.Element}) {
{/* Left side of a panel */}
-

Title

+

{sideView === "loanEdit" ? "Edit loan" : "Unknown"}

{/* @@ -89,7 +89,7 @@ export default function SideView( {children}: {children: JSX.Element}) { {/* Right side of panel */}
- +
{/*
diff --git a/labman/src/components/loans/EditLoan.tsx b/labman/src/components/loans/EditLoan.tsx index c7e9c5b..f6a42ce 100644 --- a/labman/src/components/loans/EditLoan.tsx +++ b/labman/src/components/loans/EditLoan.tsx @@ -1,10 +1,9 @@ "use client" -import {loanCount} from "@/utils/inventoryUtils"; import {useEffect, useState} from "react"; import {updateLoan} from "@/lib/actions"; import {Loan} from "@/types/Loan"; -import SideView from "@/components/core/SideView"; +import SideView from "@/components/core/SideView/SideView"; type Borrower = { @@ -75,92 +74,104 @@ export default function EditLoan({loan, setLoans}: EditLoanProps) { return( <> -
- - { - const borrower = borrowers.find(borrower => borrower.name === e.target.value); - // TODO: Better to use functional updates because React hasn't re-rendered the component yet and should not do two state changes at once due to batching - setFormData(prev => ({ - ...prev, - borrower: e.target.value, - borrowerPhone: borrower?.phone || "", - borrowerMail: borrower?.email || "" - })) - }} - /> - - {borrowers.map(borrower => )} - - - { - const selected = e.target.value; - setFormData({...formData, startDate: new Date(selected)}) - }} - /> - - { - const selected = e.target.value; - if (selected < formData.startDate.toLocaleDateString()) return; - setFormData({...formData, endDate: new Date(selected)}); - - }} - /> - - { - e.target.setCustomValidity(""); - if (e.target.value.length === 8) { - if (borrowers.find(borrower => borrower.phone === e.target.value && borrower.id !== loan.borrower.id)) { - e.target.setCustomValidity("A borrower with the same phone number already exists"); - e.target.reportValidity(); - } - } - setFormData({...formData, borrowerPhone: e.target.value}) - - }} - /> - - { - e.target.setCustomValidity(""); - if (e.target.checkValidity()) { - if (borrowers.find(borrower => borrower.email === e.target.value && borrower.id !== loan.borrower.id)) { - e.target.setCustomValidity("A borrower with the same email already exists"); - e.target.reportValidity(); - } - } - setFormData({...formData, borrowerMail: e.target.value}) - }} - /> -
+ <> +
+ + + +
+ +
+ +
+ + { + const borrower = borrowers.find(borrower => borrower.name === e.target.value); + // TODO: Better to use functional updates because React hasn't re-rendered the component yet and should not do two state changes at once due to batching + setFormData(prev => ({ + ...prev, + borrower: e.target.value, + borrowerPhone: borrower?.phone || "", + borrowerMail: borrower?.email || "" + })) + }} + /> + + {borrowers.map(borrower => )} + + + { + const selected = e.target.value; + setFormData({...formData, startDate: new Date(selected)}) + }} + /> + + { + const selected = e.target.value; + if (selected < formData.startDate.toLocaleDateString()) return; + setFormData({...formData, endDate: new Date(selected)}); + + }} + /> + + { + e.target.setCustomValidity(""); + if (e.target.value.length === 8) { + if (borrowers.find(borrower => borrower.phone === e.target.value && borrower.id !== loan.borrower.id)) { + e.target.setCustomValidity("A borrower with the same phone number already exists"); + e.target.reportValidity(); + } + } + setFormData({...formData, borrowerPhone: e.target.value}) + + }} + /> + + { + e.target.setCustomValidity(""); + if (e.target.checkValidity()) { + if (borrowers.find(borrower => borrower.email === e.target.value && borrower.id !== loan.borrower.id)) { + e.target.setCustomValidity("A borrower with the same email already exists"); + e.target.reportValidity(); + } + } + setFormData({...formData, borrowerMail: e.target.value}) + }} + /> +
+
+ +
) From b9f7cce649ca5a0a6fe2bb85b2c81c9b11169b80 Mon Sep 17 00:00:00 2001 From: Ola Bekkevold Date: Fri, 27 Feb 2026 15:30:40 +0100 Subject: [PATCH 25/61] feat: enhance side view components and improve data handling for loans and equipment Refs: #52 --- labman/src/app/(main)/layout.tsx | 2 +- labman/src/app/(main)/loans/page.tsx | 17 +- labman/src/components/core/CardList.tsx | 7 +- .../src/components/core/SideView/ItemList.tsx | 29 +++ .../src/components/core/SideView/SideView.tsx | 16 +- .../components/inventory/EquipmentClient.tsx | 5 +- .../components/inventory/EquipmentInfo.tsx | 104 +++----- labman/src/components/inventory/LoanView.tsx | 225 +++++++----------- labman/src/components/loans/EditLoan.tsx | 2 +- labman/src/lib/actions.ts | 17 +- labman/src/types/Loan.ts | 22 +- 11 files changed, 199 insertions(+), 247 deletions(-) create mode 100644 labman/src/components/core/SideView/ItemList.tsx diff --git a/labman/src/app/(main)/layout.tsx b/labman/src/app/(main)/layout.tsx index 53b28b1..c3fd74b 100644 --- a/labman/src/app/(main)/layout.tsx +++ b/labman/src/app/(main)/layout.tsx @@ -29,7 +29,7 @@ export default async function RootLayout({ return ( - +