Skip to main content

CSIM — July Update: Role Grid, Timesheets & a New Nav Shell

· 9 min read
SAIYU
Construction Team

CSIM's largest release to date. Permissions are now data instead of hard-coded roles, timesheets have shipped end to end, the dashboard is composed per tier rather than hand-written, and the whole navigation shell has been rebuilt on desktop and mobile. Everything listed here is live.


Summary

  1. Roles are data now. The 3-value role enum is gone. Admins create and edit tiers at /settings/roles with an 11-area × 5-action permission grid. Nav, route access, and the database all read that one grid.
  2. Timesheets shipped. Per-day, per-site hours, approved by the foreman of that site. Admin locks the week and exports CSV.
  3. The dashboard is composable. 12 widgets, arranged per tier at /settings/dashboards. Nobody gets a hand-written dashboard any more.
  4. New nav shell. Grouped desktop nav, a shift pill that checks you in and out from any page, and a four-tab mobile bottom bar. The hamburger menu is gone.
  5. Inventory got rewritten. Console on desktop, field view on mobile, undo everywhere.
  6. Three real security holes closed along the way — see the callouts.

1. Roles & permissions — the big one

Previously "who can do what" lived in three places that disagreed: a 3-value CHECK on profiles.role, ~348 role === 'admin' literals scattered through the code, and an app_config "Function Visibility" matrix that only hid nav links and never blocked the route. That settings page literally admitted it: "Hiding a feature does not block its route."

Now there is one grid.

  • roles holds the tier catalogue; role_permissions holds one row per (tier, area) with view / create / edit / approve / delete plus a scope of own / all_read / all.
  • Six tiers ship seeded: Admin (0), Construction Manager (1), Foreman (3), Leading Hand (4), Office (5), Employee (9). Rank 2 is reserved for a future PM tier.
  • Rank is display order only. It never decides who can approve what — approval routes by site. There's a test that fails CI if any policy ever references rank.
  • Admins can create, duplicate, and delete tiers (delete forces reassignment of members) — no migration needed to add one.
  • Every RLS policy, every API guard, and every nav entry reads the same bits. A tier without contacts.view gets a 403, not a quietly missing link.
Three security holes closed en route
  • Self-privilege escalation: any staff user could UPDATE their own profiles row, including role. Now blocked by a trigger, and there is a structural test requiring every self-service UPDATE policy to carry an explicit WITH CHECK.
  • Anonymous audit-log forgery — closed in the same migration.
  • Google OAuth: an external-domain sign-in used to leave behind a foreman profile (trigger default), which then passed the forgot-password gate and unlocked password login with foreman access. First sign-ins from allowed domains now auto-provision employee; rejected external sign-ins get their stray auth row deleted.

Behaviour changes to know about:

  • The Function Visibility section on /settings/general is gone. Branding (logo) is all that's left there — use /settings/roles instead.
  • Foreman now has records.view. It was seeded false from the old matrix, which under the new hard-block semantics would have 403'd foremen out of the induction approval screen they work in daily. Caught by e2e.
  • Leading Hand lost timesheets.view (see Nav shell — they gained a working path to their own timesheet instead).

Design record: docs/architecture/feature-roles-permissions.md.


2. Timesheets

An employee reports their own hours per site, per day. Each entry is approved by the foreman of that site — so a two-site day has two approvers. Admin is the fallback approver, locks the week for payroll, and exports CSV.

Decisions worth knowing:

  • Self-approval is banned in RLS, not in app code. A foreman's own hours route to the admin queue.
  • Entries are split per site because a different site means a different approver. That split is also what makes cross-site double-dipping visible, which is why approvers can read a little beyond their own sites.
  • Entries are entirely manual. No foreign key, no trigger, no link to staff_checkins. Check-in times are a one-tap UI suggestion only.
  • Overlapping entries never block submission, but approving one requires a named confirmation — enforced by a trigger, so the API can't bypass it. Approve-all skips overlaps.
  • Locking blocks INSERT/UPDATE but not DELETE — guarding DELETE made any employee with a locked timesheet undeletable (deleting an auth user cascades down to entries).
  • Deleting a site with timesheets returns 409 SITE_HAS_TIMESHEETS rather than a bare FK error. Timesheets are payroll evidence; they don't vanish with a site. Archiving a site is unaffected.
  • The 18:00 auto-checkout cron now tails a daily digest email to each employee whose hours someone changed that day.

Design record: docs/architecture/feature-timesheets.md.


3. Modular dashboard

Ten hand-written {#if} blocks became a 12-widget catalogue composed per tier at /settings/dashboards (tier list · 12-column canvas · widget library).

  • Two of those old blocks were asking the wrong permission bit — a tier that could approve timesheets silently saw no timesheet card.
  • The loader now takes data by layout instead of running the same fixed batch for everyone: an employee's dashboard went from 9 queries to 3, a foreman's from 17 to 11. Measured, not estimated.
  • A tier with no stored layout means "never customised" → it uses the code default. Reset to default is a delete, not a second copy of the defaults.
  • If you arrange a layout and later turn that area's view off, the card is marked red in the composer. Without that marker it looks fine to you and silently vanishes for the user — which is the whole reason the page exists.
  • The composer validates against the target tier's grid, not yours, so you can't store rows that tier will never render.

Design record: docs/architecture/feature-dashboard.md.


4. Nav shell — and the discovery behind it

The word "check in" meant two unrelated things: attendance (staff_checkins — safety, muster, roll call) and hours (timesheet_entry — payroll). The database decoupled them back in the timesheets phase. The interface hadn't, and it caused two real injuries:

  1. A Leading Hand is required to log a timesheet and had no way to find it. Their nav entry was gated on timesheets.view, which was correctly turned off (they can't do anything in an approval queue) — and /timesheets 403'd before the page could redirect. Their only remaining path was a dashboard card that an admin could delete.
  2. One "Timesheets" label, three behaviours. Only admin/CM/foreman can actually approve; everyone else holding the view bit got a silent 307 — employee to their own week, office to a dead empty state.

What shipped:

  • The nav entry is split in two: Timesheet approvals (gated on .approve) and My timesheet (gated on the per-person requires_timesheet, the one entry in that bar the grid doesn't decide). Office and Employee lose the approvals link and lose no capability — that link only ever redirected them away.
  • Site pill, top-right: says On site · K3, or On 3 sites when you have several open (being on two sites at once is legal and routine, so it never picks one to name). Lists every open shift with one-tap Check out, hosts the check-in flow, and shows the day's brief after you check in. Attendance only — carries a single Hours are logged separately → signpost.
  • Grouped desktop nav: Dashboard · Projects · Site Ops ▾ · Safety ▾ · Records · Contacts. Groups are fixed, membership is derived — an entry you can't use is dropped, and a group whose members are all dropped disappears.
  • Re-induction has a nav entry for the first time (gated on inductions.approve).
  • Mobile: four fixed tabs — Today · On site · Hours · More. The hamburger is gone; More replaced it, and its Admin group exists because the avatar dropdown is desktop-only — without it an admin on a phone couldn't reach the back office at all.
  • Nav structure now lives in one file (nav.ts) read by all three consumers, so "which bit gates Manage Users" has exactly one answer.

Design record: docs/architecture/feature-nav-shell.md.


5. Inventory overhaul

Full rewrite across seven commits: desktop console + mobile field view on /inventory/[siteId] (device-dispatched), a rewritten dashboard, a single-site unit-card board for layouts, and a per-site catalog view for products.

  • Destructive actions here use inline arm→confirm (first tap arms a red pill), not modals and not hold-to-confirm — safety is compensated by undo everywhere (6s snackbar, restore-from-snapshot).
  • Backend fixes: the status-workflow trigger was blocking all backward moves including the RPC's own revert branch; foreman deletes on items and layouts were silent no-ops (no RLS policy, and the endpoint reported a fake count); a batch PO write happened even when the status transition failed.
  • New endpoints: atomic drag-copy, restore, copy-unit-to-level.
  • Old URLs 301 to the new pages. Dead components and two superseded endpoints removed.

6. Login & shell polish

  • Login-form flash fixed. Returning users saw the landing login form for ~2s because / had no server load. Now the landing page renders a "Signing you in…" transition card server-side and hands off to the client — plus a ?redirectTo= round-trip so deep links survive login. Also closed a pre-existing //host open redirect in the OAuth callback.
  • Dark-mode black screen on sign-in fixed. The dashboard's load now returns immediately and streams; the page keeps last-resolved data during invalidateAll() instead of flashing a skeleton. Two race conditions surfaced and fixed while doing it.
  • Fixed layers were sinking to the bottom of the page. PullToRefresh carried transform + will-change unconditionally, which establishes a containing block for position: fixed — so the batch bar, undo snackbar, TrashDock and toasts all anchored to the content wrapper instead of the viewport. Now applied only during a pull.
  • Admins can edit a user's login email.
  • /timesheets/me hydration crash fixed: one gap card per site, not per check-in (same site twice in a day is legal — lunch, a trip out and back).

If you hit anything unexpected — a screen you can no longer reach, a nav entry that should be there and isn't, a timesheet that won't approve — reach out.

GZ.