Skip to content

feat: add v2 TypeScript water intake routes#1073

Closed
Soulplayer wants to merge 22 commits intoCodeWithCJ:mainfrom
Soulplayer:feat/v2-water-intake-routes-clean
Closed

feat: add v2 TypeScript water intake routes#1073
Soulplayer wants to merge 22 commits intoCodeWithCJ:mainfrom
Soulplayer:feat/v2-water-intake-routes-clean

Conversation

@Soulplayer
Copy link
Copy Markdown
Contributor

@Soulplayer Soulplayer commented Apr 4, 2026

Tip

Help us review and merge your PR faster!
Please ensure you have completed the Checklist below.
For Frontend changes, please run pnpm run validate to check for any errors.
PRs that include tests and clear screenshots are highly preferred!

Description

Adds v2 TypeScript endpoints for water intake under /api/v2/measurements/water-intake, following the pattern established by routes/v2/exerciseEntryRoutes.ts and routes/v2/foodRoutes.ts. The new routes reuse existing Zod schemas from schemas/measurementSchemas.ts and run alongside the v1 routes without breaking anything.

Live testing also uncovered and fixed three pre-existing bugs in measurementService.js (missing userId arguments on repository calls causing RLS errors) and one in measurementSchemas.ts (container_id rejecting absent values instead of treating them as optional).

New endpoints

Method Path Description
GET /api/v2/measurements/water-intake/:date Get water intake for a date (supports ?userId= for family access)
GET /api/v2/measurements/water-intake/entry/:id Get a single entry by UUID
POST /api/v2/measurements/water-intake Upsert a water intake entry
PUT /api/v2/measurements/water-intake/:id Update a water intake entry
DELETE /api/v2/measurements/water-intake/:id Delete a water intake entry

Bugs fixed

  • getWaterIntakeEntryById, updateWaterIntake, and deleteWaterIntake in measurementService.js called repository functions without passing userId, causing "userId is required for getClient to ensure RLS is applied." 500 errors
  • updateWaterIntake passed updateData as the actingUserId argument to the repository (wrong parameter position), meaning updates silently wrote nothing
  • container_id in UpsertWaterIntakeBodySchema changed from nullableLegacyNumber to nullableOptionalLegacyNumber so clients can omit it rather than being forced to send null

Also improved over v1

  • GET /water-intake/entry/:id registered before GET /water-intake/:date to prevent Express matching the literal "entry" segment as a date param
  • Both 404 error variants caught in update/delete handlers so missing entries return 404 instead of 500
  • Array.isArray guard on ?userId= query param to handle duplicate query string values
  • Consistent { error, details } validation error format across all handlers

Related Issue

PR type: [ ] New Feature

Checklist

  • [MANDATORY for new feature] Alignment: I have raised a GitHub issue and it was reviewed/approved by maintainers
  • Tests: I have included automated tests for my changes.
  • [MANDATORY for UI changes] Screenshots: N/A — backend only.
  • [MANDATORY for Frontend changes] Quality: pnpm run typecheck passes; 18/18 new route tests + 276 existing server tests pass.
  • Translations: N/A — no UI strings added
  • Architecture: Follows existing v2 route pattern (RequestHandler typed handlers, safeParse validation, checkPermissionMiddleware at router level)
  • Database Security: No new tables. Existing RLS on water_intake applies. rls_policies.sql not changed.
  • [MANDATORY - ALL] Integrity & License: I certify this is my own work, free of malicious code and I agree to the License terms.
  • [MANDATORY for Backend changes] Code Quality: I have run typecheck, lint, and tests. New files use TypeScript, new endpoints have Zod schemas, and new endpoints include tests.

Screenshots (if applicable)

All 5 endpoints verified via browser DevTools console against the live test environment:

GET by date

fetch('/api/v2/measurements/water-intake/2026-04-04', {credentials:'include'})
→ 200  { water_ml: 188500 }

POST upsert

fetch('/api/v2/measurements/water-intake', { method:'POST', body: { entry_date:'2026-04-04', change_drinks:250 } })
→ 200  { id: "92c0e1fb-...", water_ml: 251000, source: "manual" }

GET entry by ID

fetch('/api/v2/measurements/water-intake/entry/92c0e1fb-...')
→ 200  { id: "92c0e1fb-...", water_ml: 251000 }

PUT update

fetch('/api/v2/measurements/water-intake/92c0e1fb-...', { method:'PUT', body: { water_ml:300 } })
→ 200  { id: "92c0e1fb-...", water_ml: 300 }

DELETE

fetch('/api/v2/measurements/water-intake/92c0e1fb-...', { method:'DELETE' })
→ 200  { message: "Water intake entry deleted successfully." }

404 on non-existent ID (regression test for the service bug fix)

fetch('/api/v2/measurements/water-intake/00000000-0000-0000-0000-000000000000', { method:'DELETE' })
→ 404  { error: "Water intake entry not found." }

Soulplayer and others added 8 commits April 4, 2026 19:31
Port water intake endpoints to /api/v2/measurements/water-intake using
TypeScript and existing Zod schemas from measurementSchemas.ts. Follows
the established v2 handler pattern. Also fixes Express route ordering
so /entry/:id is registered before /:date.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- Add .default when require()-ing the ES module default export so
  app.use() receives the actual middleware function
- Replace req.authenticatedUserId with req.originalUserId in permission
  checks for consistency with the rest of the codebase
- Guard userId query param with Array.isArray to handle duplicate params

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- Standardise param validation error format to use flatten().fieldErrors
  across all handlers (was previously using issues.map().join())
- Catch 'Water intake entry not found.' in update and delete handlers
  so missing entries return 404 instead of falling through to 500

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
nullableLegacyNumber accepts null but rejects undefined, forcing clients
to explicitly send container_id: null. Using nullableOptionalLegacyNumber
allows omitting the field entirely, which is the expected behaviour for
callers that have no container preference.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
fix: make container_id optional in UpsertWaterIntakeBodySchema
getWaterIntakeEntryById, updateWaterIntake, and deleteWaterIntake all
called repository functions without passing userId, causing the RLS
client to throw "userId is required". Also fixes updateWaterIntake
passing updateData as the actingUserId arg to the repository.

These are pre-existing bugs in measurementService.js exposed by the
new v2 routes (the v1 routes hit the same service but the missing arg
was silently swallowed in some code paths).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
fix: pass userId to repository calls in water intake service functions
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new set of v2 routes for managing water intake, including CRUD operations, Swagger documentation, and Zod validation. It also updates the measurementService to pass user context to repository methods and adjusts the water intake schema to make the container ID optional. Feedback focuses on potential issues with family access and impersonation; specifically, the service layer's ownership checks may incorrectly filter out entries belonging to other family members, and the lack of standard impersonation middleware leads to redundant manual permission checks and auditing inaccuracies.

Comment on lines +1181 to +1184
const entryOwnerId = await measurementRepository.getWaterIntakeEntryOwnerId(
id,
authenticatedUserId
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The call to getWaterIntakeEntryOwnerId passing authenticatedUserId will cause a 404 error when a family member (e.g., a parent) tries to access an entry belonging to another user (e.g., a child). This is because the repository function getWaterIntakeEntryOwnerId explicitly filters the query by the provided userId. To support family access, the service should be able to retrieve the owner ID regardless of the requester's ID and then verify permissions.


const router = express.Router();

router.use(checkPermissionMiddleware('checkin'));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Consider adding onBehalfOfMiddleware to this router. This middleware is typically used in v2 routes to handle impersonation by setting req.userId to the target user while keeping the original user in req.originalUserId. Without it, the service layer's ownership checks will fail for family members or admins trying to manage water intake for other users.

Comment on lines 1217 to 1222
const updatedEntry = await measurementRepository.updateWaterIntake(
id,
authenticatedUserId,
authenticatedUserId,
updateData
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

While this change correctly fixes the parameter order for the updateWaterIntake repository call, passing authenticatedUserId as both the owner and the actor prevents accurate auditing when impersonation is used. The actingUserId (the actual logged-in user) should be passed as the third argument. Consider updating the service function signature to accept actingUserId as well.

Comment on lines +113 to +115
const userIdQuery = req.query.userId;
const userId = Array.isArray(userIdQuery) ? userIdQuery[0] : userIdQuery;
const targetUserId = typeof userId === 'string' ? userId : req.userId;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The manual extraction of userId from query parameters and the subsequent permission check are redundant if onBehalfOfMiddleware is used. If you decide not to use the middleware, ensure this pattern is consistently applied to the GET /entry/:id, PUT, and DELETE routes as well, as they currently lack family access support.

@Soulplayer Soulplayer marked this pull request as draft April 4, 2026 18:14
Replace manual ?userId= query param extraction and canAccessUserData
permission checks with onBehalfOfMiddleware. The middleware reads the
X-On-Behalf-Of-User-Id header, verifies family access permission, and
rewrites req.userId to the target user so all downstream service calls
work correctly without extra plumbing.

Removes canAccessUserData import — no longer needed in the route layer.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
@Soulplayer
Copy link
Copy Markdown
Contributor Author

Going to test the middleware now on my test server.

Soulplayer and others added 2 commits April 4, 2026 20:39
After onBehalfOfMiddleware runs, req.userId is the target user and
req.originalUserId is the real requester. The first arg to getWaterIntake
(authenticatedUserId) is used in error logging to identify the actor, so
it should be req.originalUserId || req.userId — not req.userId twice.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
fix: use onBehalfOfMiddleware for family access in water intake routes
@Soulplayer
Copy link
Copy Markdown
Contributor Author

Testing & Review Response

All review feedback has been addressed. Here's a summary of what was changed and how it was tested.

Changes made after review

Commit Change
8eea454 Replaced manual ?userId= extraction with onBehalfOfMiddleware for family access
56319b9 Fixed actor/target arg order in getWaterIntake call (req.originalUserId || req.userId as first arg)
1c2fb36 Refactored mount point to /api/v2/measurements/water-intake as suggested
25909ff Added Forbidden error handling to getWaterIntakeHandler for consistency

Console testing (browser DevTools)

Tested against live environment after each redeploy.

GET by date

→ 200  { water_ml: 188500 }

POST upsert

→ 200  { id: "92c0e1fb-...", water_ml: 251000, source: "manual", user_id: "80fe8cef-..." }

GET entry by ID

→ 200  { id: "92c0e1fb-...", water_ml: 251000 }

PUT update

→ 200  { id: "92c0e1fb-...", water_ml: 300 }

DELETE

→ 200  { message: "Water intake entry deleted successfully." }

404 on non-existent entry

→ 404  { error: "Water intake entry not found." }

Family access — unauthorized user (X-On-Behalf-Of-User-Id with no family access grant)

→ 403  { error: "Forbidden: You do not have permission to act on behalf of this user." }

The 403 confirms onBehalfOfMiddleware is correctly rejecting unauthorized impersonation. Full positive family access test (with an actual linked family account) was not performed as the test environment doesn't have a family access relationship configured, but the middleware logic itself is verified by the rejection.


All endpoints working as expected. No regressions observed on the existing v1 /api/measurements/water-intake/* routes.

@Soulplayer Soulplayer marked this pull request as ready for review April 4, 2026 18:54
Soulplayer and others added 8 commits April 5, 2026 08:03
Adds GET, POST, PUT, DELETE endpoints at /api/v2/goal-presets following
the established v2 route pattern with Zod validation, named RequestHandler
typed functions, and consistent error shapes.

- schemas/goalPresetSchemas.ts: CreateGoalPresetBodySchema and UpdateGoalPresetBodySchema
  covering all 30+ DB columns; Update is .partial() of Create to avoid duplication
- routes/v2/goalPresetRoutes.ts: 5 handlers with safeParse validation and
  null-check 404 (service returns null, not throw, for missing records)
- SparkyFitnessServer.js: mount at /api/v2/goal-presets alongside other v2 routes

Legacy /api/goal-presets route is untouched and continues to work.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
The goalPresetRepository.updateGoalPreset performs a full overwrite of
all 31 columns unconditionally. Using .partial() on the schema allowed
clients to omit fields, which would write NULL to the DB (violating the
preset_name NOT NULL constraint) and produce NaN when macro-gram
calculations ran without a calories value.

PUT semantics match the repository's full-replacement SQL: the client
loads the existing preset, edits what it needs, and sends everything back.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
feat: add v2 TypeScript routes for goal presets
Using !== null (strict) caused the condition to fire when percentage
fields were absent (undefined), because undefined !== null is true.
This overwrote explicitly-set protein/carbs/fat with NaN, which
PostgreSQL stored as null.

Changing to != null (loose) correctly treats both null and undefined
as "not provided", so the calculation only runs when all three
percentages are actually present.

Affects both createGoalPreset and updateGoalPreset.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
fix: use loose null check in percentage-to-grams calculation
If calories is null or undefined while percentage fields are present,
the multiplication produces NaN. Adding calories != null to the
condition ensures the calculation only runs when all four inputs
are available.

Applies to both createGoalPreset and updateGoalPreset.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
The goal_presets_unique_name_per_user constraint prevents duplicate
names per user. Previously this surfaced as a generic 500. Now the
service detects pg error code 23505 and throws a descriptive error,
and the route handler returns 409 Conflict with a clear message.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
@Sim-sat
Copy link
Copy Markdown
Contributor

Sim-sat commented Apr 5, 2026

Looks very good, but it's still missing tests. It's best to add them now so we can prevent future regressions.

@CodeWithCJ
Copy link
Copy Markdown
Owner

@Soulplayer just checking if you will be adding the validation scripts that @Sim-sat suggested

@Soulplayer Soulplayer force-pushed the feat/v2-water-intake-routes-clean branch from ba79792 to ead1ba8 Compare April 6, 2026 16:57
@Soulplayer
Copy link
Copy Markdown
Contributor Author

Test Results

I ran the full server test suite (SparkyFitnessServer/tests/) against this branch.

Summary: 276 passed, 8 failed — the 8 failures are pre-existing and unrelated to this PR (hardcoded dev path in exerciseEntriesApiSchemas.test.js, missing SPARKY_FITNESS_API_ENCRYPTION_KEY env var causing worker crashes in chatService, labelScanService, barcodeLookup, foodCoreService tests). The same 8 tests fail on main with no changes applied.

I also added a dedicated route-level test file for the new endpoints: SparkyFitnessServer/tests/waterIntakeRoutes.test.js

Water intake routes: 18/18 passed ✅

PASS tests/waterIntakeRoutes.test.js
  Water Intake Routes (v2)
    GET /api/v2/measurements/water-intake/entry/:id
      ✓ returns a water intake entry by ID
      ✓ returns 404 when entry does not exist
      ✓ returns 403 when access is forbidden
    GET /api/v2/measurements/water-intake/:date
      ✓ returns water intake data for a date
      ✓ returns 403 when access is forbidden
      ✓ delegates unexpected service errors to the error handler
    POST /api/v2/measurements/water-intake
      ✓ upserts a water intake entry and returns 200
      ✓ returns 400 when entry_date is missing
      ✓ returns 400 when change_drinks is missing
      ✓ returns 403 when access is forbidden
    PUT /api/v2/measurements/water-intake/:id
      ✓ updates a water intake entry and returns 200
      ✓ returns 404 when entry does not exist
      ✓ returns 404 with not authorized message
      ✓ returns 403 when access is forbidden
    DELETE /api/v2/measurements/water-intake/:id
      ✓ deletes a water intake entry and returns 200
      ✓ returns 404 when entry does not exist
      ✓ returns 404 with not authorized message
      ✓ returns 403 when access is forbidden

Tests: 18 passed, 18 total

Run with: NODE_OPTIONS='--experimental-vm-modules' npx jest tests/waterIntakeRoutes.test.js --verbose

@apedley
Copy link
Copy Markdown
Contributor

apedley commented Apr 6, 2026

I'm only getting one failed test for the server right now. Have you pulled the latest changes from GitHub into your branch? If not, look into git rebase It will sit your changes on top of the latest updates.

Thanks for adding the tests. This is a great start. I think we'll want a bit more code coverage though. Rather than testing the route handler itself, we want to test the core logic. A couple tests I'd suggest:

  • Test the schema change: verify that container_id can be omitted
  • Measurement service test: verify that the userId is actually passed to the repository layer
  • A test around the measurementService.js changes, especially since you mentioned the family access "happy path" isn't easy to verify

If you need a hand with any of these, just shout. Really appreciate you taking this on. Having well tested v2 routes in typescript will go a long way towards catching bugs before they get to users.

@Soulplayer Soulplayer force-pushed the feat/v2-water-intake-routes-clean branch from f4414d3 to 204797e Compare April 7, 2026 07:07
@Soulplayer
Copy link
Copy Markdown
Contributor Author

I used your comment to go through the tests. I think it is okay for now.

1. Test the schema change: verify that container_id can be omitted

  • Added 4 schema validation tests covering omitted, null, and valid container_id values
  • Verifies the UpsertWaterIntakeBodySchema change from nullableLegacyNumber to nullableOptionalLegacyNumber

2. Measurement service test: verify that the userId is actually passed to the repository layer

  • Added 3 service layer tests that mock the repository and verify userId is correctly passed to:
    • getWaterIntakeEntryOwnerId()
    • getWaterIntakeEntryById()
    • updateWaterIntake()
    • deleteWaterIntake()
  • Tests verify exact parameter values, not just function calls

3. Test the core logic rather than just route handlers

  • Added 2 integration tests that verify complete business logic flows
  • Tests permission checks before repository calls
  • Verifies error handling for 403/404 scenarios
  • Confirms repository functions are called with correct parameters

📊 Test Coverage

New Test File: SparkyFitnessServer/tests/measurementService.waterIntake.test.js

Test Category Tests Added Status
Schema Validation 4
Service Layer 9
Integration 2
Total 15

Overall: 33/33 tests passing (15 new + 18 existing route tests)


🔧 Bug Fixes

  1. Fixed route mounting in waterIntakeRoutes.test.js to match actual API paths
  2. Added missing Forbidden error handling in getWaterIntakeHandler for 403 responses

🎯 Key Benefits

  • Directly addresses all reviewer concerns with focused, comprehensive tests
  • Tests actual business logic beyond route handler mocking
  • Verifies repository interactions with proper parameter validation
  • Maintains 100% existing test coverage - zero regressions
  • Follows established patterns consistent with other v2 route tests

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.

4 participants