From 57b55b3371272d50fa490ae78839f39908c576e8 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Wed, 11 Nov 2026 12:54:50 -0500 Subject: [PATCH] fix(admin): resolve feedback key from navigation path in mark/unmarkFeedbackIrrelevant (#3080) The markFeedbackIrrelevant / unmarkFeedbackIrrelevant actions are bound to TutorialFeedback, but the admin UI renders their buttons inside the feedbackItems LineItem on a Tutorials object page. That invokes them through the navigation path Tutorials(...)/feedbackItems()/markFeedbackIrrelevant, so req.params is [tutorialKey, feedbackKey]. The handler read req.params[0] (the tutorial key), so the feedback lookup never matched -> consistent 'Feedback entry not found' 404. Resolve the bound entity's own key from the LAST req.params entry, which works for both the direct single-key path and the two-key navigation path. Add regression tests covering the navigation-path invocation for both actions. --- srv/__tests__/tutorial-feedback.test.js | 40 +++++++++++++++++++++++++ srv/admin-service.js | 17 +++++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/srv/__tests__/tutorial-feedback.test.js b/srv/__tests__/tutorial-feedback.test.js index 7f2914974..c8b04d6b2 100644 --- a/srv/__tests__/tutorial-feedback.test.js +++ b/srv/__tests__/tutorial-feedback.test.js @@ -319,4 +319,44 @@ describe('markFeedbackIrrelevant / unmarkFeedbackIrrelevant', () => { ); expect(status).toBe(404); }); + + // Regression for #3080: the admin UI renders the action button inside the + // feedbackItems LineItem on a Tutorials object page, so it invokes the + // action through the navigation path Tutorials(...)/feedbackItems(...)/ + // markFeedbackIrrelevant. In that case req.params carries BOTH keys + // ([tutorialKey, feedbackKey]); the handler must resolve the feedback row + // from its OWN (last) key, not req.params[0] (the tutorial key). + it('marks irrelevant via the Tutorials/feedbackItems navigation path (#3080)', async () => { + const { Tutorials, TutorialFeedback } = cds.entities('com.sap.developers.ims'); + const tutorialId = cds.utils.uuid(); + const navFeedbackId = cds.utils.uuid(); + await INSERT.into(Tutorials).entries([{ ID: tutorialId, slug: 'nav-demo-tutorial' }]); + await INSERT.into(TutorialFeedback).entries([ + { ID: navFeedbackId, tutorialSlug: 'nav-demo-tutorial', npsScore: 5 } + ]); + + const { status } = await project.post( + `/admin/Tutorials(ID=${tutorialId},IsActiveEntity=true)/feedbackItems(${navFeedbackId})/markFeedbackIrrelevant`, + { reason: 'Off-topic' }, + { ...auth, validateStatus: () => true } + ); + expect(status).toBe(200); + const [row] = await SELECT.from('com.sap.developers.ims.TutorialFeedback').where({ ID: navFeedbackId }); + expect(row.isIrrelevant).toBe(true); + expect(row.irrelevantReason).toBe('Off-topic'); + + // and unmark via the same navigation path + const { status: unmarkStatus } = await project.post( + `/admin/Tutorials(ID=${tutorialId},IsActiveEntity=true)/feedbackItems(${navFeedbackId})/unmarkFeedbackIrrelevant`, + {}, + { ...auth, validateStatus: () => true } + ); + expect(unmarkStatus).toBe(200); + const [after] = await SELECT.from('com.sap.developers.ims.TutorialFeedback').where({ ID: navFeedbackId }); + expect(after.isIrrelevant).toBe(false); + expect(after.irrelevantReason).toBeNull(); + + await DELETE.from(TutorialFeedback).where({ ID: navFeedbackId }); + await DELETE.from(Tutorials).where({ ID: tutorialId }); + }); }); diff --git a/srv/admin-service.js b/srv/admin-service.js index 176ca1bb5..150171991 100644 --- a/srv/admin-service.js +++ b/srv/admin-service.js @@ -1829,10 +1829,23 @@ export default class AdminService extends cds.ApplicationService { }); // --- Feedback moderation --- + // + // #3080: the action is bound to TutorialFeedback, but the admin UI renders + // its button inside the feedbackItems LineItem on a Tutorials object page, + // so it is invoked through the navigation path + // Tutorials(...)/feedbackItems()/markFeedbackIrrelevant + // In that case req.params is [tutorialKey, feedbackKey] — the bound + // entity's own key is the LAST element, not req.params[0] (the tutorial + // key). Read the last param so BOTH the direct path (1 key) and the + // navigation path (2 keys) resolve the correct feedback row. + const feedbackKeyOf = (req) => { + const last = req.params[req.params.length - 1]; + return last?.ID ?? last; + }; this.on('markFeedbackIrrelevant', 'TutorialFeedback', async (req) => { const { TutorialFeedback } = cds.entities('com.sap.developers.ims'); - const id = req.params[0]?.ID ?? req.params[0]; + const id = feedbackKeyOf(req); if (!id) return req.error(400, 'Missing entity key'); const row = await SELECT.one.from(TutorialFeedback).where({ ID: id }); if (!row) return req.error(404, 'Feedback entry not found'); @@ -1842,7 +1855,7 @@ export default class AdminService extends cds.ApplicationService { this.on('unmarkFeedbackIrrelevant', 'TutorialFeedback', async (req) => { const { TutorialFeedback } = cds.entities('com.sap.developers.ims'); - const id = req.params[0]?.ID ?? req.params[0]; + const id = feedbackKeyOf(req); if (!id) return req.error(400, 'Missing entity key'); const row = await SELECT.one.from(TutorialFeedback).where({ ID: id }); if (!row) return req.error(404, 'Feedback entry not found');