Proliige ("pro member") is Lapikud's membership app, for working your way to becoming a member. People do club tasks, submit proof, and earn points when a reviewer approves it. Everyone can follow the feed of completed tasks, the leaderboard, and each person's page.
Built with Next.js, PostgreSQL (Drizzle), pg-boss background jobs, FreeIPA sign-in, Garage photo storage, Tailwind and shadcn/ui.
SPEC.md describes what the app does and the rules it keeps; this README is about working on the code.
You need Node 22.12+, pnpm and Docker.
pnpm install
cp .env.example .env
docker compose up -d postgres
pnpm garage:setup # writes garage.toml, starts Garage, prints a key for .env; run again after filling it
pnpm db:migrate
pnpm dev # http://localhost:3000.env.example lists every setting; set SITE_URL to the public address so link previews and the sitemap point at it. The app refuses to start while one is missing or invalid, and says which. .env and garage.toml hold secrets, so both are ignored by git and never archived; their templates are .env.example and garage.example.toml.
To try the app without FreeIPA, set AUTH_DRIVER=seed in .env and run pnpm db:seed. Every seed account signs in with the password seed. Seed sign-in is refused in production, and pnpm db:seed refuses non-local databases unless SEED_CONFIRM=1 is set.
| Account | Role |
|---|---|
seed-admin-1, seed-admin-2 |
Admin |
seed-pixel-1 to seed-pixel-8 |
Pixel |
seed-member-1, seed-member-2 |
Member |
seed-excluded |
Pixel left out of the leaderboard and feed |
seed-no-group |
No group |
| Command | What it does |
|---|---|
pnpm dev |
Run the app with hot reload |
pnpm test |
Run all tests (uses its own lapikud_test database) |
pnpm typecheck |
Check types |
pnpm lint |
Check code rules |
pnpm format |
Format everything with Prettier |
pnpm build |
Production build |
pnpm db:generate <name> |
Write a named migration after changing a model.ts |
pnpm db:migrate |
Apply migrations; stops if a model.ts change has no migration |
pnpm db:check |
Check that every model.ts change has a migration |
pnpm db:seed |
Fill a local database with seed accounts, tasks and posts |
pnpm garage:setup [app-origin] |
Prepare Garage: config, layout, bucket, key, CORS |
Before opening a pull request, run pnpm format && pnpm lint && pnpm typecheck && pnpm test. CI runs the same checks, plus a build, on every pull request.
The same words are used in the code, the database and the screens.
| Word | Meaning |
|---|---|
| User | Anyone who signed in with a FreeIPA account |
| Role | What a user may do, derived from their FreeIPA groups: member, pixel or admin |
| Member | A user in the members group; likes, comments and suggests tasks |
| Pixel | A user in the pixels group; does tasks, and can do everything a member can |
| Admin | A user in an admin group (team_juhatus); reviews proofs and manages tasks, never submits |
| Task | Something to do for the club, worth points, in a category |
| Suggestion | A task a member or pixel proposes; it stays off the task list until an admin publishes it |
| Own task | A task a pixel adds for work they already did, sent with its proof; a reviewer sets points |
| Policy | How often a task can be done: once per user, repeatable, first one wins |
| Proof | What a pixel submits to show they did a task, often with photos |
| Reviewer | An admin asked to review a proof, like a reviewer on a pull request |
| Review | Approving or rejecting a proof; any admin may review any proof |
| Points | Awarded when a proof is approved or a contribution is recognized, in the points ledger |
| Recognition | Points an admin gives a pixel for a contribution with no task or proof, shown in the feed; admins can edit it, remove it from the feed or take the points back |
| Badge | A reusable label, with an optional image, that an admin can attach to a recognition |
| Feed | Approved proofs and recognitions, newest first; proofs have likes and comments |
| Report | A signed-in user flags a post on the feed with a reason; admins see it on the review page |
| Unapprove | An admin takes back an approved proof: it leaves the feed and its points are reversed |
| Leaderboard | Users ranked by points, all time or this month |
| Rule | A yes/no check of what a user may do, such as userCanSubmitProof |
Who can do what:
| Who | Can |
|---|---|
| Visitor | Read the feed, the leaderboard and user pages |
| Signed in, no group | Also like, comment, report posts, and get notified of replies |
| Member | Also suggest tasks |
| Pixel | Also browse tasks, submit proofs, request reviewers and add own tasks |
| Admin | Review proofs, manage tasks and categories, recognize pixels, remove or unapprove posts; never submits proofs |
A proof with requested reviewers notifies them; without any, nobody is notified and it simply waits in the review queue. Groups are set in src/config/access.ts. Every rule lives in src/domain/rules.ts.
Code depends inwards only: app and components use infra, infra implements core, and core builds on domain.
src/
domain/ Plain types and pure functions: what a proof, task or rule is. No database, no React.
core/
ports/ Interfaces the services need, such as ProofRepository or PushPort.
services/ One per resource. Each method is wrapped in guard(rule, …), so permissions are never skipped.
infra/ Implementations of the ports: Drizzle repositories, Garage storage, FreeIPA, Web Push, the job queue.
index.ts Builds every adapter and service; the only thing app code imports from infra.
app/ Next.js routes only. Pages are grouped by who they are for: (public), (member),
(account), admin/. The root holds Next's special files (layout, errors, metadata).
components/ UI grouped by feature (feed/, proof/, review/, leaderboard/, profile/…); ui/ holds the basics.
actions/ Server actions: what forms submit, one folder per resource.
lib/ Small shared helpers: server actions, forms, navigation, dates, logging.
config/ Settings in code: site name and colours, access groups, photo limits, feed paging.
tests/
domain/ Pure tests of domain logic and rules.
integration/ Services against a real database.
Submitting a proof, from click to database:
components/proof/form.tsxvalidates the form withactions/proof/schema.tsand callssubmitProofAction.actions/proof/index.tswraps the call inaction(), which validates again on the server and finds the signed-in user.core/services/proof.tssubmitProofis guarded byuserCanSubmitProof, checks the task's policy, and saves throughProofRepository.infra/db/proof/submit.tswrites the proof, its photos and requested reviewers in one transaction.- The service notifies the requested reviewers through
NotificationPort, which queues a Web Push for each of their browsers.
The user page (/users/[userId]) is a small, complete example:
- Rule:
userCanViewProfilesindomain/rules.ts. - Port: the data it needs, here the feed's
authorIdfilter incore/ports/feed.ts. - Infra: the query in
infra/db/feed/repo.ts. - Service:
core/services/user.ts, registered ininfra/index.ts. - Page and components:
app/(public)/users/[userId]/page.tsxandcomponents/profile/. - Tests: rules in
tests/domain/, queries intests/integration/.
Files
- Name
.tsand.tsxfiles in camelCase:photoCarousel.tsx. - Don't repeat the folder in the file name:
feed/card.tsx, notfeed/feedCard.tsx. - Next.js files and route folders keep Next's names:
page.tsx,not-found.tsx,[userId]/. - Other files keep their usual names:
lapikud-logo.svg,setup.sh. - Write code in TypeScript. The exceptions are
public/serviceWorker.jsand the shell scripts inscripts/.
Code
- Check permissions with a rule from
domain/rules.ts. Never check roles directly. - Comment only what someone could otherwise break by accident.
- Let Prettier format the code. It also sorts Tailwind classes.
- Don't edit
components/ui/chart.tsxby hand; update it withnpx shadcn add chart.
Photos are uploaded and served through the app: the browser posts each file to POST /api/photos/upload, which validates it and stores it in Garage over the server's own connection. Garage stays private and never needs to be reachable from the browser, so no public endpoint or bucket CORS is required. Approved photos are served through the app to everyone; pending or rejected ones only to their owner and admins.
Notifications are stored in PostgreSQL and delivered live over /api/notifications/stream. Allow long-lived connections and turn off proxy buffering for that path.
Web Push delivers them with the site closed. Generate keys with npx web-push generate-vapid-keys and set NEXT_PUBLIC_VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY and VAPID_SUBJECT, or leave all three empty to turn push off. People turn desktop notifications on from the notifications page; signing out turns them off for that browser.
Slow or unreliable work runs as jobs in pg-boss, a queue stored in the same PostgreSQL database under the pgboss schema, so there is nothing extra to run. Workers start with the server (src/instrumentation.ts calls startWorkers()). Failed jobs are retried five times with growing delays.
There are two queues: push-delivery (one job per browser per notification, five sent at a time) and photo-backup (the weekly backup below, scheduled with pg-boss). To add a job, follow src/infra/push/webPush.ts or src/infra/backup/schedule.ts.
Server errors are written as JSON to stdout and, in development, to logs/app.log. Set LOG_FILE for a durable path and LOG_LEVEL to debug, info, warn or error. Secrets, cookies and tokens are redacted.
Photos can grow large, so every Sunday at 03:00 (Europe/Tallinn) a background job copies new proof photos from Garage to Nextcloud over WebDAV, under NEXTCLOUD_BACKUP_PATH with the same paths as in the bucket. Each photo is copied once; the photo_backups table records what is already there. Set NEXTCLOUD_WEBDAV_URL, NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD to turn it on.
Every push to main is checked again and, if it passes, pushed to git.lapikud.ee, where a runner inside the club's LXC container deploys it. The container builds the new version next to the running one, applies migrations, switches over and restarts the app. If the new version doesn't come up, it goes back to the previous one.
A rollback only undoes code, and migrations run while the previous version is still live, so every migration must also work with the code before it. To remove or rename a column, stop using it in one pull request and drop it in the next.
docs/deploy.md explains how deploys work and how to set up the server, Gitea and GitHub.