Repository navigation
feat: decouple gate checkin and add self checkin-checkout kiosk endpo… - #474
Conversation
…ints with temp wifi
|
Claude finished @vvshk's task in 1m 26s —— View job Review of PR #474: kiosk check-in/out and temp WiFi
Should fix before merge:
Worth tidying:
What's good: Moving the logic into I did not run lint or tests: AGENTS.md says dependencies and the test suite aren't available in GitHub Actions reviews. |
| } | ||
| }); | ||
|
|
||
| await t.commit(); |
There was a problem hiding this comment.
Behaviour change: gate scans no longer check anyone in. Before this PR, a gate entry scan on the check-in day moved pending_checkin room and flat bookings to checkedin. After it, only manualCheckin / flatCheckin (staff or the kiosk) do that. That has two effects:
- A guest who only scans at the gate stays
pending_checkin.fetchBookings()inwifi.controller.jsneedscheckedin, so the guest's app-side temp WiFi request now fails with "user not checked in yet". Anything else that readscheckedin(occupancy, day-wise reports) is affected the same way until someone checks the guest in. gateExit(around L317–329) still auto-checks-out flat bookings. Entry and exit now treat bookings differently. If you meant to fully decouple gate scans from booking status, move the exit-side update too, or note in the PR why it stays.
Also, this removal leaves RoomBooking, ROOM_STATUS_PENDING_CHECKIN and logger imported but unused in this file (L5, L16, L18). lint:undef won't flag them, so please drop them.
| // Kiosk WiFi actions use the scanned guest card number, not the admin's card. | ||
| router.post('/kiosk/wifi/generate-temp-code', CatchAsync(generateTempCodeForAdmin)); | ||
| router.get('/kiosk/wifi/fetch-temp-codes/:cardno', CatchAsync(fetchTempCodesForAdmin)); |
There was a problem hiding this comment.
Security: the self-service kiosk needs a full room-admin login. These routes sit under router.use(authorizeRoles(ROLE_OFFICE_ADMIN, ROLE_SUPER_ADMIN, ROLE_ROOM_ADMIN)). The kiosk device, which guests use unattended, therefore has to hold an office-, room- or super-admin token. With that token anyone at the kiosk can call every other route in this file: bookForMumukshu, block_room, block_rc, update_room_booking, late-checkout-fees/revoke, and the rest. It also allows the kiosk to checkin or checkout any booking id, not just the scanned guest's.
Please consider:
- a dedicated
ROLE_KIOSK(or similar), and a smallkiosk.routes.jsthat exposes only fetch-bookings-by-card, check in, check out and the two WiFi actions; - checking server-side that the
bookingidbeing checked in or out belongs to the scannedcardno.
Smaller points:
generate-temp-codeandfetch-temp-codesuse kebab-case, but the other routes here use snake_case (fetch_room_bookings,block_rc).- There is a doubled blank line after L63 (and after the import at L42).
| if (req.query.kiosk === 'true') { | ||
| const [roomBookings, flatBookings, cardDetails] = await Promise.all([ | ||
| RoomBooking.findAll({ where: { cardno }, order: [['checkin', 'ASC']] }), | ||
| FlatBooking.findAll({ where: { cardno }, order: [['checkin', 'ASC']] }), | ||
| CardDb.findOne({ | ||
| where: { cardno }, | ||
| attributes: ['cardno', 'issuedto'] | ||
| }) | ||
| ]); | ||
|
|
||
| return res.status(200).send({ | ||
| message: 'Fetched bookings', | ||
| data: { | ||
| room_booking: roomBookings, | ||
| flat_booking: flatBookings, | ||
| card_details: cardDetails | ||
| } | ||
| }); | ||
| } | ||
|
|
||
|
|
There was a problem hiding this comment.
Some issues with the ?kiosk=true branch:
- One endpoint, two response shapes. With the flag,
datais an object; without it,datais an array. Clients have to branch on a query flag to know which they got. A separate handler and route (for exampleGET /kiosk/bookings/:cardno) is clearer, and it fits the dedicated-kiosk-role suggestion on the routes file. - It returns more than a kiosk needs. It sends every room and flat booking the card has ever had, with all columns. A guest at an unattended screen only needs current and upcoming bookings, such as
checkout >= todaywith statuspending_checkinorcheckedin. You could also limitattributes. - No logging or 404. This path skips the
fetch_room_bookings_by_card_successlog line. If the card does not exist, it returns 200 withcard_details: nullinstead of a 404. - There are two extra blank lines after the block (L988–989).
| export const fetchTempCodesForAdmin = async (req, res) => { | ||
| const { cardno } = req.params; | ||
| if (!cardno) throw new APIError(400, 'cardno is required'); | ||
|
|
||
| const rows = await fetchTempCodesForCard(cardno); | ||
| return res.status(200).send({ | ||
| message: rows.length ? 'Wifi Passwords' : 'No active WiFi passwords found', | ||
| data: rows | ||
| }); | ||
| }; |
There was a problem hiding this comment.
There are three small inconsistencies between the admin fetch and generate paths:
generateTempCodeForAdmintrimscardno(L166), but this handler does not. A scan with stray whitespace can therefore generate a code and then fetch nothing.fetchTempCodeschecksres_status(mumukshu or guest) before returning codes.fetchTempCodesForCarddoesn't. Behaviour is the same today, because only eligible cards can ever get a row, but the two paths are asymmetric.- AGENTS.md asks request controllers to use
req.log. Neither new admin handler logs anything, so it's hard to trace which admin account generated a code for which card. For an unattended kiosk,req.log.info('kiosk_wifi_temp_code_generated', { cardno, by: req.user.username })ingenerateTempCodeForAdminwould help with audits.
| export const fetchTempCodesForAdmin = async (req, res) => { | |
| const { cardno } = req.params; | |
| if (!cardno) throw new APIError(400, 'cardno is required'); | |
| const rows = await fetchTempCodesForCard(cardno); | |
| return res.status(200).send({ | |
| message: rows.length ? 'Wifi Passwords' : 'No active WiFi passwords found', | |
| data: rows | |
| }); | |
| }; | |
| export const fetchTempCodesForAdmin = async (req, res) => { | |
| const cardno = req.params.cardno?.trim(); | |
| if (!cardno) throw new APIError(400, 'cardno is required'); | |
| const rows = await fetchTempCodesForCard(cardno); | |
| req.log.info('kiosk_wifi_fetch_temp_codes', { cardno, count: rows.length }); | |
| return res.status(200).send({ | |
| message: rows.length ? 'Wifi Passwords' : 'No WiFi passwords found', | |
| data: rows | |
| }); | |
| }; |
| // Day visits (nights 0) store checkout = checkin + 1; judge them by checkin | ||
| // so a visit today is not eligible tomorrow. | ||
| const roomWhereClause = { | ||
| cardno, | ||
| status: ROOM_STATUS_CHECKEDIN, | ||
| [Sequelize.Op.or]: [ | ||
| { nights: { [Sequelize.Op.gt]: 0 }, checkout: { [Sequelize.Op.gte]: today } }, | ||
| { nights: 0, checkin: { [Sequelize.Op.gte]: today } } | ||
| ] | ||
| }; |
There was a problem hiding this comment.
This day-visit rule looks right. manualCheckin blocks checkin > today, so gte behaves the same as eq in practice. Two notes:
- The new rule applies to rooms only.
FlatBookingalso hasnightsand still usescommonWhereClause. A flat day visit (if those exist) would still be WiFi-eligible the next day. - Because
fetchBookingsis shared, this also changes the member-appgenerateTempCodeandfetchTempCodespaths, not just the kiosk. That is probably what you want, but please mention it in the PR description.
Optional: { nights: 0, checkin: today } states the intent more clearly than gte.
…sk routes, and wifi controllers
|
Claude finished @vvshk's task in 1m 14s —— View job Re-review of PR #474 after
|
| [Sequelize.Op.or]: [ | ||
| { status: ROOM_STATUS_CHECKEDIN }, | ||
| { | ||
| status: [ROOM_STATUS_PENDING_CHECKIN, STATUS_PENDING], |
There was a problem hiding this comment.
STATUS_PENDING ('pending') is not one of the RoomBooking or FlatBooking status values. Both enums allow only waiting, payment pending, pending checkin, checkedin, checkedout, cancelled and admin cancelled. That part of the filter can never match, so it's dead code and misleading. If you meant unpaid bookings, the constant is STATUS_PAYMENT_PENDING. But manualCheckin and flatCheckin only accept pending checkin, so the kiosk would list bookings it can't check in. The simplest fix is to drop it and remove the STATUS_PENDING import at L33:
| status: [ROOM_STATUS_PENDING_CHECKIN, STATUS_PENDING], | |
| status: ROOM_STATUS_PENDING_CHECKIN, |
Also, the checkedin branch has no date bound. That's fine for overdue stays, but it means a stale checkedin row from months ago will appear at the kiosk, because gate exit no longer checks flats out (see the gate comment).
| }; | ||
|
|
||
| export const fetchRoomBookingsByCard = async (req, res) => { | ||
| if (req.query.kiosk === 'true') { |
There was a problem hiding this comment.
Now that /kiosk/bookings/:cardno exists, is the ?kiosk=true branch on fetch_room_bookings still needed? Keeping it means two URLs serve the kiosk response, and this endpoint still returns two response shapes depending on a query flag. If no client already ships with ?kiosk=true, please remove the branch so this handler goes back to its original form.
| if (req.log) { | ||
| req.log.info('kiosk_wifi_temp_code_generated', { | ||
| cardno: cleanCardNo, | ||
| by: req.user?.username | ||
| }); | ||
| } |
There was a problem hiding this comment.
The if (req.log) guard isn't needed. The request logger middleware always sets req.log, and other controllers (such as manualCheckin and fetchKioskBookingsByCard) call it directly. The same applies at L192. To match the surrounding code:
| if (req.log) { | |
| req.log.info('kiosk_wifi_temp_code_generated', { | |
| cardno: cleanCardNo, | |
| by: req.user?.username | |
| }); | |
| } | |
| req.log.info('kiosk_wifi_temp_code_generated', { | |
| cardno: cleanCardNo, | |
| by: req.user?.username | |
| }); |
| ...(createdAt && { createdAt }) | ||
| }, | ||
| { transaction: t } | ||
| ); |
There was a problem hiding this comment.
Entry and exit are now consistent, and the unused imports are gone, thanks. One effect to note in the PR description: nothing else ever moves a flat booking to checkedout. cron.js only handles unpaid past bookings, so only flatCheckout (staff or kiosk) does it. Flat guests who leave through the gate without using the kiosk will stay checkedin indefinitely. That affects occupancy reports and the WiFi eligibility check (checkout >= today stops matching, so WiFi is fine, but the kiosk list from fetchKioskBookingsByCard will keep showing the old flat stay). Consider adding a daily cron that checks out stale checkedin bookings, or at least flagging them in a report.
|
Claude finished @vvshk's task in 54s —— View job Re-review of PR #474 after
|
| const isToday = rb.checkout === today; | ||
| const isHalfDay = nowTime <= LATE_CHECKOUT_HALF; | ||
| const estLateFee = isToday ? calcLateCheckoutFee(rb.roomtype, isHalfDay) : null; | ||
| const daysOverdue = moment(today).diff(moment(rb.checkout), 'days'); | ||
|
|
||
| return { | ||
| id: null, | ||
| amount: isToday ? `₹${estLateFee} (Est.)` : (daysOverdue > 0 ? `${daysOverdue}d overdue` : 'Overdue'), |
There was a problem hiding this comment.
There are two problems with the estimated fee:
- Day visits show a fee they won't be charged.
handleSameDayCheckout(L102) never charges a late fee whenbooking.nights < 1. A day visit storescheckout = checkin + 1, so a guest stillcheckedinafter 11:00 on that date shows₹… (Est.)here, but checking them out creates no fee. Please apply the samenights >= 1rule here. amounthas a different type in this mode. The otherpayment_typemodes return the transaction's numericamount. This one returns display strings ("₹500 (Est.)","3d overdue"). A client that sums or formatsamountwill break. I'd keepamountnumeric (ornull) and add separate fields such asest_late_feeanddays_overdue, so the UI can decide how to show them.
| const isToday = rb.checkout === today; | |
| const isHalfDay = nowTime <= LATE_CHECKOUT_HALF; | |
| const estLateFee = isToday ? calcLateCheckoutFee(rb.roomtype, isHalfDay) : null; | |
| const daysOverdue = moment(today).diff(moment(rb.checkout), 'days'); | |
| return { | |
| id: null, | |
| amount: isToday ? `₹${estLateFee} (Est.)` : (daysOverdue > 0 ? `${daysOverdue}d overdue` : 'Overdue'), | |
| const isToday = rb.checkout === today; | |
| const isHalfDay = nowTime <= LATE_CHECKOUT_HALF; | |
| const estLateFee = | |
| isToday && rb.nights >= 1 ? calcLateCheckoutFee(rb.roomtype, isHalfDay) : null; | |
| const daysOverdue = moment(today).diff(moment(rb.checkout), 'days'); | |
| return { | |
| id: null, | |
| amount: estLateFee, | |
| est_late_fee: estLateFee, | |
| days_overdue: daysOverdue, |
(If you make this change, give the flat mapper the same amount: null, days_overdue shape.)
| [Sequelize.Op.or]: [ | ||
| { | ||
| status: ROOM_STATUS_CHECKEDIN, | ||
| checkout: { [Sequelize.Op.gte]: moment().tz('Asia/Kolkata').subtract(14, 'days').format('YYYY-MM-DD') } |
There was a problem hiding this comment.
The 14-day cap keeps stale checkedin rows off the kiosk, which is good. Together with the new overdue report, this covers my earlier concern about flats that never get checked out. Two small points:
14is a magic number. A named constant such asKIOSK_CHECKEDIN_LOOKBACK_DAYSwith a one-line comment would explain it. Keep in mind that a guest whose stay is more than 14 days overdue can no longer check out at the kiosk. Staff have to handle it from the overdue report.todayis already computed fromnowISTa few lines up. Deriving the cutoff from it avoids callingmoment()a second time.
| checkout: { [Sequelize.Op.gte]: moment().tz('Asia/Kolkata').subtract(14, 'days').format('YYYY-MM-DD') } | |
| checkout: { [Sequelize.Op.gte]: moment(today).subtract(KIOSK_CHECKEDIN_LOOKBACK_DAYS, 'days').format('YYYY-MM-DD') } |
…, and extract kiosk lookback constant
|
Claude finished @vvshk's task in 22s —— View job Re-review of PR #474 after
|
|
Claude finished @vendz's task in 1m 13s —— View job Re-review of PR #474 after
|
| ['GET', /^\/stay\/kiosk\/bookings\/[^/]+$/], | ||
| ['POST', /^\/stay\/kiosk\/wifi\/generate[-_]temp[-_]code$/], | ||
| ['GET', /^\/stay\/kiosk\/wifi\/fetch[-_]temp[-_]codes\/[^/]+$/], | ||
| ['PUT', /^\/stay\/(flat_)?check(in|out)\/[^/]+$/] |
There was a problem hiding this comment.
The kiosk can still check in or check out any booking. Kiosk-only accounts are now limited to these routes, which is a big improvement. But PUT /stay/(flat_)?check(in|out)/:bookingid doesn't check who the booking belongs to. Anyone with the kiosk token, or physical access to the kiosk device, can check out another guest's booking by its id. On an early checkout, that cancels the guest's transaction and books credits.
Neither manualCheckin/manualCheckout nor flatCheckin/flatCheckout take a cardno today. A small fix: when req.kioskRoute is set, require the scanned cardno (in the body or query) and add it to the findOne where clause, so a mismatch returns 404. Staff requests stay as they are.
| const transaction = await Transactions.findOne({ | ||
| where: { | ||
| bookingid: booking.bookingid, | ||
| ...(isFlat && { category: TYPE_FLAT }) | ||
| } | ||
| }); | ||
| if (!transaction) return null; | ||
| const nights = await calculateNights(booking.checkin, today); | ||
| const newAmount = roomCharge(isFlat ? 'nac' : booking.roomtype) * nights; | ||
| const credit = Number(transaction.amount) + Number(transaction.discount) - newAmount; |
There was a problem hiding this comment.
The credit preview ignores the transaction's status, so it can show a credit the guest won't get. On checkout, cancelTransaction (helpers/transactions.helper.js:192-197) gives back amount + discount only when the transaction is payment completed or cash completed. Otherwise it gives back only discount. So the kiosk shows a credit for:
- a booking with an unpaid (
cash pendingorpayment pending) transaction. At checkout the guest gets a smaller bill, not a credit. - a transaction that is already
cancelledoradmin cancelled.
Use the same rule as cancelTransaction (you'll also need to import STATUS_CASH_COMPLETED):
| const transaction = await Transactions.findOne({ | |
| where: { | |
| bookingid: booking.bookingid, | |
| ...(isFlat && { category: TYPE_FLAT }) | |
| } | |
| }); | |
| if (!transaction) return null; | |
| const nights = await calculateNights(booking.checkin, today); | |
| const newAmount = roomCharge(isFlat ? 'nac' : booking.roomtype) * nights; | |
| const credit = Number(transaction.amount) + Number(transaction.discount) - newAmount; | |
| const transaction = await Transactions.findOne({ | |
| where: { | |
| bookingid: booking.bookingid, | |
| ...(isFlat && { category: TYPE_FLAT }) | |
| } | |
| }); | |
| if (!transaction) return null; | |
| const isPaid = [STATUS_PAYMENT_COMPLETED, STATUS_CASH_COMPLETED].includes(transaction.status); | |
| if (!isPaid) return null; | |
| const nights = await calculateNights(booking.checkin, today); | |
| const newAmount = roomCharge(isFlat ? 'nac' : booking.roomtype) * nights; | |
| const credit = Number(transaction.amount) + Number(transaction.discount) - newAmount; |
Minor: this runs one Transactions query plus calculateNights for each booking. That's fine for the few bookings a single card has.
There was a problem hiding this comment.
Fixed in the latest commit: the preview now follows the same rule as the real cancel. It counts the full amount only when the payment is completed (online or cash), and only the discount otherwise.
| { | ||
| status: STATUS_PAYMENT_PENDING, | ||
| checkout: { [Sequelize.Op.gte]: today } | ||
| } |
There was a problem hiding this comment.
The kiosk now lists payment pending bookings it can't act on. manualCheckin and flatCheckin only accept pending checkin, so tapping check-in on one of these returns 404 ERR_BOOKING_NOT_FOUND. If the kiosk should show "please complete payment" for these, that's fine, but the client needs to hide the check-in button based on status. Please confirm the kiosk UI does this, or drop this branch.
There was a problem hiding this comment.
Intended. The kiosk lists these stays so it can say 'Payment Incomplete' instead of 'No booking'. The kiosk page shows that message and offers no check-in button for them (covered in the browser test with an unpaid stay).
|
Claude finished @vendz's task in 37s —— View job Re-review of PR #474 after
|
|
Claude finished @vendz's task in 27s —— View job Re-review of PR #474 after
|
|
Claude finished @vendz's task in 25s —— View job Re-review of PR #474 after
|
|
Claude finished @vvshk's task in 55s —— View job Re-review of PR #474 after
|
| const payerCardno = transaction.cardno || booking.bookedBy || booking.cardno; | ||
| const isSelfBooked = !payerCardno || payerCardno === booking.cardno; | ||
| const payerName = !isSelfBooked | ||
| ? (bookerNameMap?.get(payerCardno) || payerCardno) | ||
| : null; | ||
|
|
||
| return { | ||
| credit, | ||
| is_self_booked: isSelfBooked, | ||
| payer_cardno: payerCardno, | ||
| payer_name: payerName |
There was a problem hiding this comment.
Privacy: the kiosk lookup now returns another person's card number. Anyone can type a card number at the kiosk, with no second factor (the PR description accepts this risk). The response now includes payer_cardno, the booker's card number, whenever someone else booked or paid for the stay. When the name lookup misses, payer_name and booker_name (L1129–1132) also fall back to that card number. So one typed card number reveals a second card number, and that number can then be looked up at the kiosk too.
The kiosk only needs to show "credit goes to ". Please drop the card number from the response and fall back to null:
| const payerCardno = transaction.cardno || booking.bookedBy || booking.cardno; | |
| const isSelfBooked = !payerCardno || payerCardno === booking.cardno; | |
| const payerName = !isSelfBooked | |
| ? (bookerNameMap?.get(payerCardno) || payerCardno) | |
| : null; | |
| return { | |
| credit, | |
| is_self_booked: isSelfBooked, | |
| payer_cardno: payerCardno, | |
| payer_name: payerName | |
| const payerCardno = transaction.cardno || booking.bookedBy || booking.cardno; | |
| const isSelfBooked = payerCardno === booking.cardno; | |
| const payerName = isSelfBooked ? null : bookerNameMap?.get(payerCardno) || null; | |
| return { | |
| credit, | |
| is_self_booked: isSelfBooked, | |
| payer_name: payerName | |
| }; |
(payerCardno is never empty, because booking.cardno is always set, so the !payerCardno branch can go.) Apply the same || null fallback to booker_name at L1131, and check that admin #225 doesn't read payer_cardno.
| // Also if any transaction was paid by another card not in bookerNameMap, resolve it | ||
| const missingPayerCardnos = transactions | ||
| .map((t) => t.cardno) | ||
| .filter((c) => c && c !== cardno && !bookerNameMap.has(c)); | ||
|
|
||
| if (missingPayerCardnos.length > 0) { | ||
| const extraPayerCards = await CardDb.findAll({ | ||
| where: { cardno: missingPayerCardnos }, | ||
| attributes: ['cardno', 'issuedto'] | ||
| }); | ||
| for (const c of extraPayerCards) { | ||
| bookerNameMap.set(c.cardno, c.issuedto); | ||
| } | ||
| } |
There was a problem hiding this comment.
Minor: this third, sequential CardDb query can be removed. Batch 2 runs the booker-card query in parallel with the transactions query, so it can't know the payer cards yet. To save a round trip, fetch the transactions first, then run one CardDb.findAll over the union of bookedBy and transaction.cardno values. That's 2 round trips in total instead of up to 3. Also, missingPayerCardnos isn't deduplicated, so wrap it in new Set(...) if you keep this block.
Also note: the room preview now filters category IN ('room','flat'), but manualCheckout (L382) looks the transaction up by bookingid only. Booking ids are UUIDs, so in practice both find the same row, but the preview and the real checkout no longer use exactly the same query.
|
Claude finished @vvshk's task in 35s —— View job Re-review of PR #474 after
|
| : null; | ||
| const tx = txMap.get(plain.bookingid) || null; | ||
| return { | ||
| ...plain, |
There was a problem hiding this comment.
The booker's card number still leaks here. payer_cardno is gone and the name fallbacks are now null, but ...plain spreads every selected attribute into the response. That includes bookedBy (L1044), so when someone else booked the stay, the kiosk lookup still returns their card number. That card number can then be typed into the kiosk too, which is the problem the last commit set out to fix.
bookedBy is only needed on the server, to work out booker_name and the payer. Remove it from the response:
| ...plain, | |
| ...omitBookedBy(plain), |
with a small helper next to withPreview:
const omitBookedBy = ({ bookedBy, ...rest }) => rest;Or destructure it inline: const { bookedBy, ...publicFields } = booking.get({ plain: true });, then use bookedBy for booker_name and spread publicFields. Please also check that admin #225 doesn't read bookedBy from this response.
|
Claude finished @vvshk's task in 41s —— View job Re-review of PR #474 after
|
| } | ||
| }); | ||
|
|
||
| await t.commit(); |
There was a problem hiding this comment.
4bb08aa takes check-in and check-out off the gate again, but the PR description still says "The gate is untouched". With this commit, only manualCheckin and flatCheckin move a stay to checkedin. Only manualCheckout and flatCheckout move it to checkedout. That happens through staff or the kiosk, and no cron covers it. This breaks several things the description promises:
- "Kiosk check-in is optional, so guests who skip it lose nothing except the WiFi code." That is no longer true. A guest who only scans at the gate stays
pending checkin. They don't show up in reports that countcheckedinstays.fetchBookingsinwifi.controller.jsneedscheckedin, so the member-app WiFi request fails as well, not just the kiosk one. - The evidence line "gate entry alone -> stay checked in, no kiosk needed" no longer matches the code.
- Flat guests who leave through the gate without using the kiosk stay
checkedinuntil staff use the overdue report.
Please do one of these:
- If this commit was intended (the PR title says "decouple gate checkin"), rewrite the Summary, diagram, evidence and Blast Radius to say the kiosk or staff check-in is now required. Then get the owner's sign-off, because hotfix/utsav-admin-creating-mumukshu-registration-bugs #225 and the 4fee390 revert both assumed the opposite.
- If it wasn't intended, revert
4bb08aaso this file matchesorigin/devagain.
Summary
Self check-in and check-out kiosk API. The kiosk is optional: it is for guests who want a WiFi code. The gate keeps checking guests in and out as before. A new
kioskAdminrole can reach the kiosk and nothing else.Evidence
After: local backend + test DB + headless browser, fake camera, 107 / 107 checks pass.
lint:undefclean.Screenshots are on admin PR #225. Two
toggleRCBlockModetests fail the same way on plaindev; the full jest run is flaky on a shared test DB, also on plaindev.Merge Danger
Door: two-way. Code only, no migration.
The
kioskAdminrole is one row added by hand: a super admin inserts rolekioskAdmin(statusactive), then creates the account with only that role. Logins last 24 hours. A kiosk login always lands on check-in; the check-out tablet opens its page by URL.Blast Radius: kiosk and admin login
Deploy this before admin #225.