Competition luggage management system for robotics teams.
Packing for an international robotics competition with 30+ people, 5 oversized boxes, dozens of batteries and a flight that won't take any of them in the wrong category — that's how this started. We were tracking everything in a Notion table that nobody updated. Packman replaces that with a single source of truth, scannable QR codes on every box, AI-tagged photos so you can find anything by what it looks like, and per-event scoping so the same system works year after year.
- Items — Add, edit, search by name/tag, filter by group/box/status/shipping
- Boxes — Organize items into checked / carry-on, scannable QR sticker per box
- Batteries — Per-battery assignment with Taiwan CAA + French DGAC reminders
- AI auto-tagging — Snap a photo, local Ollama vision model writes the tags
- QR scan-on-site — Open
/scanon phone, point at sticker, jump to checklist - Sticker printing — PDF stickers in 4 sizes (50×30 mm to A4 sheet)
- Slack SSO — Sign in with your team's private Slack workspace
- Per-event scoping — One DB, many events (Eurobot 2025 / 2026 / …); active event switch, optional per-event member list to gate sign-in and shorten owner pickers
- Full backup — Database + photos exported as a single ZIP, restorable in one click
- Localized UI — English / 繁體中文 with Light / Dark / System theme; dark mode by default
— Made by SCX, originally for the DIT Robotics team.
- Docker and Docker Compose
- A Slack workspace (for SSO login)
- (Optional) A machine running Ollama with a vision model (
llavaetc.) for AI tagging
-
Clone:
git clone https://github.com/SeanChangX/packman.git cd packman -
Copy the example env and edit infra-only fields:
cp .env.example .env
Slack OAuth / app URLs / admin account / Ollama servers / branding are all configured in the admin UI, not in
.env. JWT and cookie secrets are auto-generated on first startup. -
Pull pre-built production images and start:
docker compose pull docker compose up -d
Or build locally:
docker compose -f docker-compose.dev.yml up --build
-
Set the admin password first. Open http://localhost:3001 (admin panel). On first visit it shows a setup form — choose username + password.
-
Use the app:
- http://localhost:3000 — Main app (members)
- http://localhost:3001 — Admin panel
- http://localhost:9001 — MinIO console (photo storage)
- https://api.slack.com/apps → Create New App → From scratch
- Name
Packman, pick your workspace - User Token Scopes:
identity.basic,identity.email,identity.avatar - In Admin → Settings, set the Web URL to your public address (e.g.
https://packman.example.com). The Slack Redirect URI is auto-derived as${WEB_URL}/auth/slack/callbackand shown read-only in the Slack section — copy it into your Slack app's OAuth & Permissions → Redirect URLs. - Copy Client ID + Client Secret + Workspace ID from Slack into Admin → Settings → Slack.
┌─ Web nginx (3000) ─┐ proxies /api, /auth
[ Browser ] ────┤ ├──→ [ API (internal :8080) ] ─── [ Postgres (internal) ]
└─ Admin nginx (3001)┘ └─ [ MinIO (internal :9000) ]
└─ [ Ollama (vision LLM) ]
In production, only the Web (3000) and Admin (3001) nginx containers are reachable from outside the Docker network. API, Postgres, and MinIO have no published ports — the SPAs call /api/* and /auth/* on their own origin and nginx reverse-proxies to the API. This keeps the attack surface to the two SPA front-ends.
| Service | Public port (prod) | Stack |
|---|---|---|
| Web | 3000 | React + Vite + TanStack Router/Query |
| Admin | 3001 | React + Vite (separate SPA) |
| API | — (internal :8080) | Node.js + Fastify + Prisma + TypeScript |
| Postgres | — (internal) | Schema with per-event scoping, GIN/trigram indexes |
| MinIO | console on 127.0.0.1:9001 | S3-compatible photo storage (S3 API internal :9000) |
Admin URL note. The Admin URL configured in Admin → Settings is added to the API's CORS allowlist. With the default same-origin reverse-proxy deployment (admin nginx serves the SPA and proxies
/api/*to the API on the same host:port), CORS doesn't trigger for day-to-day admin use, so a mismatched Admin URL won't lock you out. The setting matters in cross-origin deployments — if you serve the admin SPA from a different host than the API, the value must match the admin origin or the browser will CORS-reject the requests. Either way, exposing port3001only on a private network or behind a VPN is recommended.
Docker images are built via GitHub Actions when a v* tag is pushed (or the workflow is dispatched manually) and pushed to ghcr.io/seanchangx/packman-{api,web,admin} with latest and semver tags.
- Print stickers — Main app → Stickers → choose size and items/boxes → PDF (single-box stickers also available from any box detail page)
- Stick before packing — Each sticker has a QR code pointing at the item/box page
- Scan on-site — Open
/scanon phone, point at a box sticker, get the checklist - Tick off items — Mark each item PACKED / SEALED as you load it
| Sticker size | Dimensions | Use case |
|---|---|---|
| SMALL | 50 × 30 mm | Small parts, individual items |
| MEDIUM | 100 × 50 mm | Standard item labels |
| LARGE | 150 × 100 mm | Box ID labels |
| A4_SHEET | A4, 2 × 4 grid | 8 stickers per page |
Stickers include the brand logo (configurable in Admin → Settings → Brand) and embed a QR code that opens the item or box detail page on any phone.
Optional Ollama integration. Upload an item photo → background worker calls a local Ollama vision model → tags appear on the item record.
- Set up one or more Ollama endpoints in Admin → AI Recognition
- Pick a vision model (e.g.
llava,llama3.2-vision) - Customize the tagging prompt (default works for general gear)
- Health checks + latency stats per endpoint, automatic failover
Photos and tags are searchable from the items page. Re-tag any photo manually from its detail page.
One Packman instance can run multiple events. Items, boxes, and batteries are scoped to the active event; switching events instantly changes what users see, without touching the data.
- Admin → Events → create / rename / activate / delete
- Same box label (e.g. "1") can exist in different events without conflict
- Backups capture all events at once
Each event can be restricted to a subset of users via Admin → Events → Members. When at least one member is selected:
- Owner pickers across the web app only show event members (faster to scan with large rosters)
- Slack sign-in is rejected for non-members with a clear error on the login page
- Existing live sessions of removed users get cleared on their next request
Events with no members configured are unrestricted (all users available, all may sign in).
Admin → Export → Backup & Restore:
- Downloads
packman-backup-YYYY-MM-DD.zipcontaining:data.json— all events, items, boxes, batteries, users, groups, event memberships, options, regulations, settings, Ollama endpointsphotos/— every photo in MinIO (item photos + brand logo)
- Restore uploads the same ZIP back: wipes current data inside a transaction, then re-imports everything including photos
- Validates ZIP magic bytes, version (1.x), required fields, and only allows whitelisted photo extensions (jpg/png/webp/gif/heic)
docker compose -f docker-compose.dev.yml up --build| Service | URL |
|---|---|
| Web (Vite HMR) | http://localhost:3000 |
| Admin (Vite HMR) | http://localhost:3001 |
| API | http://localhost:8080 |
| MinIO console | http://localhost:9001 |
Changes to apps/web/src/ and apps/admin/src/ reload instantly. Rebuild only when adding packages or changing packages/shared.
# Logs
docker compose logs -f api
# Open a Prisma migration shell
docker compose exec api npx prisma migrate dev --name my_change
# Browse the DB
docker compose exec api npx prisma studio --port 5555 --browser none
# → http://localhost:5555
# Re-seed
docker compose exec api node dist/seed.jsPushing a v* tag triggers a GitHub Actions build that pushes 3 images to GHCR with latest + semver tags. The workflow can also be run manually via workflow_dispatch:
git tag v1.0.0 && git push --tags
# Server side:
IMAGE_TAG=v1.0.0 docker compose up -dThere is no built-in password-reset flow (no email, no reset link). The admin credentials live in the SystemSetting table; deleting those rows puts the admin console back into "first-time setup" mode and you can choose a new username + password.
# 1. Back up the DB first
docker compose exec -T postgres pg_dump -U "${POSTGRES_USER:-packman}" "${POSTGRES_DB:-packman}" > pre-reset-backup.sql
# 2. Drop the admin credentials
docker compose exec -T postgres psql -U "${POSTGRES_USER:-packman}" -d "${POSTGRES_DB:-packman}" \
-c "DELETE FROM \"SystemSetting\" WHERE key IN ('admin.username', 'admin.passwordHash') RETURNING key;"
# 3. Reload the admin URL — it now shows the setup form againThis project is licensed under the MIT License - see the LICENSE file for details.






