Skip to content

Production release: security fixes, admin activity metrics, and the dev-vs-main review#121

Merged
OffCrazyFreak merged 45 commits into
mainfrom
dev
Jul 24, 2026
Merged

Production release: security fixes, admin activity metrics, and the dev-vs-main review#121
OffCrazyFreak merged 45 commits into
mainfrom
dev

Conversation

@OffCrazyFreak

Copy link
Copy Markdown
Owner

Production promotion. 35 commits, 47 files, +1909 / -1645.

Two high-severity security alerts, one new admin feature, one SEO regression repaired, and 31 findings from a 7-reviewer sweep of this exact diff.


1. Security

Closes Dependabot #13 and #14

Both are high severity and both are transitive, which is why Dependabot could not open a PR for either: it only edits versions you declared. They are fixed with pnpm-workspace.yaml overrides, matching the existing shell-quote / postcss / esbuild entries.

Alert Package Chain Reached production?
#13 sharp@0.34.5 (libvips CVE-2026-33327 / -33328 / -35590 / -35591) Next 16.2.11's own optionalDependencies Yes
#14 fast-uri@3.1.3 (host confusion via literal backslash authority delimiter) react-email to conf/schema-utils to ajv No, build-time only

sharp is the one that mattered. Our own sharp is a devDependency, so it is pruned from the runtime image; the copy Next resolved for production image optimization was the vulnerable 0.34.5 nested under next. The override collapses both into one 0.35.3 copy and drops 24 stale @img/sharp-* platform entries.

Next declares sharp as optionalDependencies: "^0.34.5", so forcing 0.35.3 is deliberate. Verified safe: next/dist/server/image-optimizer.js calls sharp() and nothing else, touching none of the APIs 0.35.0 removed (failOnError, paletteBitDepth, format.jp2k), and Node 24 clears the new >= 20.9.0 floor.

These alerts stay open until this merges. Dependabot only rescans the default branch.

The springdoc ignore was silently blocking security updates

Earlier in this cycle the springdoc ignore was widened and lost its update-types filter. A bare dependency-name ignore suppresses security updates too. Since SecurityConfig.java:51-55 serves /api-docs/**, /v3/api-docs/** and /swagger-ui/** with permitAll(), an unauthenticated Swagger surface pinned at 2.2.0 would never have been told it was vulnerable. Now bounded at versions: [">=2.3.0"], so the Boot-4-only line stays blocked while 2.2.x patches get through.


2. The timezone bug (highest-confidence finding in the review)

Three reviewers reached this independently from both ends.

UserDto.lastLoginAt and lastActiveAt were Java LocalDateTime, so Jackson emitted "2026-07-24T13:05:11.123" with no offset. spring.jackson.time-zone is unset and the container JVM runs UTC, so the value really was UTC, but new Date(...) in the browser parses an offset-less string as local time.

In Zagreb that meant:

  • a user who had just signed in rendered as "prije 2 sata"
  • the WAU/MAU window shifted by each admin's UTC offset, so users near the window edge landed in the wrong bucket and two admins in different timezones saw different numbers

The fix is two dependent commits, in order:

  1. 1450b55 adds nowUtc() and stamps every app_user timestamp in UTC. This was a real bug on its own: last_active_at was written with LocalDateTime.now() (JVM zone) but compared against MAX(session.updated_at), which Drizzle writes in UTC. The two only shared a clock base while the JVM happened to be UTC, so running the backend off Docker made the stamped column always win the merge.
  2. a2e2197 then changes the two DTO fields and AuthIdentity to Instant. Converting to UTC is only correct because step 1 landed first.

The wire format gains a Z. The zod schemas stay z.string(), so nothing breaks in parsing, and only the admin dashboard reads these two fields.

One subtlety worth knowing for future work: toInstant converts a Timestamp via toLocalDateTime() first on purpose. The JDBC driver builds a Timestamp for a zone-less column by reading it in the JVM zone, so calling toInstant() directly would re-apply that offset and shift the value again.


3. New feature: admin activity metrics

ec05803 adds a last-login column and WAU/MAU counters to the admin users table. It had never been reviewed by any tool before this sweep, and it accounted for the largest share of findings. What shipped is the hardened version:

  • counts no longer render a confident 0 while the query is pending or has failed (flagged by all three engines) — a skeleton shows instead
  • the last-login cell no longer crashes. formatRelativeTime has no invalid-date guard and throws RangeError on NaN, and useGetAllUsers returns raw axios data with no zod parse, unlike the contact and digital-card services
  • that cell is also SSR-stable now. It rendered Date.now()-derived text with no mount guard, so it could mismatch on hydration and then never refresh. Now uses useMinuteTick, matching the last-synced-label precedent
  • the "(WAU)" / "(MAU)" heading no longer sits over a 90-day number when the window is changed

4. SEO and rendering

c11ea04 made coming-soon nav items crawlable. The subsequent sidebar work partly undid it: SidebarProductNav called useSearchParams purely for active state, and during a static prerender a Suspense boundary emits its fallback, so the skeleton, not the Popusti / Karta / Statistika / Novosti anchors, went into the served HTML.

c0c050c introduces useClientSearchParams, which reads the query string after mount so the caller stays in the prerender. Only SidebarFilterMenu, which genuinely needs the URL, sits behind a boundary now.

Known trade, agreed during review: the ?discounted=true highlight on Popusti resolves just after hydration rather than in the first paint. Navigating /products to /products?discounted=true keeps the same pathname, so that one case settles on the next navigation. Every other entry point is correct.


5. UI regressions repaired

The sort-select to labeled-select extraction changed behaviour at its call sites. All three are user-visible and were verified against main by hand:

What Detail
Wrong label The product page's chain sort control read "Optimiziraj po:", borrowed from the shopping-list optimize control, over options still in the dative written for "Sortiraj po"
Lost layout justify-end, w-full sm:w-60 and size="sm" were dropped, so all three selects lost right alignment and mobile full width and grew taller
Unsound typing options accepted any string while onValueChange promised TValue, closed by an unchecked cast, so a typo'd mode compiled and silently never matched
No accessible name The Radix role="combobox" trigger announced only its current value. On the dashboard, two sibling cards each announced just their period with nothing tying either to WAU or MAU

6. Correctness and accessibility elsewhere

  • Shopping lists: the "parse every chain's avg_price, drop NaN, take min/max" block existed three times with three different empty-case behaviours, which is how the badge and cell rules drifted apart originally. One getChainAvgPriceRange helper now.
  • Performance: productsData was rebuilt outside any memo, taking a new identity every render and invalidating four downstream memos plus two memo() components. Now built through useQueries' combine, which TanStack memoises. First use of that option in the repo.
  • Dead code: completeStoresAnalysis was computed, threaded through two components and never destructured, implying a "best basket" marker that was never wired up.
  • WCAG 1.4.1: cheapest and most-expensive were signalled by icon and colour only, and the store-card price row by colour alone. Both now carry sr-only text, matching the precedent already set in shopping-list-item-row.
  • Croatian: "Optimiziraj po:" governs the locative; three of the four option labels were in the wrong case.

7. Dependencies

Next 16.2.11, TypeScript 6.0.3, lucide-react 1.26.0, @types/node 24, lombok 1.18.46, Sentry 8.50.1, plus the grouped frontend bump. recharts was freed from the exact-pin set; kysely stays pinned.

One reviewer finding was rejected here. It called the direct kysely dependency dead weight because nothing imports it. It is not: @better-auth/core@1.6.25 declares kysely as a non-optional peer and the root entry is what satisfies it, with drizzle-orm resolving its optional peer against the same copy. Removing it would not break the build, since auto-install-peers defaults to true, but the version would then float within ^0.28.5 || ^0.29.0 on an auth-critical package. The real defect was that nothing recorded why it exists, which is why three reviewers read it as dead. Now noted in AGENTS.md, and the stale DEPLOYMENT.md row that claimed pnpm overrides "weren't honored" (and told readers to use a direct dependency instead) has been rewritten.


8. Review provenance

7 independent reviewers over this diff: 5 Claude Opus 5 subagents at high effort, Codex gpt-5.6-sol at high, and CodeRabbit. Cursor was out of usage and did not run, so this diff has one fewer independent opinion than a full sweep; its folders were absorbed by the other three.

40 findings ranked. 31 fixed, 6 filed as issues, 1 rejected, 2 informational.

Rejected: Codex's only backend finding claimed last_active_at was added without a migration. application.properties:11 sets spring.jpa.hibernate.ddl-auto=update, so Hibernate adds the column itself.

Also worth flagging: CodeRabbit reported zero backend findings across all four Java files, while the Claude reviewers found six there including the confirmed timezone bug. Treat CR's backend pass as weak evidence.

Filed rather than fixed: #115, #116 (both pre-existing, since one reviewer widened past the branch diff), #117, #118, #119, #120.


Deploy notes

  • No new environment variables, so nothing to set in Dokploy.
  • No manual migration. ddl-auto=update adds app_user.last_active_at on boot.
  • API contract change: lastLoginAt and lastActiveAt now serialise with a Z suffix. Only the admin dashboard consumes them, and it ships in the same deploy.
  • main carries a scanner hotfix (25cbef1) that dev never received. Verified no file overlap and a clean merge, so it is preserved.

Verification

  • prettier --check and tsc --noEmit clean; mvn -B verify green on every commit of the fix branch.
  • Not yet verified on dev.disscount.me: the timezone fix end to end, the select layout at 375px, and the sidebar anchors in the served HTML. Worth a pass on dev before merging, since the timezone change alters a wire format.

OffCrazyFreak and others added 30 commits July 24, 2026 15:32
…n prerendered HTML

Changes:
- Render locked nav items as anchors carrying their real href, marked aria-disabled with tabIndex -1 and a click guard, instead of hrefless disabled buttons
- Keep the plain disabled button only for items with no page behind them, and add a shared PLACEHOLDER_HREF constant for that check
- Wrap SidebarProductNav in its own Suspense boundary with a matching skeleton, so its useSearchParams call stops taking the whole sidebar out of static HTML

An SEO audit of production found two problems. Every destination worth crawling rendered as a disabled button with no href, so /map, /statistics, /updates and /suggestions sat in sitemap.xml with zero internal links pointing at them. Separately, the landing page shipped an empty aside carrying a BAILOUT_TO_CLIENT_SIDE_RENDERING marker, because useSearchParams inside the product nav bailed the layout's fallback={null} boundary and dropped the entire sidebar from every prerendered route.

Notes:
- Access is unchanged. isLocked still gates who can navigate, non-admins get pointer-events-none and opacity-50 from the existing cva in components/ui/sidebar.tsx, and the coming-soon badges all stay. This is preparation, not a launch.
- The disabled styling needed no new CSS, sidebarMenuButtonVariants already carries aria-disabled:pointer-events-none and aria-disabled:opacity-50.
- Query-string hrefs are safe here, /products?chain=konzum already canonicals to /products.
- /map, /statistics and /updates live inside SidebarProductNav, so they are still client-filled on statically prerendered routes. They are crawlable on the dynamic ones. Footer nav links were considered and deliberately left out.
- The bailout only happens in a production build, so the Suspense fix cannot be reproduced against a dev server and needs a post-deploy check.
fix(seo): Make coming-soon nav items crawlable and keep the sidebar in prerendered HTML
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.561.0 to 1.26.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.26.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.26.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
…ntend/lucide-react-1.26.0

chore(deps): Bump lucide-react from 0.561.0 to 1.26.0 in /frontend
Changes:
- Bump org.projectlombok:lombok 1.18.30 to 1.18.46, in both the dependency and the annotationProcessorPaths entry
- Bump io.sentry:sentry-spring-boot-starter-jakarta 8.16.0 to 8.50.1
- Widen the springdoc Dependabot ignore from major-only to every update type

Taken from the backend group Dependabot proposed, minus its springdoc bump.
springdoc publishes a line per Spring Boot line and 2.8.17 is built against Boot
3.5.13, so on this project's Boot 3.1.0 it would compile but could call Spring
APIs added after 3.1 and fail at runtime. The matrix-correct pairing for Boot
3.1.x is 2.2.x, which is what stays pinned. The ignore only covered majors, so
that minor slipped through; it now covers every update type.

Notes:
- lombok compile-verifies through the annotation processor, and the changelog shows no generated-code changes for the annotations used here. The codebase has no records and no lombok.config, so the 1.18.34 `@lombok.Generated` default is a no-op.
- Sentry is configured declaratively only; none of the five `sentry.*` keys in application.properties were renamed, deprecated or removed across 8.16.0 to 8.50.1, and Boot 3.x support was never dropped.
- The backend has no tests, so CI proves compilation only. Smoke-test /api-docs and confirm Sentry still receives events after deploying to dev.
Changes:
- Bump the typescript devDependency from `^5.7` to `^6.0.3`

TypeScript 6.0 is the last release built on the JavaScript codebase and is
explicitly API-compatible with 5.9, so it is a drop-in here. Verified locally
against this branch: `next typegen` generates route types successfully and
`tsc --noEmit` is clean, with eslint reporting no errors.

Worth recording why the Dependabot PR for this failed. On that branch `next
typegen` aborted with "trying to use TypeScript but do not have the required
package(s) installed", emitted nothing, and `tsc` then failed on missing
`PageProps` and `RouteContext`. That was its regenerated lockfile rather than
TypeScript 6, which a clean `pnpm add` resolve does not reproduce. Note the
typegen step still reports success in that failure mode, because the unhandled
rejection sets no exit code, which disguises the cause as a typecheck error.

Notes:
- TypeScript 7 is deliberately not taken: it is the Go port, whose package drops the `lib/typescript.js` Compiler API that Next's checker loads, and Next 16.2.x needs an `experimental.useTypeScriptCli` opt-in for it.
- typescript-eslint 8.64 declares support for `>=4.8.4 <6.1.0`, so a future 6.1 would fall outside its tested range and emit an unsupported-version warning. Dependabot will surface that bump for review when it lands.
chore(deps): Bump lombok and Sentry, hold springdoc at 2.2.x
…range

Changes:
- Move @types/node from `^25` to `^24.13.3`
- Narrow typescript from `^6.0.3` to `~6.0.3`

@types/node majors track the Node major they describe, and this project runs
Node 24 in CI and in the Docker image. It was pinned to `^25`, a short-lived
Current-only line that reached end of life on 2026-06-01 and was never the
runtime here, so the compiler was being told about APIs that do not exist at
run time. That mismatch is invisible to tsc by construction.

typescript-eslint 8.64 declares support for `>=4.8.4 <6.1.0`, and the caret
range would have let a future 6.1 install straight past that ceiling without
review. The tilde keeps 6.0.x patches flowing and leaves the 6.1 decision to a
Dependabot PR.

Notes:
- Both flagged by a full version-compatibility audit of the stack.
- `eslint-config-next` is one release behind `next` (16.2.2 against 16.2.11); the pending frontend group PR already carries that bump, so it is deliberately not touched here to avoid a conflict.
…r-button height

Changes:
- Add components/custom/common/sort-select.tsx, a label plus dropdown generic over its mode type, rendering coming-soon options itself
- Reduce product-chain-sort-select and store-optimize-select to their option lists, dropping the duplicated wrapper markup and the string casts at both call sites
- Left align the control, keep the label and dropdown inline on mobile instead of wrapping to full width, and raise the trigger to min-h-10 so it matches the Filteri button

The two sort controls carried identical wrapper markup, so the same three layout problems had to be fixed twice. They were right aligned, the trigger went full width below the label on mobile, and size="sm" left it visibly thinner than every neighbouring control.

Notes:
- The trigger keeps the primitive's w-fit rather than a fixed width. SelectValue carries line-clamp-1, whose overflow:hidden collapses the flex item's automatic minimum size, so any fixed width plus shrinking ellipsises the option text. min-w-52 only sets a floor, so the control does not resize as options change.
- The height override is min-h-10 rather than h-10 on purpose. select.tsx sizes itself with data-[size=default]:h-9, whose attribute selector out-specifies a plain h-10 utility, so only min-height wins. min-h-* is also how MultiSelectTrigger already sizes itself.
- flex-wrap on the row is an overflow valve for viewports under roughly 320px, where the label and a 13rem dropdown no longer fit side by side.
- StoreOptimizeSelect's value prop tightened from string to StoreOptimizeMode, so its caller's handler no longer casts.
… price cells

Changes:
- Route countChainsAtExtreme through getPriceExtreme instead of comparing against a raw Math.min/Math.max result
- Take the wanted extreme as a "min" or "max" argument rather than a pickExtreme callback, since the shared helper now decides

A store chain card could show both the cheapest and the most expensive arrow while none of its products were marked. The badges counted any chain whose average price equalled the extreme, so a product priced identically at every chain made every chain both the cheapest and the most expensive for it. The price cells never had that problem because getPriceExtreme returns null when min equals max, which is exactly the guard the badge counter was missing.

Notes:
- countCheapestByChain also ranks chains in the "Zasebnim proizvodima" mode, so that ordering shifts slightly. Uniformly priced products no longer pad every chain's cheapest count, which is the intended reading.
- Still outstanding, in the other direction: the badges count only unchecked items while the table colours checked ones too, so ticking off the product behind a badge leaves a highlighted row with no badge.
…-ts-range

chore(deps): Match @types/node to the runtime and cap the TypeScript range
…badges

fix(ui): Share one sort select and stop chain price badges appearing with no marked row
Changes:
- better-auth 1.6.14 to 1.6.25 and kysely 0.28.17 to 0.29.4, together
- react and react-dom 19.2.4 to 19.2.8, @types/react to 19.2.17
- eslint-config-next 16.2.2 to 16.2.11, matching the pinned next
- pg 8.21.0 to 8.22.0 and recharts 3.9.0 to 3.10.0
- Drop the stale kysely notes from AGENTS.md and docs/DEPLOYMENT.md

Applied by hand rather than merging the Dependabot group PR, which failed to
rebase onto dev three times.

kysely could finally move because the jwtClient() plugin was removed earlier on
this branch line. better-auth widened its kysely range in 1.6.12 before fixing
the code, so 1.6.12 through 1.6.14 genuinely broke on 0.29; the fix landed in
1.6.15. With the plugin gone and better-auth on 1.6.25, the pin has no reason to
exist, so the notes explaining it are removed.

eslint-config-next is published in lockstep with next, so leaving it nine
releases behind the pinned next was drift rather than a deliberate pin.

Notes:
- Scoped to the exactly-pinned dependencies only. The caret-ranged deps are deliberately untouched, so the loose specifiers such as `^4` and `^9` keep their intended meaning.
- Verified locally: `next typegen` succeeds, `tsc --noEmit` is clean, prettier passes and eslint reports 0 errors.
- This touches the auth stack, so smoke-test login, an authenticated API call and JWT issuance on dev before promoting.
chore(deps): Bump the frontend dependency group
Changes:
- Give recharts a `^3.10.0` range instead of an exact pin
- Drop the pinning convention note from AGENTS.md

An exact version only controls what Dependabot and `pnpm add`/`update` may move
you onto, since the committed lockfile already fixes what actually installs.
That review gate is worth it for RSC-adjacent, auth and data-layer packages, and
not for a chart library, where it just produced a pull request per patch release.

The AGENTS.md note is removed rather than reworded, to keep the file lean.
chore(deps): Free recharts from the exact-pin set
Changes:
- Remove the "Versions live in frontend/package.json" line from the frontend Tech stack section
- Remove the "Versions are managed by the Spring Boot parent (3.1.0)" lead-in from the backend Tech stack section, keeping "Non-obvious notes:" for the bullets below

Both lines only restated that versions live in the manifests, which the
"Installed libs" line right below already says. The backend one also hardcoded
the Spring Boot version, which would have gone stale with the Boot 4 migration.

Notes:
- Boot 4 migration is tracked in #110.
…users

Changes:
- Add app_user.last_active_at, stamped throttled in ensureActiveProfile on every authenticated request
- Expose lastLoginAt and lastActiveAt on UserDto, sourced from better-auth session rows via the existing email backfill query
- Add AuthIdentity record so the admin list reads email, last login and last seen in one native query
- Add AdminUsersStats and AdminActivityCard with per-card trailing-window selects (default 7 and 30 days)
- Extract AdminUserRow from AdminUsersTable and add the Zadnja prijava column
- Rename SortSelect to LabeledSelect and repoint its two call sites

The admin dashboard had no way to see whether an account was still in use.
Last login answers that per user, and the WAU/MAU counters give the same
answer in aggregate over a window the admin picks.

Notes:
- Login and activity are deliberately separate signals: a long-lived session means last login can be weeks old while the user is active daily, so the counters read last_active_at, not last_login_at
- Session rows are removed on sign-out and expire after 7 days, so the durable stamped column is what makes MAU trustworthy; existing users are covered by latestOf(stamped, MAX(session.updated_at)) until the stamp catches up
- The windows are trailing presets rather than a two-ended calendar range because only the latest activity per user is stored, so a past-to-past range would undercount
- ddl-auto=update adds the nullable column on the next backend restart
Changes:
- Add a General-section bullet permitting dev server and build commands during framework or dependency migrations, on both frontend and backend

The Spring Boot 4 migration cannot be verified by reading alone, so the blanket
ban on build commands would have left an agent unable to check its own work.
Placed in General rather than a stack section, since the same reasoning applies
to a future Next.js or React major.

Notes:
- Scoped to migrations, not general development
- The backend still has no tests, so a green "mvn -B verify" proves compilation only
Changes:
- Add "sharp@<0.35.0": "^0.35.3" to the pnpm overrides
- Add "fast-uri@<3.1.4": "^3.1.4" to the pnpm overrides
- Regenerate pnpm-lock.yaml, dropping the duplicate sharp 0.34.5 tree

Dependabot alerts 13 and 14 flag two high-severity transitive deps that it
cannot patch itself, since neither is a direct dependency. sharp 0.34.5 came
in as Next 16.2.11's own optional dependency and carries the libvips CVEs
(CVE-2026-33327, -33328, -35590, -35591); it is the copy Next resolves for
production image optimization, because our own sharp is a devDependency and
is pruned from the runtime image. fast-uri 3.1.3 arrives via
react-email -> conf/schema-utils -> ajv and is build-time only.

Notes:
- Next declares sharp as optionalDependencies "^0.34.5", so forcing 0.35.3 is
  deliberate. Verified safe: its image optimizer only calls sharp(), touching
  none of the APIs 0.35.0 removed (failOnError, paletteBitDepth, jp2k) and
  Node 24 clears the new >= 20.9.0 floor.
- The override collapses the two sharp copies into one, so the lockfile loses
  24 @img/sharp-* platform binary entries and shrinks by ~276 lines.
- Same range-selector pattern as the existing shell-quote/postcss/esbuild
  overrides, so already-safe copies are untouched.
…ansitive-deps

fix(deps): Override the vulnerable transitive sharp and fast-uri copies
Changes:
- Add `versions: [">=2.3.0"]` to the springdoc ignore entry
- Note in the comment why the bound exists

A bare `dependency-name` ignore suppresses Dependabot security updates as
well as version updates. SecurityConfig serves /api-docs/**, /v3/api-docs/**
and /swagger-ui/** with permitAll(), so an unauthenticated Swagger surface
frozen at springdoc 2.2.0 would never receive a security PR. Bounding the
ignore at >=2.3.0 keeps the Boot-4-only line blocked while letting 2.2.x
patches and security fixes through.

Notes:
- Review finding 1 (reviews/REVIEW-2026-07-24-DEV-VS-MAIN.md)
- Inert until this reaches main; Dependabot reads the config from the
  default branch only.
Changes:
- Add a private nowUtc() helper to UserService and use it for all six timestamp writes
- Stamp createdAt via LocalDateTime.now(ZoneOffset.UTC) in User.onCreate

latestOf compares app_user.last_active_at against MAX(session.updated_at),
which drizzle always writes in UTC. Stamping with LocalDateTime.now() used the
JVM default zone, so the two operands only shared a clock base while the JVM
happened to run UTC. Off Docker (local dev on Europe/Zagreb) the stamped column
sat 1-2h ahead and always won the merge, and the same column held a different
clock base per environment.

Notes:
- Review finding 6 (reviews/REVIEW-2026-07-24-DEV-VS-MAIN.md)
- Prerequisite for finding 4: serialising these as UTC on the wire is only
  correct once the stored values genuinely are UTC.
- Scoped to the user domain. Other domains still stamp via BaseEntity in the
  JVM zone, which is harmless today since nothing compares them to better-auth.
Changes:
- Change UserDto.lastLoginAt / lastActiveAt and both AuthIdentity components to Instant
- Convert the app_user column with toUtcInstant() in convertToUserDto
- Replace toLocalDateTime with toInstant, handling Timestamp, Instant, OffsetDateTime
  and LocalDateTime, and logging an unmapped type instead of returning null

Jackson serialized LocalDateTime without an offset, so the browser parsed
"2026-07-24T13:05:11.123" as local time. In Zagreb that made a just-signed-in
user render as "prije 2 sata", and shifted the dashboard's WAU/MAU window by
the viewer's UTC offset so window-edge users landed in the wrong bucket and
counts differed per admin. The wire format is now "...Z".

The zod schemas stay z.string(), so parsing is unaffected; only the admin
dashboard reads these two fields.

Notes:
- Review findings 4 and 10 (reviews/REVIEW-2026-07-24-DEV-VS-MAIN.md)
- toInstant converts a Timestamp via toLocalDateTime() first on purpose: the
  driver reads a zone-less column in the JVM zone, so calling toInstant()
  directly would re-apply that offset and shift the value again.
- Depends on the preceding UTC-stamping commit; converting with ZoneOffset.UTC
  is only correct because the column is now written in UTC.
Changes:
- Add a <lombok.version> property set to 1.18.46
- Drop the hard-coded version from the lombok dependency
- Reference ${lombok.version} from the annotation processor path

The version was hard-coded in two places, so the last bump had to edit both
and a future bump that missed one would compile against a different lombok
than it declares.

Notes:
- Review finding 28 (reviews/REVIEW-2026-07-24-DEV-VS-MAIN.md)
- The property name is the one spring-boot-dependencies uses, so declaring it
  overrides the parent-managed version. Simply deleting both version elements
  would have downgraded lombok to whatever Boot 3.1.0 pins, undoing 3c40b6a.
Changes:
- Add AuthIdentityDao holding findByUserIds, deleteById and the timestamp coercion
- Inject it into UserService and drop the direct EntityManager dependency
- Remove the now-unused Timestamp, HashMap, OffsetDateTime and persistence imports

Reading and deleting rows in better-auth's `user` / `session` tables is a
different concern from the app_user profile service: that schema is owned by
the frontend's auth library and has no JPA entities, so it needs native
queries. Keeping it inline left UserService at 316 lines with two raw SQL
statements sitting next to the profile logic.

Notes:
- Review finding 34 (reviews/REVIEW-2026-07-24-DEV-VS-MAIN.md)
- UserService is now 291 lines, still above the AGENTS.md 50-100 target. The
  remaining bulk is genuine profile logic and would need a separate split.
Changes:
- Export MILLISECONDS_PER_DAY from utils/date.ts
- Import it in user-activity.ts instead of redeclaring it

AGENTS.md requires checking utils/ for an existing helper before writing a new
one. The constant already existed but was module-private, so the dashboard
util declared a second copy of the same value.

Notes:
- Review finding 31 (reviews/REVIEW-2026-07-24-DEV-VS-MAIN.md)
…ending

Changes:
- Read isLoading and isError from useGetAllUsers in AdminUsersStats
- Pass both into AdminActivityCard and render a Skeleton or the error text
- Show the count only once the query has resolved successfully

countActiveUsers(undefined, ...) returns 0, so both cards asserted a confident
"0 tjedno aktivnih korisnika" while the request was still in flight or had
failed, sitting right next to the users table's own error message. Zero was
indistinguishable from unknown.

Notes:
- Review finding 5 (reviews/REVIEW-2026-07-24-DEV-VS-MAIN.md)
- Flagged independently by all three engines, the joint-highest consensus in
  the review.
- Error copy matches the users table so the panel reads consistently.
Changes:
- Track isDefaultWindow and show the specific title plus abbreviation only then
- Fall back to the generic "Aktivni korisnici" for any other window

The heading was fixed while the window is user-selectable, so picking
"Zadnjih 90 dana" left "Tjedno aktivni korisnici (WAU)" sitting over a 90-day
number, and both cards could be set to the same window and show the identical
figure under two different labels.

Notes:
- Review finding 38 (reviews/REVIEW-2026-07-24-DEV-VS-MAIN.md)
Changes:
- Add formatDateTime to utils/strings, reusing formatDate for the date half
- Add a RelativeTime component that guards an unparsable value and waits for mount
- Render the last-login cell through it

formatRelativeTime has no invalid-date guard: it falls through its DIVISIONS
loop on NaN and calls Intl.RelativeTimeFormat.format(NaN, "years"), which
throws a RangeError and takes down the client tree. useGetAllUsers returns raw
axios data with no zod parse, unlike the contact and digital-card services, so
an unparsable lastLoginAt really can reach the cell.

The same line also read Date.now() during the server render with no mount
guard, so the relative label could mismatch on hydration and then never
refresh. RelativeTime follows the last-synced-label precedent and uses
useMinuteTick, rendering the absolute timestamp until mounted.

Notes:
- Review findings 7 and 39 (reviews/REVIEW-2026-07-24-DEV-VS-MAIN.md)
- The hydration half was not in the review; it was found while fixing finding 7.
- The tooltip now carries the time too, which finding 39 asked for: formatDate
  emits only dd.mm.yyyy., so hovering "prije 5 minuta" revealed nothing.
Changes:
- Split the shared comment so each field describes itself
- Note that lastActiveAt is present on every UserDto, not just the admin list

The single "Admin list only" comment covered both fields but was true only for
lastLoginAt: convertToUserDto sets lastActiveAt, so GET and PATCH /api/users/me
return it too. The comment is the only contract doc for these fields, so it
would have sent the next consumer to the admin list for data /me already has.

Notes:
- Review finding 27 (reviews/REVIEW-2026-07-24-DEV-VS-MAIN.md)
Changes:
- Point admin-activity-card and user-activity at @/lib/api/schemas/auth-user

The dashboard was split two-vs-two: the newer WAU/MAU files went through the
@/lib/api/types barrel while the user-table files imported the schema module
directly. Both resolve to the same type, but one feature folder using two
paths for one type is churn waiting to happen.

Notes:
- Review finding 40 (reviews/REVIEW-2026-07-24-DEV-VS-MAIN.md)
- Only the dashboard is normalised here. The contact-side files also use the
  barrel, but they are outside this review's scope.
Changes:
- Move ILabeledSelectOption to @/typings and make it generic over TValue
- Type options as readonly ILabeledSelectOption<TValue>[]
- Resolve the picked option instead of casting the raw string back to TValue
- Type each call site's array against its own union, adding ActivityWindowDays

options accepted any string while onValueChange promised TValue, and the gap
was closed by an unchecked `next as TValue`. A typo like "totl" compiled
cleanly and arrived at setStoreOptimizeMode as a mode that silently never
matches anything.

Moving the type also removes the coupling that made a dashboard util import
from a component module; AGENTS.md puts shared UI types in @/typings.

Notes:
- Review findings 13 and 32 (reviews/REVIEW-2026-07-24-DEV-VS-MAIN.md)
- Finding 13 was flagged by all three engines.
- The default type parameter keeps ILabeledSelectOption usable unparameterised.
Changes:
- Generate a useId for the label span
- Reference it from SelectTrigger via aria-labelledby

The label was only visually adjacent, so the Radix role="combobox" trigger took
its accessible name from the current value. A screen reader announced
"Trgovinama, combobox" with no indication of what the control does, at every
call site. On the dashboard it was worse: two sibling cards each announced only
their selected period, with nothing tying either to WAU or MAU.

Notes:
- Review finding 14 (reviews/REVIEW-2026-07-24-DEV-VS-MAIN.md)
- Flagged by all three engines, the broadest consensus in the review.
- aria-labelledby rather than aria-label so the announced name stays in sync
  with the visible text.
Changes:
- Put justify-end back on the wrapper and accept a className override
- Restore the trigger's w-full sm:w-60 and size="sm"
- Drop the comment explaining the min-h workaround it no longer needs

Extracting sort-select into LabeledSelect silently lost three layout classes
that both original call sites had: the control stopped being right-aligned,
stopped filling the width on mobile, and grew from the small trigger to the
default height. That is a visible regression on the product detail page and
the shopping-list stores panel.

The activity cards opt out of the right alignment, since they sit under a
left-aligned number rather than above a list.

Notes:
- Review findings 12 and 36 (reviews/REVIEW-2026-07-24-DEV-VS-MAIN.md)
- Only Codex caught this one.
- Verified the wrapper and trigger classes now match main, modulo the
  tailwind plugin's class ordering.
Changes:
- Restore the sort wording on the product detail chain control

The LabeledSelect extraction swapped in "Optimiziraj po:", borrowed from the
shopping-list optimize control, which is a different concept. This picks how
the chains carrying one product are ordered, and its options are still the
dative "Trgovinama / Cijeni / Udaljenosti" written for "Sortiraj po", so the
control mislabelled itself on every product detail page.

Notes:
- Review finding 11 (reviews/REVIEW-2026-07-24-DEV-VS-MAIN.md)
- Keeps the trailing colon, which is now the convention for every label
  rendered through LabeledSelect.
Changes:
- Change the child skeletons from h-7 to h-8

The real child row is SidebarFilterMenu, whose collapsible trigger is a plain
SidebarMenuButton at the default h-8, not a SidebarMenuSubButton at h-7. Every
child row therefore jumped 4px when the Suspense boundary resolved, which is
exactly what the file's own "Mirrors the real row heights so nothing shifts"
comment promises it does not do.

Notes:
- Review finding 17 (reviews/REVIEW-2026-07-24-DEV-VS-MAIN.md)
Changes:
- Add SidebarProductNavShell holding the group, label and menu wrapper
- Render both the real nav and the skeleton through it

The group label "Istraži" and the SidebarGroup / SidebarGroupContent /
SidebarMenu structure were copy-pasted between the two files, so the skeleton
would silently stop matching the real nav as soon as either changed shape.
The h-7 versus h-8 mismatch fixed in the previous commit is what that drift
looks like in practice.

Notes:
- Review finding 35 (reviews/REVIEW-2026-07-24-DEV-VS-MAIN.md)
Changes:
- Add useClientSearchParams, a client-only query-string read
- Use it in SidebarProductNav so the group no longer bails out of prerendering
- Wrap only the filter menus in Suspense, with a new SidebarFilterMenuSkeleton
- Drop the whole-group boundary from AppSidebar and delete its skeleton

SidebarProductNav called useSearchParams purely for active-state and filter
params, so the Suspense boundary around it put a skeleton, rather than the
Popusti / Karta / Statistika / Novosti anchors, into the static HTML. Those
hrefs come from a constant and need no request data, so this partly undid the
crawlable-nav fix in c11ea04.

The anchors now render statically and only SidebarFilterMenu, which genuinely
needs the URL, sits behind a boundary.

Notes:
- Review findings 16, 17 and 35 (reviews/REVIEW-2026-07-24-DEV-VS-MAIN.md)
- Agreed trade: the ?discounted=true highlight on Popusti now resolves just
  after hydration instead of in the first paint. Navigating from /products to
  /products?discounted=true keeps the same pathname, so the highlight there
  settles on the next navigation; every other entry point is correct.
- This supersedes the whole-group skeleton that findings 17 and 35 had just
  fixed and deduplicated. The h-8 correction from 17 carries over into
  SidebarFilterMenuSkeleton, which is the only skeleton left.
…e extremes

Changes:
- Add getChainAvgPriceRange to product-price-utils and call it from all three
  duplicates (store-chain-extremes, shopping-list-items-table-utils, store-sorting-utils)
- Memoise productsData through useQueries' combine option
- Delete findCompleteStoresAnalysis, extremeByAvgPrice, ICompleteStoresAnalysis
  and the prop threading behind them
- Give the store-card header arrows aria-hidden plus sr-only text
- Split the price row into StoreCardPriceStat and add an sr-only extreme label
- Put the optimize option labels in the locative

The "parse every chain's avg_price, drop NaN, take min/max" block existed three
times with three different empty-case behaviours, which is exactly how the badge
and cell rules drifted apart before.

productsData was rebuilt outside any memo, so it took a new identity every
render and invalidated the allChains, cheapestCountByChain and
storesWithHighestPriceItems memos plus the two memo() components downstream.

completeStoresAnalysis was computed, threaded through two components and never
destructured, implying a "best basket" marker that was not wired up.

Cheapest and most-expensive were signalled by icon and colour only, with no
text alternative, even though shopping-list-item-row already sets that
precedent; the price row was colour-only, which fails WCAG 1.4.1.

Notes:
- Review findings 20, 21, 22, 23, 33, 36, 37, 40 (reviews/REVIEW-2026-07-24-DEV-VS-MAIN.md)
- combine is the repo's first use of that option; it is memoised by TanStack.
- computeAbsolutePrices and chainsStockingEveryItem are still live and untouched.
- "Optimiziraj po:" governs the locative, so three of the four labels were in
  the wrong case.
Changes:
- Raise the sharp devDependency to ^0.35.3 via pnpm add
- Note in AGENTS.md that kysely is a direct dep only to pin better-auth's peer
- Rewrite the stale DEPLOYMENT.md row that said pnpm overrides are not honored

The sharp override declares everything below 0.35.0 unsafe and forces ^0.35.3,
but the direct devDependency still asked for ^0.35.2, which the selector does
not match. That copy sat outside the override's protection and landed on a safe
version only by luck of "latest".

Three reviewers independently read kysely as dead weight because nothing
imports it. It is not: @better-auth/core declares kysely as a NON-optional peer
and the root entry is what satisfies it, with drizzle-orm resolving its own
optional peer against the same copy. Removing it would not break the build,
since auto-install-peers defaults to true, but the version would float within
"^0.28.5 || ^0.29.0" on an auth-critical package.

The DEPLOYMENT.md gotcha was the source of that confusion: it told readers
overrides do not work and to use a direct dependency instead.

Notes:
- Review findings 3, 24 and 25 (reviews/REVIEW-2026-07-24-DEV-VS-MAIN.md)
- Finding 24 asked for kysely to be removed. Rejected after checking the peer
  declarations; the real defect was the missing rationale, which is now recorded.
Changes:
- Collapse the multi-line explanatory comments added across this branch
- Run prettier over three shopping-list files the batch gate missed

AGENTS.md asks for single-line comments only where genuinely needed, and says
a multi-line explanation usually means the code should be rewritten instead.
Several comments introduced earlier on this branch broke that.

Notes:
- Comment-only for the backend; no behaviour change.
…chParams

Changes:
- AUTH.md: add AuthIdentityDao to the key-files map and note UserService stamps UTC
- AUTH.md: add three gotchas covering the naive-timestamp stack, why the two
  dashboard fields are Instant, and that lastLoginAt is derived rather than stored
- STATE-PERSISTENCE.md: expand the useSearchParams gotcha to explain that the
  Suspense fallback is what gets prerendered, and point at useClientSearchParams
- STATE-PERSISTENCE.md: correct the stale next version in the library table

The docs described the code as it was before this branch. The timezone fix and
the sidebar prerender fix both turned on behaviour that was written down
nowhere, which is why each took a full review to surface.

Notes:
- Docs only, no code change.
…2026-07

fix: Apply the dev-vs-main review findings
@netlify

netlify Bot commented Jul 24, 2026

Copy link
Copy Markdown

Deploy Preview for disscount failed. Why did it fail? →

Name Link
🔨 Latest commit 0053f37
🔍 Latest deploy log https://app.netlify.com/projects/disscount/deploys/6a63e118d44f0b000881b2a2

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@OffCrazyFreak, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 21 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1da2ac4d-e3e2-4976-b9b1-beace593d3d0

📥 Commits

Reviewing files that changed from the base of the PR and between 1227340 and 0053f37.

⛔ Files ignored due to path filters (1)
  • frontend/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (46)
  • .github/dependabot.yml
  • AGENTS.md
  • backend/pom.xml
  • backend/src/main/java/disscount/user/dao/AuthIdentityDao.java
  • backend/src/main/java/disscount/user/domain/User.java
  • backend/src/main/java/disscount/user/dto/AuthIdentity.java
  • backend/src/main/java/disscount/user/dto/UserDto.java
  • backend/src/main/java/disscount/user/service/UserService.java
  • docs/AUTH.md
  • docs/DEPLOYMENT.md
  • docs/STATE-PERSISTENCE.md
  • frontend/package.json
  • frontend/pnpm-workspace.yaml
  • frontend/src/app/(user)/shopping-lists/[id]/components/stores/shopping-list-store-card.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/components/stores/shopping-list-stores-list.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/components/stores/store-card-header.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/components/stores/store-card-price-row.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/components/stores/store-card-price-stat.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/components/stores/store-optimize-select.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/hooks/use-store-chain-analysis.ts
  • frontend/src/app/(user)/shopping-lists/[id]/typings/store-chain-types.ts
  • frontend/src/app/(user)/shopping-lists/[id]/utils/shopping-list-items-table-utils.ts
  • frontend/src/app/(user)/shopping-lists/[id]/utils/store-chain-completeness.ts
  • frontend/src/app/(user)/shopping-lists/[id]/utils/store-chain-extremes.ts
  • frontend/src/app/(user)/shopping-lists/utils/store-sorting-utils.ts
  • frontend/src/app/dashboard/components/admin-activity-card.tsx
  • frontend/src/app/dashboard/components/admin-user-row.tsx
  • frontend/src/app/dashboard/components/admin-users-stats.tsx
  • frontend/src/app/dashboard/components/admin-users-table.tsx
  • frontend/src/app/dashboard/components/dashboard-content.tsx
  • frontend/src/app/dashboard/utils/user-activity.ts
  • frontend/src/app/products/[id]/components/product-chain-sort-select.tsx
  • frontend/src/app/products/utils/product-price-utils.ts
  • frontend/src/app/products/utils/product-utils.ts
  • frontend/src/components/custom/common/labeled-select.tsx
  • frontend/src/components/custom/common/relative-time.tsx
  • frontend/src/components/custom/sidebar/sidebar-filter-menu-skeleton.tsx
  • frontend/src/components/custom/sidebar/sidebar-nav-item.tsx
  • frontend/src/components/custom/sidebar/sidebar-product-nav-shell.tsx
  • frontend/src/components/custom/sidebar/sidebar-product-nav.tsx
  • frontend/src/constants/navigation.ts
  • frontend/src/hooks/use-client-search-params.ts
  • frontend/src/lib/api/schemas/auth-user.ts
  • frontend/src/typings/labeled-select-option.ts
  • frontend/src/utils/date.ts
  • frontend/src/utils/strings.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@OffCrazyFreak
OffCrazyFreak merged commit 0fd57bc into main Jul 24, 2026
6 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Store card price colouring pools min/avg/max into one range, marking the wrong chain

1 participant