A web-based equipment loan management system for organizations. Browse inventory, request loans, and manage returns with automated email notifications.
- Browse equipment catalog with search and filtering
- Request loans with flexible date ranges
- Automated email notifications for loan reminders and updates
- Admin dashboard for managing inventory, locations, and loans
- Multi-user support with role-based permissions (Admin, User, Kiosk)
- Support for normal and temporary items
- Organized inventory with categories, locations, and boxes
- Browse available equipment in the catalog
- Request a loan by selecting dates and items
- Receive an email confirmation right away β requests are accepted automatically, there is no approval queue to wait for
- Get automated reminders before pickup, and when a loan runs late
- Return items and view loan history
- Manage equipment catalog (add, edit, remove items)
- Organize items by categories, locations, and boxes
- Track all active and past loans; reject or cancel ones that shouldn't run (rejecting is after the fact β see the note on automatic acceptance above)
- Handle returns, including items dropped into a return box
- Manage user accounts and permissions
- Self-service stations for quick item checkout and returns
- Simplified interface for public access points
- Next.js 16 (App Router) β React framework
- React 19 with TypeScript (strict)
- Prisma 7 β Database ORM
- PostgreSQL β Database
- Auth.js / next-auth v5 β Authentication
- Tailwind CSS 4 β Utility-first styling (CSS-first config)
- shadcn/ui β Component primitives (owned in
components/ui/) - Radix UI β Unstyled accessible primitives (Dialog, Switch, Tooltip, Label)
- next-themes β Dark mode (class-based)
- sonner β Toast notifications
- react-select β Creatable/multi selects (shadcn-styled wrapper)
- lucide-react β Icons
- SWR β Client-side data fetching
- AWS SES β Email notifications
- All UI primitives live in
components/ui/β they're source files you own and edit freely (shadcn pattern, not an installed library). - Design tokens are CSS variables in
styles/globals.css. Colors map to HSL vars (--primary,--background,--card,--destructive,--success,--warning, etc.) with.darkoverrides. - There is no
tailwind.config.tsβ Tailwind 4 is configured CSS-first.styles/globals.cssdoes@import 'tailwindcss'and exposes the tokens through an@themeblock, which is what makesbg-primary,text-muted-foregroundetc. work. PostCSS wires it up via@tailwindcss/postcssinpostcss.config.js. Dark mode is class-based β toggled bynext-themesvia theclassattribute on<html>. - Toasts use
sonner. Importtoastfromsonnerand calltoast.success(...),toast.error(...),toast.warning(...). - Creatable selects use the
CreatableSelectwrapper incomponents/ui/creatable-select.tsxβ it stylesreact-select's creatable via theclassNamesAPI so it respects dark mode and tokens without runtime theme juggling.
- Install dependencies:
pnpm install- Set up environment variables:
cp .env.example .envThe defaults in .env.example match docker-compose.yml, so the database
works out of the box; the AWS and Google values are only needed for real email,
photo uploads, and Google sign-in.
- Start local database:
docker-compose up -d- Run migrations and seed data:
pnpm prisma migrate dev
pnpm prisma db seed- Start development server (also boots the local SES mock automatically):
pnpm devVisit http://localhost:3000.
pnpm devβ Next dev server + local SES mockpnpm buildβprisma migrate deploy+next buildpnpm startβ Production serverpnpm type-checkβtsc --noEmitpnpm lintβ ESLint (Next config)pnpm testβ Vitest against a disposable Postgres (docker-compose)pnpm test:ciβ Vitest without docker (expectsDATABASE_URLalready set)
The dev script starts aws-ses-v2-local in parallel. All emails are captured instead of being sent.
Open the email viewer at http://localhost:8005 to see sent emails.
To review the templates without triggering the flows that send them:
pnpm tsx scripts/preview-emails.tsIt renders every template with sample data β no app or database needed β to
/tmp/klapi-emails/, with an index.html linking them all.
Schema is defined in prisma/schema.prisma. After schema changes:
pnpm prisma migrate dev --name description_of_changeGenerate test data:
pnpm prisma db seedThe diagram below is generated from prisma/schema.prisma
by prisma-erd-generator. Refresh it after a schema change with:
pnpm erdThat regenerates the mermaid source into .erd.md (gitignored) and splices it
between the markers below. Markdown output is pure text generation, so it costs
~25 ms β the .pdf/.svg/.png output modes are the expensive ones, because
they rasterize through a headless Chrome that puppeteer has to download first.
Don't switch the generator's output to one of those.
erDiagram
ItemHistoryAction {
CREATED CREATED
UPDATED UPDATED
ARCHIVED ARCHIVED
RESTORED RESTORED
PROMOTED PROMOTED
}
AnnouncementKind {
KORJATTAVAA KORJATTAVAA
TIEDOKSI TIEDOKSI
}
LoanHistoryAction {
CREATED CREATED
UPDATED UPDATED
APPROVED APPROVED
REJECTED REJECTED
CANCELLED CANCELLED
STARTED STARTED
RETURNED_TO_BOX RETURNED_TO_BOX
PROCESSED_FROM_BOX PROCESSED_FROM_BOX
}
Group {
ADMIN ADMIN
USER USER
KIOSK KIOSK
}
ItemType {
normal normal
temporary temporary
}
ReportStatus {
OPEN OPEN
IN_PROGRESS IN_PROGRESS
RESOLVED RESOLVED
}
ReportCreated {
BEFORE_LOAN BEFORE_LOAN
AFTER_LOAN AFTER_LOAN
}
LoanStatus {
ACCEPTED ACCEPTED
REJECTED REJECTED
CANCELLED CANCELLED
INUSE INUSE
IN_BOX IN_BOX
PARTIALLY_RETURNED PARTIALLY_RETURNED
RETURNED RETURNED
}
ReservationStatus {
ACCEPTED ACCEPTED
REJECTED REJECTED
INUSE INUSE
IN_BOX IN_BOX
RETURNED RETURNED
}
EmailType {
EXPIRING_LOAN_REMINDER EXPIRING_LOAN_REMINDER
PICKUP_REMINDER PICKUP_REMINDER
PICKUP_OVERDUE_REMINDER PICKUP_OVERDUE_REMINDER
OVERDUE_USER_REMINDER OVERDUE_USER_REMINDER
OVERDUE_ADMIN_NOTIFICATION OVERDUE_ADMIN_NOTIFICATION
OLD_BOX_ADMIN_NOTIFICATION OLD_BOX_ADMIN_NOTIFICATION
}
"Account" {
String id "ποΈ"
String type
String provider
String providerAccountId
String refresh_token "β"
String access_token "β"
Int expires_at "β"
String token_type "β"
String scope "β"
String id_token "β"
String session_state "β"
}
"Session" {
String id "ποΈ"
String sessionToken
DateTime expires
}
"User" {
String id "ποΈ"
String name "β"
String email "β"
DateTime emailVerified "β"
String image "β"
DateTime deletedAt "β"
Group group
String password "β"
DateTime passwordExpiresAt "β"
String kioskPasswordEnc "β"
String kioskElevatePin "β"
String username "β"
Boolean emailNewLoanNotification
Boolean emailWeeklyReminder
Boolean emailExpiringReminder
Boolean emailOldBoxNotification
Boolean emailOverdueNotification
}
"Item" {
String id "ποΈ"
String name
String description "β"
Int amount
ItemType type
DateTime deletedAt "β"
}
"Template" {
String id "ποΈ"
String name
String description "β"
DateTime createdAt
DateTime updatedAt
}
"TemplateItem" {
String id "ποΈ"
Int amount
}
"ItemHistory" {
String id "ποΈ"
ItemHistoryAction action
Json details "β"
DateTime createdAt
}
"Announcement" {
String id "ποΈ"
String message
AnnouncementKind kind
DateTime createdAt
DateTime expiresAt "β"
}
"Location" {
String name
String description "β"
String id "ποΈ"
}
"Box" {
String id "ποΈ"
String name
String description "β"
DateTime createdAt
DateTime updatedAt
}
"Category" {
String id "ποΈ"
String name
String description "β"
}
"Reservation" {
String id "ποΈ"
Int amount
ReservationStatus status
}
"Loan" {
String id "ποΈ"
LoanStatus status
DateTime startTime
DateTime endTime
String description "β"
String loaner "β"
}
"LoanHistory" {
String id "ποΈ"
LoanHistoryAction action
Json details "β"
DateTime createdAt
}
"Report" {
String id "ποΈ"
String content
ReportStatus status
DateTime createdAt
ReportCreated created
}
"ReportAffectedItem" {
String id "ποΈ"
Int amount
}
"EmailLog" {
String id "ποΈ"
EmailType emailType
DateTime sentAt
}
"Account" }o--|| "User" : "user"
"Session" }o--|| "User" : "user"
"User" |o--|| "Group" : "enum:group"
"Item" |o--|| "ItemType" : "enum:type"
"Item" }o--|o "Location" : "location"
"Item" o{--}o "Category" : ""
"TemplateItem" }o--|| "Template" : "template"
"TemplateItem" }o--|| "Item" : "item"
"ItemHistory" |o--|| "ItemHistoryAction" : "enum:action"
"ItemHistory" }o--|| "Item" : "item"
"ItemHistory" }o--|o "User" : "actedBy"
"Announcement" |o--|| "AnnouncementKind" : "enum:kind"
"Announcement" }o--|o "Item" : "item"
"Announcement" }o--|o "Report" : "report"
"Reservation" |o--|| "ReservationStatus" : "enum:status"
"Reservation" }o--|| "Item" : "item"
"Reservation" }o--|| "Loan" : "loan"
"Loan" |o--|| "LoanStatus" : "enum:status"
"Loan" }o--|o "Box" : "box"
"Loan" }o--|| "User" : "user"
"LoanHistory" |o--|| "LoanHistoryAction" : "enum:action"
"LoanHistory" }o--|| "Loan" : "loan"
"LoanHistory" }o--|o "User" : "actedBy"
"Report" |o--|| "ReportStatus" : "enum:status"
"Report" |o--|| "ReportCreated" : "enum:created"
"Report" }o--|| "Loan" : "loan"
"ReportAffectedItem" }o--|| "Report" : "report"
"ReportAffectedItem" }o--|| "Item" : "item"
"EmailLog" |o--|| "EmailType" : "enum:emailType"
"EmailLog" }o--|| "Loan" : "loan"
"EmailLog" }o--|| "User" : "user"
Supports Google OAuth and username/password authentication via Auth.js (next-auth v5). lib/auth.ts holds the whole config and exports auth(), handlers, signIn and signOut; app/api/auth/[...nextauth]/route.ts is three lines re-exporting the handlers.
"Jatka Googlella" skips the account chooser. Two parameters do that, and only together:
hd(fromGOOGLE_WORKSPACE_DOMAIN) tells Google which domain the visitor belongs to, so its chooser is filtered to the troop account and a personal Gmail is out of the way. It is a hint, not a fence β Google still returns a Gmail account to anyone who picks "use another account", which is what keeps the pre-Workspace logins working (see Merging duplicate accounts). Blank turns it off.prompt=noneis what actually removes the tap.hdalone does not: Google shows the chooser even when exactly one account matches, so filtering the list to one entry still leaves the member tapping it.prompt=noneasks Google to finish silently and to answer with an error rather than render anything.
So the button tries silently first, and /login turns a bounce straight back
into an ordinary sign-in β the member still only clicked once. That fallback is
load-bearing: a silent attempt fails for everyone who is signed out, has never
consented, or has two accounts in the domain, and all of them must still get in.
utils/loginHelpers.ts holds the policy and the one-shot guard that keeps the
retry from looping; pages.signIn/pages.error in lib/auth.ts are what route
the bounce to /login in the first place.
User Roles:
- Admin: Full access to manage catalog, users, and loans
- User: Browse catalog, request loans, view own history
- Kiosk: Simplified interface for self-service stations
Admins can elevate a kiosk session to ADMIN temporarily via a 4-digit PIN (set in /admin). The elevated session auto-expires after 30 minutes.
The troop's roster lives in Google Workspace, so Klapi follows it rather than
keeping a second list by hand. /api/cron/syncWorkspaceUsers runs nightly (see
vercel.json) and reconciles the two:
- a member of the Workspace group with no Klapi account gets one, so the
whole troop is pickable in
/adminandLoanerAutocompletebefore they have ever logged in; - a name that changed in the directory is refreshed;
- someone deleted, suspended, archived, or removed from the group is
soft-deleted β
deletedAtis stamped, so their loans and loan history survive andlib/auth.tsrefuses the login; - someone who comes back is restored, but only if the sync is the one that
deactivated them.
User.deletedBySyncrecords that provenance: an admin who deletes a user by hand in/adminstays deleted, instead of being resurrected the same night.
GOOGLE_WORKSPACE_EXCLUDE drops the robot accounts (admin@, pitvadev@)
out of scope entirely β neither provisioned nor deactivated. They need it
because the member group carries a whole-organisation member, which puts every
domain user in the group whether or not they were added by hand.
Only accounts Workspace can plausibly own are in scope β a @$GOOGLE_WORKSPACE_DOMAIN
email and a group other than KIOSK. The local admin account, the shared kiosk
terminal and anyone signed in with a personal Gmail are invisible to it.
Three guards fence the destructive half: an empty roster aborts the run, a run
that would deactivate more than WORKSPACE_SYNC_MAX_DEACTIVATIONS accounts
aborts before writing anything (HTTP 409), and the last live ADMIN is never
deactivated.
Setup. The cron authenticates as a service account with domain-wide delegation β the only Google auth flow that works unattended:
- Create a service account in the project that owns the Klapi OAuth client and download a JSON key.
- In Admin console β Security β API controls β Domain-wide delegation, add
the service account's client id (the numeric
uniqueId, not the email) with exactly these scopes:https://www.googleapis.com/auth/admin.directory.user.readonlyandhttps://www.googleapis.com/auth/admin.directory.group.member.readonly. - Set
GOOGLE_WORKSPACE_SA_KEY(base64 of the JSON key),GOOGLE_WORKSPACE_SUBJECT,GOOGLE_WORKSPACE_DOMAINandGOOGLE_WORKSPACE_GROUPβ see.env.example.
Verify without writing anything:
curl -H "Authorization: Bearer $CRON_SECRET" \
"https://<host>/api/cron/syncWorkspaceUsers?dryRun=1"A unauthorized_client error from the token exchange means step 2 is missing or
the scopes don't match exactly.
Members who predate the sync may hold two Klapi accounts β one from a personal
Gmail, one under their work address β with loan history split across the pair.
scripts/merge-users.ts folds the personal one into the Workspace one, moving
every loan, email log and audit entry:
pnpm tsx scripts/merge-users.ts # dry run β reports, writes nothing
pnpm tsx scripts/merge-users.ts --applyIts pair list is keyed on email, not name, and was reviewed by hand β names
are free text and a surname heuristic merging the wrong two people is not
recoverable by looking at the data afterwards. The merged-away row is kept and
soft-deleted with mergedIntoId set, so that Google login is refused (rather
than silently creating a third account) and the merge stays reversible.
The member group carries a
type: CUSTOMERmember ("the whole organisation is in this group"). The API returns that entry rather than expanding it, so the sync reads it as every domain user is a member β which is what it means, and what keeps a brand-new member from waiting on someone re-runningpitva-calendar-sync.shbefore they get a Klapi account.
Every loan is mirrored onto one shared Google Calendar β "what is out of the varasto, and until when" β as a single event per loan, and the borrower is invited as a guest so the loan also shows up in their own calendar. The event carries the item list and a link back to the loan in Klapi, and is marked free rather than busy: a loan is not a meeting.
utils/loanCalendar owns it. Every route that creates, edits, cancels, rejects
or un-rejects a loan calls the same syncLoanCalendarInBackground(loanId),
which reconciles rather than commands β it reads the loan as it now stands and
makes the calendar agree, creating, updating or removing the event as needed.
Loan.calendarEventId is the link between the two. It runs in after(), so a
slow or broken Google never delays or fails the request that saved the loan; a
failure is logged and healed by the next edit.
Who gets invited as a guest:
- a live Workspace address (
@$GOOGLE_WORKSPACE_DOMAIN) β a personal Gmail login is never invited to a troop event, and the shared kiosk terminal's calendar is nobody's; - who hasn't turned "Lainat kalenteriisi" off on
/account(an admin can flip it for them on/admin/user/[userId]). That switch is only about the personal copy β the loan is on the shared calendar either way.
Setup. Unlike the user sync, this needs no Admin console change and no domain-wide delegation. The service account acts as itself and reaches exactly one calendar, because that calendar was shared with it.
The calendar already exists β "PitVa β Klapin lainat", owned by admin@,
Europe/Helsinki:
c_da0d0879ecccdd2ea46f3ff536a54caeb9b07b864ebc08488188ce7f077a21ca@group.calendar.google.com
klapi-workspace-sync@login-201416.iam.gserviceaccount.com is a writer on
it and the domain is a reader; the Calendar API is enabled in the
login-201416 project. Set that id as GOOGLE_CALENDAR_ID and the mirror is
live. ~/bin/pitva-calendar-sync.sh subscribes every member to it, alongside
the troop's other shared calendars.
To rebuild it from scratch (GAM, as a Workspace admin):
gam user admin@pitkajarvenvaeltajat.fi create calendar \
summary "PitVa β Klapin lainat" timezone Europe/Helsinki
gam user admin@pitkajarvenvaeltajat.fi add calendaracls <calId> \
writer user:klapi-workspace-sync@login-201416.iam.gserviceaccount.com
gam user admin@pitkajarvenvaeltajat.fi add calendaracls <calId> \
reader domain:pitkajarvenvaeltajat.fiLeave GOOGLE_CALENDAR_ID unset and the mirror switches off cleanly: loans save
exactly as before, they just get no events. That is also why local dev and the
test suite need no Google credentials.
Returning a loan early does not shorten its event β the event stands until the return date the loan was booked for. Cancelling or rejecting removes it.
Klapi is deployed automatically when a new commit lands on main.
See .env.example for the full list with comments. The ones a deployment cannot run without:
DATABASE_URL: PostgreSQL connection stringNEXTAUTH_SECRET: Random secret for Auth.js (generate withopenssl rand -base64 32). v5 prefersAUTH_SECRETand falls back to this name, so the deployed variable did not have to be renamed.NEXTAUTH_URL: Public URL of your deploymentGOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET: Google OAuth credentialsKLAPI_AWS_REGION,KLAPI_AWS_ACCESS_KEY_ID,KLAPI_AWS_SECRET_ACCESS_KEY: AWS credentials for SES and S3 (prefixed because Vercel reservesAWS_*)AWS_SES_FROM_EMAIL: Sender address for all notification emailAWS_BUCKET_NAME,NEXT_PUBLIC_AWS_ITEM_PHOTOS_URL: S3 bucket for item photos, and its public URLCRON_SECRET: Shared secret the/api/cron/*routes require asAuthorization: Bearer β¦. Without it the nightly reminder/overdue/auto-start jobs return 401 and silently stop working.
app/ App Router β pages (server components by default) and
app/api/ route handlers (`route.ts`), including the cron jobs
components/ App-specific React components
components/ui/ shadcn/ui primitives (owned source, edit freely)
contexts/ React contexts (cart, dates)
hooks/ Custom hooks
lib/ `cn()` className merger and the Auth.js config
styles/globals.css Tailwind import + `@theme` design tokens (light/dark)
types/ Shared types and the Auth.js session augmentation
utils/ Server and shared helpers (Prisma client, loan helpers, etc.)
prisma/ Schema, migrations, seed
scripts/ Operator scripts run with `tsx` (kiosk user, email previews)
__tests__/ Vitest suites β mostly API integration tests