ธีม
2026-08-01 — ทีม SAMO: portrait crop, modal stacking, photo lifecycle, data health, and 0108 team_people
Pruned out of STATE.md to keep it inside the context budget. All of this is SHIPPED and DEPLOYED (commits a04e796, 021db9d, 48d4f92 on main; GAS v10; migration 0108 applied). Kept because the reasoning is not recoverable from the diff — particularly the identity rule, why kkumail rather than รหัสนักศึกษา, and the list of things that were verified live versus assumed.
IN THE WORKING TREE, NOT COMMITTED (2026-08-01) — ทีม SAMO photo crop + modal stacking
Frontend only; no migration, no deploy. npm run build + npm test (203) green.
- จุดโฟกัสของรูป is gone from the UI. Picking a photo now opens a pan/zoom crop dialog (
src/js/image-crop.js+src/css/image-crop.css, imported by BOTHmain.cssandadmin.cssbecause the public self-service profile below will need it) and what uploads is already 3:4.photo_focusstays in the DB and inorg-chart.js— legacy and archived rows still carrytop/bottomand must keep rendering right; new uploads writecenter, which makes lh3's-ccrop exact and halves the per-card bytes. Geometry is a pure exportedcropGeometry()with 8 tests. - Stacked modals now stack.
src/js/modal-stack.js, wired into both entries. Bootstrap gives every modal z-index 1055, so DOM order decided painting order and#teamPickerModal(declared line ~149) rendered BEHIND#teamMemberModal(line ~372) — the reported "ตำแหน่ง picker shows behind the member editor". One delegatedshow.bs.modallistener lifts each stacked modal + its backdrop. - Two bugs found off a screenshot:
.team-photo-field/-preview/-controls/ -emptywere intab-team.htmlwith NO CSS anywhere, so the preview<img>rendered at natural size and burst out of the modal; andconvertDriveUrl(url, 320)returns an already-lh3 URL untouched, so its size argument is silently ignored for exactly the rows this app writes. Both preview call sites now useportraitSrc(), which rebuilds the option string. decode()inimage-resize.jsnow passesimageOrientation: 'from-image'so createImageBitmap agrees with the<img>fallback on EXIF-rotated phone photos.
0108 APPLIED — team_people (store each person once). EXPAND STEP.
Applied to the live DB 2026-08-01. tools/team0108-people.mjs is the proof: it runs the real migration file against the real data inside a transaction that ROLLS BACK, then asserts. 12/12 both before and after applying — the second run is also the idempotency check, since re-running the whole migration produces the same 303 people rather than a second set. team0089-manage 5/5, team0104-terms 40/40, proj0086-seats 24/24, vs0083-scope 16/16, security-sweeps clean afterwards.
403 team_members rows → 303 people. Higher than the ~285 humans actually in the roster, and that is the rule refusing to guess: ambiguous rows stay split until someone resolves them in ตรวจสอบข้อมูล.
Nothing reads team_people yet. Ten resolvers (effective_team_*_for_email, node_effective_*, sync_my_team_permissions) still join on team_members.kkumail, every policy is unchanged, and the proof asserts zero accounts whose managed_permissions would resolve differently. The contract step — switching writes to the person, then dropping the duplicated columns — is a later migration. Do not repoint a resolver without moving all ten.
Three things in it worth not undoing:
- The mirror is ONE-directional (person → its placements). A two-way mirror between a table and its own denormalised copy is the "two implementations of one rule drift" entry wearing a trigger. While the UI still writes to
team_membersa person row simply goes stale, which is harmless because nothing reads it. The proof asserts the upward direction does NOT happen. - The backfill disables
touch_team_members_updated_at. Stampingperson_idis bookkeeping, butteam_term_status(0105) derives "ผังสดเปลี่ยนแล้ว · ควรเผยแพร่ซ้ำ" frommax(updated_at)— leaving it on flagged every published ปีการศึกษา as stale for a change no human made. The proof caught this: it snapshots the columns BEFORE the migration and diffs, rather than inferring fromupdated_at, which is itself one of the things that must not move. revoke all … from anonis explicit, not left to "RLS returns no rows anyway". Supabase's default privileges handanona SELECT grant on new public tables.
NEXT — self-service member profile (design DECIDED 2026-08-01, nothing built)
The model: kkumail is AUTHENTICATION (any KKU student has one), a team_members row is AUTHORIZATION, and the tree — nodes, hierarchy, ตำแหน่ง, สิทธิ์ — stays admin-only. A member edits FIELDS ON their row, never WHERE the row sits. That is what keeps an open door from turning into a messy org chart, and what stops a random kkumail student getting anything.
Decisions the user made (don't re-litigate):
- Not on the roster → a REQUEST FORM into an admin approval queue. Two request kinds, one queue:
claim("this existing row is me" — links kkumail to a row an admin already typed) andjoin("I'm not listed, here's my info"). The person does the data entry; the admin does only the placement, which is the part that needs judgement. Graduated seniors / Discord outsiders are simply never approved — their record already lives in the published year archive. - Self-uploaded photos go LIVE immediately, no flag, no moderation queue.
- A signed-in roster member MAY see other members' details — the user's call, stated as "it's not that sensitive data". The directory projection still omits
permissions/inherit_permissions/vs_dept/project_seat/passport_dept_id/user_id: who holds which grant is a targeting map and costs nothing to leave out.
Non-negotiables, each one a scar in this repo:
- Self-edit goes through a SECURITY DEFINER RPC with an explicit column allow-list — NOT
for update using (user_id = auth.uid()). That class has already bittenusers(0028),vs_tickets(0096) andshop_orders(0100); here it would let a member self-grantpermissions/project_seat/vs_dept/passport_dept_idor move their ownnode_id. - Reads go through a projection RPC too.
team_membershas NO public SELECT policy today and must never get one — a row carries every student's kkumail, รหัสนักศึกษา and สิทธิ์ (0086 wrote this down;using (true)can never be narrowed later because policies are OR'd). - Approving a
claimCAN CONFER PERMISSIONS: kkumail feedseffective_team_permissions_for_email, so approving someone onto หัวหน้าฝ่าย IT hands them that ฝ่าย's grants. The approval dialog must name the grants it is about to give — the privilege-escalating direction gets the strong confirm. - Discord is NOT an identity source (a bot + OAuth buys no authorization we don't have). It is the announcement channel that points people at the page.
The crop dialog is already public-entry-ready: image-crop.css is imported by main.css as well as admin.css precisely so the member-facing photo upload can reuse it.
IDENTITY RULE, decided 2026-08-01 — kkumail is the identity, รหัสนักศึกษา is a field. Reasons: the permission engine already resolves by email (effective_team_permissions_for_email), the email is PROVEN by the Google login while a student id is typed and never checked, and the live data settles it — 673070332-6 is one mistyped id shared by two humans whose emails are correct and distinct, so a student-id merge would fuse two people. Resolution on any new row (import, admin add, self-edit): group by valid kkumail; else by รหัสนักศึกษา; else alone. Never on name. Nothing non-empty is silently overwritten, and two keys that disagree REFUSE rather than guess. Mark an email ยืนยันแล้ว once that person has actually signed in with it — that makes the historical mess self-cleaning and isolates the typos. Year to year: the PERSON persists (so a returning member keeps their photo/ชื่อเล่น), the PLACEMENT does not — last year's is already frozen in the 0104 archive.
No approval queue, no per-department delegation (user's call, echoing the 0094 shop-scope revert — a boundary that isolates nothing is worse than none). team permission manages everything, unchanged. Every member action is either INSTANT or REFUSED, never pending. The single rule behind the refusals: the ตำแหน่ง you end up in must not carry สิทธิ์ — which also closes the non-obvious route, since inherit_permissions defaults true and a new ตำแหน่ง created BENEATH a สิทธิ์-bearing one would inherit it. Joining the roster at all stays admin-only; the user explicitly rejected letting any kkumail self-add.
tools/team-identity-dryrun.mjs — READ-ONLY, re-runnable, prints exactly what would merge. Current output: 403 rows → 303 people, 100 folded, all 81 multi-placement people matched by kkumail (zero needed the student-id fallback). Needs human eyes: 10 keyless rows (2 are test rows under the "hi" node), 1 kkumail literally -, 673070332-6 shared by two people, and 3 names split across two groups because a key is missing.
ตรวจสอบข้อมูล — the standing resolution workflow (new mode, 2026-08-01)
src/js/team/health.js + #teamHealthPane — a fourth mode beside จัดการทีม / จัดการสิทธิ์ / ปีการศึกษา, with a count badge on the button so it advertises itself. Findings are computed LIVE from the members already in memory (no query, no script to remember), so the next CSV import that reintroduces an inconsistency simply appears. Five kinds, ordered mechanical-first: invalid_email · sid_clash · sid_drift · drift · no_key.
Design points that are load-bearing, not decoration:
- A name match is a SUGGESTION with a confirm, never an auto-merge. Linking an email also hands over whatever สิทธิ์ that person's ตำแหน่ง carry.
- Picking a drift value writes it to EVERY row of that person — the drift exists precisely because the rows are separate copies.
- Writes are SERIALISED, not dropped (
chain = chain.then(...)). Anif (busy) returnwould silently discard the second of two quick clicks, and every click here carries a different decision. render()deliberately does not repaint this pane — it is the realtime re-render target and would destroy half-typed emails, same as the terms pane.- 25 tests in
health.test.js, written from the live cases (673070332-6, the-email, ปรายฟ้า/ปลายฟ้า) — plus two structural ones worth keeping: every action button must carry adata-h*attribute the click handler actually branches on (a dead control fails closed and silently), and every class the module uses must have a CSS rule.
Three bugs found in a later self-scan of this same file, all fixed: (1) status(okMsg) ran BEFORE renderHealth(), which replaces innerHTML and recreates #teamHealthStatus empty — so every SUCCESS was silently wiped while every FAILURE showed, the exact inverse of what is useful. (2) The click handler opened with if (!t || busy) return, dropping any click that landed mid-write — the precise antipattern the run() comment above it warns against. The promise chain already serialises and every handler reads its inputs synchronously, so the guard is gone. (3) renderHealth() over an unloaded tree rendered "ข้อมูลครบถ้วน" — an empty array is not an empty state; it now shows a loading line, gated on loaded passed through getData().
NOT VISUALLY VERIFIED. The Chrome extension was not connected this session, so the crop dialog and this pane have never been rendered — only built, unit tested and reasoned about. Look at both before trusting the layout. Everything below the surface WAS verified live (see the scan list at the end of this file).
The durable answer is still the member's own profile page — an admin cannot know whether the ชื่อเล่น is ปรายฟ้า or ปลายฟ้า, and วรวลัญช์ can answer in one click. This pane is the half that works today and the half that will always be needed for rows belonging to people who never sign in.
Bugs found in the same scan
- Team photos were never deleted from Drive — FIXED AND DEPLOYED. Shop and Projects have had a delete path on both sides for a long time; Team had none, so every replaced / cleared / deleted portrait, and every upload abandoned before บันทึก, left a file in Drive shared "anyone with the link" forever. Unbounded, and a privacy problem more than a storage one. Added
deleteTeamFiletoappscript/prform.gs(guarded by the existingfileLivesUnderTop_(file, 'Team'); adds no new Google service, so the OAuth scopes are unchanged and no re-consent was needed) +deleteTeamFileinuploads.js+deleteTeamPhotoIfUnusedinteam/api.js. GAS version 10 deployed; /exec URL unchanged. Verified live BOTH ways bytools/gas-team-delete-probe.mjs(7/7): a real file uploaded intoTeamis trashed, a real file inShopis refused with "file is not inside Team", a missing fileUrl is refused, and an unknown id reportsalreadyGoneso a cleanup cannot loop. Every probe file it creates it deletes again. The non-obvious half:publish_team_termcopiesphoto_urlintoteam_archive_members, so a live portrait and an archived year's card are the SAME Drive file. A naive delete would blank a published year months later. The delete is therefore a REFCOUNT over both tables, called only AFTER the row is gone/repointed (never on the นำรูปออก click, which would destroy a photo the DB still uses if the admin then cancels). Full write-up in mistakes.md. - FIXED in this tree: the Drive filename prefix used
membersOf(nodeId).lengthfor an existing member, filing the first of five people as05-. - Verified NOT bugs:
render()only replaces#teamTree, so a realtime edit by another admin cannot destroy an open member editor or the crop dialog;data-perm-onlyappears only in a public-only partial, so the admin entry having no handler for it is not a gap.security-sweepsclean,team0089-manage5/5,team0104-terms40/40.
SCAN LOG — what was verified LIVE on 2026-08-01, and what was not
Kept because a cold-start agent will otherwise re-do it. Each line is a check that was actually executed, not reasoned about.
Verified live, clean:
deleteTeamPhotoIfUnused's refcount filter really matches. This fails in the DANGEROUS direction — "0 rows" means "trash the file" — so it was tested over real HTTPS against the one live photo:photo_url=eq.<encodeURIComponent(url)>returns the row (the=w1200inside the value survives encoding), an absent URL returns[], and the archive table is queried the same way.- 0108 did NOT widen the public projection.
get_public_team_chart/get_public_org_chartare hand-builtjsonb_build_object(noselect *, noreturns setof), and the live serialised chart carries exactlyname · nickname · node_id · photo_focus · photo_url · position— noperson_id, no kkumail, no รหัสนักศึกษา. - Bundle boundaries: the crop dialog and health pane are in the ADMIN JS only (0 hits in
dist/assets/public-*.js).image-crop.cssis in both entries on purpose — the future member-facing profile page needs it. - CSS custom properties resolve for the crop dialog even though it appends itself to
<body>outside.team-tab:--ink-*/--radius-*are on:rootin base.css. team_members.yearistext, so the health pane writing a string is right.- No circular import from
uploads.js → db.js(added forcurrentAccessToken). - Proof suites after 0108:
team0089-manage5/5,team0104-terms40/40,proj0086-seats24/24,vs0083-scope16/16,security-sweepsclean,team0108-people12/12.
Known gaps, deliberately left (do NOT treat as bugs to be surprised by):
createMemberand the CSV import writeteam_membersrows withperson_id = null, andbuildExportJsondoes not carryperson_id. Harmless today because nothing reads it; the CONTRACT step must either add an INSERT trigger that resolves/creates the person, or re-run the 0108 backfill (it is idempotent — it only considers unlinked rows).- The crop dialog leaks nothing now, but re-opening it while it is already open would load the new image into the live dialog. Not reachable: the file input that triggers it sits behind a Bootstrap modal.
- Team photo upload still happens on PICK, so abandoning the member editor after a successful upload orphans one Drive file. Much narrower than before (the crop step is cancelled before any upload), and
deleteTeamPhotoIfUnuseddoes not cover it because no row ever referenced the file.