Skip to content

Latest commit

Β 

History

496 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Klapi

A web-based equipment loan management system for organizations. Browse inventory, request loans, and manage returns with automated email notifications.

Features

  • 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

Workflows

For Users

  1. Browse available equipment in the catalog
  2. Request a loan by selecting dates and items
  3. Receive an email confirmation right away β€” requests are accepted automatically, there is no approval queue to wait for
  4. Get automated reminders before pickup, and when a loan runs late
  5. Return items and view loan history

For Admins

  1. Manage equipment catalog (add, edit, remove items)
  2. Organize items by categories, locations, and boxes
  3. 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)
  4. Handle returns, including items dropped into a return box
  5. Manage user accounts and permissions

For Kiosk Mode

  • Self-service stations for quick item checkout and returns
  • Simplified interface for public access points

Tech Stack

UI & theming

  • 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 .dark overrides.
  • There is no tailwind.config.ts β€” Tailwind 4 is configured CSS-first. styles/globals.css does @import 'tailwindcss' and exposes the tokens through an @theme block, which is what makes bg-primary, text-muted-foreground etc. work. PostCSS wires it up via @tailwindcss/postcss in postcss.config.js. Dark mode is class-based β€” toggled by next-themes via the class attribute on <html>.
  • Toasts use sonner. Import toast from sonner and call toast.success(...), toast.error(...), toast.warning(...).
  • Creatable selects use the CreatableSelect wrapper in components/ui/creatable-select.tsx β€” it styles react-select's creatable via the classNames API so it respects dark mode and tokens without runtime theme juggling.

Development

  1. Install dependencies:
pnpm install
  1. Set up environment variables:
cp .env.example .env

The 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.

  1. Start local database:
docker-compose up -d
  1. Run migrations and seed data:
pnpm prisma migrate dev
pnpm prisma db seed
  1. Start development server (also boots the local SES mock automatically):
pnpm dev

Visit http://localhost:3000.

Useful scripts

  • pnpm dev β€” Next dev server + local SES mock
  • pnpm build β€” prisma migrate deploy + next build
  • pnpm start β€” Production server
  • pnpm type-check β€” tsc --noEmit
  • pnpm lint β€” ESLint (Next config)
  • pnpm test β€” Vitest against a disposable Postgres (docker-compose)
  • pnpm test:ci β€” Vitest without docker (expects DATABASE_URL already set)

Local Email Testing

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.ts

It renders every template with sample data β€” no app or database needed β€” to /tmp/klapi-emails/, with an index.html linking them all.

Database

Schema is defined in prisma/schema.prisma. After schema changes:

pnpm prisma migrate dev --name description_of_change

Generate test data:

pnpm prisma db seed

Entity-relationship diagram

The diagram below is generated from prisma/schema.prisma by prisma-erd-generator. Refresh it after a schema change with:

pnpm erd

That 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"
Loading

Authentication

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 (from GOOGLE_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=none is what actually removes the tap. hd alone 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=none asks 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.

Google Workspace user sync

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 /admin and LoanerAutocomplete before 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 β€” deletedAt is stamped, so their loans and loan history survive and lib/auth.ts refuses the login;
  • someone who comes back is restored, but only if the sync is the one that deactivated them. User.deletedBySync records that provenance: an admin who deletes a user by hand in /admin stays 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:

  1. Create a service account in the project that owns the Klapi OAuth client and download a JSON key.
  2. 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.readonly and https://www.googleapis.com/auth/admin.directory.group.member.readonly.
  3. Set GOOGLE_WORKSPACE_SA_KEY (base64 of the JSON key), GOOGLE_WORKSPACE_SUBJECT, GOOGLE_WORKSPACE_DOMAIN and GOOGLE_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.

Merging duplicate accounts

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 --apply

Its 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: CUSTOMER member ("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-running pitva-calendar-sync.sh before they get a Klapi account.

Loans on the shared calendar

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.fi

Leave 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.

Hosting

Production Deployment

Klapi is deployed automatically when a new commit lands on main.

Environment Variables

See .env.example for the full list with comments. The ones a deployment cannot run without:

  • DATABASE_URL: PostgreSQL connection string
  • NEXTAUTH_SECRET: Random secret for Auth.js (generate with openssl rand -base64 32). v5 prefers AUTH_SECRET and falls back to this name, so the deployed variable did not have to be renamed.
  • NEXTAUTH_URL: Public URL of your deployment
  • GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET: Google OAuth credentials
  • KLAPI_AWS_REGION, KLAPI_AWS_ACCESS_KEY_ID, KLAPI_AWS_SECRET_ACCESS_KEY: AWS credentials for SES and S3 (prefixed because Vercel reserves AWS_*)
  • AWS_SES_FROM_EMAIL: Sender address for all notification email
  • AWS_BUCKET_NAME, NEXT_PUBLIC_AWS_ITEM_PHOTOS_URL: S3 bucket for item photos, and its public URL
  • CRON_SECRET: Shared secret the /api/cron/* routes require as Authorization: Bearer …. Without it the nightly reminder/overdue/auto-start jobs return 401 and silently stop working.

Project layout

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

Releases

Packages

Used by

Contributors

Languages