Skip to content

Commit 4ae39ea

Browse files
authored
Merge pull request #399 from ConductionNL/wip/catalog-ratings
feat(reviews): catalog-ratings — moderated ratings + close the beoordeeling authorization hole (#375)
2 parents b90cdd4 + 2832f56 commit 4ae39ea

36 files changed

Lines changed: 4187 additions & 107 deletions

appinfo/routes.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,11 +200,20 @@
200200
// until an admin approves). Anti-spam rate-limited.
201201
['name' => 'intake#submit', 'url' => '/api/intake/register', 'verb' => 'POST'],
202202

203-
// REGISTRATION MODERATION / APPROVAL QUEUE — admin-gated (isAdmin guard).
203+
// REGISTRATION / REVIEW MODERATION / APPROVAL QUEUE — admin-gated
204+
// (AuthorizedAdminSetting). Selects organisatie (default) or
205+
// beoordeeling via the `type` query param — one generalised
206+
// mechanism, see ModerationService.
204207
['name' => 'moderation#pending', 'url' => '/api/moderation/pending', 'verb' => 'GET'],
205208
['name' => 'moderation#approve', 'url' => '/api/moderation/{uuid}/approve', 'verb' => 'POST'],
206209
['name' => 'moderation#reject', 'url' => '/api/moderation/{uuid}/reject', 'verb' => 'POST'],
207210

211+
// CATALOG RATINGS (softwarecatalog#375) — authenticated review
212+
// submission (author/status always server-stamped, never from the
213+
// client) + public approved-only aggregate for module/dienst detail.
214+
['name' => 'review#submit', 'url' => '/api/reviews', 'verb' => 'POST'],
215+
['name' => 'review#aggregate', 'url' => '/api/reviews/aggregate', 'verb' => 'GET'],
216+
208217
// ORGANISATION MERGE (gemeentelijke herindeling / leveranciersovername) —
209218
// admin-gated (isAdmin guard in the controller body, no-admin-idor safe).
210219
// @spec openspec/specs/organisation-merge/spec.md#requirement-both-merge-endpoints-must-be-admin-only-with-an-explicit-per-object-authorization-guard

docs/features/catalog-ratings.md

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
3+
- SPDX-License-Identifier: EUPL-1.2
4+
-->
5+
6+
# Catalog ratings
7+
8+
Turns the previously dormant `beoordeeling` (review) schema into a working,
9+
**moderated** ratings-and-testimonials feature for modules and services —
10+
and closes the authorization hole it shipped with (world-readable, no
11+
create/update/delete rules, no attributable author). See
12+
[VNG Softwarecatalogus issue #49](https://github.com/VNG-Realisatie/Softwarecatalogus/issues/49)
13+
and softwarecatalog#375.
14+
15+
Specification: [`openspec/specs/catalog-ratings/spec.md`](../../openspec/specs/catalog-ratings/spec.md).
16+
17+
## Why it existed but didn't work
18+
19+
The `beoordeeling` schema was part of the published VNG data model but had
20+
never been wired up: `authorization` was `{"read": ["public"]}` with **no**
21+
create/update/delete rules at all, and no author or owning-organisation
22+
binding. Shipping a ratings UI on top of that as-is would have been
23+
world-readable with undefined write rules and no accountability. This
24+
change fixes the schema first, then builds the feature on top of the fixed
25+
schema.
26+
27+
## Submitting a review
28+
29+
From a module's detail page, a signed-in catalog user clicks **"Write a
30+
review"** (`ReviewsPanel.vue`, a body widget on `ModuleDetail`), which opens
31+
`SubmitReviewModal.vue`: a title, a 1-10 rating, and a testimonial. There is
32+
**no "your name" field** — the author is always the authenticated Nextcloud
33+
session, bound server-side by `ReviewService`; anything the client sends for
34+
`auteur` is discarded.
35+
36+
```
37+
POST /apps/softwarecatalog/api/reviews
38+
{ "review": {"naam": "Solid intake flow", "waardering": 9, "beschrijvingLang": "..."},
39+
"subjectType": "module", "subjectId": "<module uuid>" }
40+
```
41+
42+
Every submission lands `status: "pending"` — it is not yet visible to
43+
anyone outside the catalog's internal groups.
44+
45+
## Moderation
46+
47+
Reviews are approved/rejected through the **same** `ModerationQueue.vue`
48+
component already used for anonymous organisation registration, now
49+
parameterised by a `type` prop (`organisatie`, default, or `beoordeeling`).
50+
A second instance renders in **Settings → Review moderation**, backed by the
51+
same admin-gated `ModerationController`/`ModerationService`
52+
(`#[AuthorizedAdminSetting]`), selected via `?type=beoordeeling`. Approving
53+
sets `status: "approved"`; rejecting sets `status: "rejected"` and the
54+
review stays hidden.
55+
56+
## Fail-closed public read
57+
58+
`beoordeeling.authorization.read` is no longer an unconditional `["public"]`
59+
grant. It is `[{"group":"public","match":{"status":"approved"}}, <internal
60+
catalog groups>]` — unauthenticated readers only ever see `approved`
61+
reviews; `pending`/`rejected` reviews are invisible to them. This is
62+
declared in a new **fragment**, `lib/Settings/register.d/catalog-ratings.json`
63+
(ADR-037) — the shipped monolith `softwarecatalogus_register.json` is never
64+
edited directly (an edit there is a silent no-op on installed instances).
65+
66+
A subtlety in the fragment merge itself was fixed as part of closing this
67+
hole: the generic register-fragment merge concatenates list values (correct
68+
for most schema properties), which would have left the dangerous bare
69+
`"public"` entry in place even after the fragment "added" a narrower rule.
70+
`SettingsService::deepMergeConfig()` now replaces (rather than
71+
concatenates) list values within any `authorization` block specifically, so
72+
the fragment genuinely removes the wide-open base rule.
73+
74+
## Aggregate rating
75+
76+
Module (and, once a `DienstDetail` page exists — see Known gaps below,
77+
dienst) detail pages show an average rating + review count, computed by
78+
`ReviewAggregateService` from **approved reviews only**. A module with zero
79+
approved reviews shows a null average / zero count rather than an error.
80+
81+
## Authorization summary
82+
83+
| Action | Who |
84+
|---|---|
85+
| Read approved reviews | Anyone (public) |
86+
| Read pending/rejected reviews | Internal catalog groups + the review's own author (owner privilege) |
87+
| Create | Authenticated catalog-user groups (never anonymous) |
88+
| Update | The review's author (owner privilege) or an org-scoped admin group |
89+
| Delete | `software-catalog-admins` only, or the review's author (owner privilege) |
90+
91+
## Known gaps / follow-ups
92+
93+
- **No `DienstDetail` page yet.** The submit/aggregate backend is
94+
subject-type-agnostic (`module` or `dienst`), and `beoordeeling` already
95+
supports a `diensten` relation, but the softwarecatalog manifest has no
96+
`/diensten/:id` detail route today (`Diensten` is a `type: custom` faceted
97+
index with no per-row detail page) — that is a pre-existing gap unrelated
98+
to the authorization fix this change makes. Filed as a follow-up to wire
99+
`ReviewsPanel` onto that page once it exists.
100+
- **Residual direct-API risk.** A user already in an authorized `create`
101+
group could bypass `ReviewController` and call OpenRegister's generic
102+
object API directly, setting `auteur`/`status` themselves on that path.
103+
This is an existing, accepted trust boundary shared by every other schema
104+
in this app; the public read gate is enforced independently of which path
105+
wrote the object.
106+
107+
## Screenshots
108+
109+
Not captured in this change — per this repo's convention (see
110+
`organisation-merge.md`), Playwright screenshot capture against a live
111+
instance was out of bounds for this session (no live Nextcloud instance
112+
without touching the shared dev environment). Follow-up: capture the
113+
"Write a review" flow, the aggregate rating panel, and the review moderation
114+
queue per ADR-010 once verified against a running instance.

l10n/en_US.js

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,42 @@ OC.L10N.register(
369369
"Attach the applications that make up this suite. Only applications already in the catalogue can be attached — creating a new application is not part of this wizard." : "Attach the applications that make up this suite. Only applications already in the catalogue can be attached — creating a new application is not part of this wizard.",
370370
"Could not load applications. Please try again." : "Could not load applications. Please try again.",
371371
"Applications ({count})" : "Applications ({count})",
372-
"No applications attached yet." : "No applications attached yet."
372+
"No applications attached yet." : "No applications attached yet.",
373+
"Approve" : "Approve",
374+
"Reject" : "Reject",
375+
"Nothing to moderate" : "Nothing to moderate",
376+
"Refresh queue" : "Refresh queue",
377+
"Registration moderation" : "Registration moderation",
378+
"Review anonymous catalog registrations. Approving an entry publishes it; rejecting leaves it hidden." : "Review anonymous catalog registrations. Approving an entry publishes it; rejecting leaves it hidden.",
379+
"Loading pending registrations…" : "Loading pending registrations…",
380+
"There are no pending registrations right now." : "There are no pending registrations right now.",
381+
"Could not load the moderation queue" : "Could not load the moderation queue",
382+
"{label} approved and published" : "{label} approved and published",
383+
"{label} rejected" : "{label} rejected",
384+
"{label} has no identifier" : "{label} has no identifier",
385+
"Could not update the {label}" : "Could not update the {label}",
386+
"Review moderation" : "Review moderation",
387+
"Review pending ratings and testimonials. Approving a review publishes it; rejecting leaves it hidden." : "Review pending ratings and testimonials. Approving a review publishes it; rejecting leaves it hidden.",
388+
"Loading pending reviews…" : "Loading pending reviews…",
389+
"There are no pending reviews right now." : "There are no pending reviews right now.",
390+
"Ratings & reviews" : "Ratings & reviews",
391+
"Loading reviews" : "Loading reviews",
392+
"Could not load reviews" : "Could not load reviews",
393+
"Write a review" : "Write a review",
394+
"1 review" : "1 review",
395+
"{count} reviews" : "{count} reviews",
396+
"No reviews yet" : "No reviews yet",
397+
"Be the first to share your experience." : "Be the first to share your experience.",
398+
"Your review will be visible to other municipalities once an administrator approves it." : "Your review will be visible to other municipalities once an administrator approves it.",
399+
"Title" : "Title",
400+
"Summarise your experience in a few words" : "Summarise your experience in a few words",
401+
"Rating (1-10)" : "Rating (1-10)",
402+
"Select a rating" : "Select a rating",
403+
"Testimonial" : "Testimonial",
404+
"What was your experience with this software?" : "What was your experience with this software?",
405+
"Submit review" : "Submit review",
406+
"Thank you — your review was submitted for moderation" : "Thank you — your review was submitted for moderation",
407+
"Could not submit your review" : "Could not submit your review"
373408
},
374409
"nplurals=2; plural=(n != 1);"
375410
);

l10n/en_US.json

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,41 @@
428428
"Attach the applications that make up this suite. Only applications already in the catalogue can be attached — creating a new application is not part of this wizard.": "Attach the applications that make up this suite. Only applications already in the catalogue can be attached — creating a new application is not part of this wizard.",
429429
"Could not load applications. Please try again.": "Could not load applications. Please try again.",
430430
"Applications ({count})": "Applications ({count})",
431-
"No applications attached yet.": "No applications attached yet."
431+
"No applications attached yet.": "No applications attached yet.",
432+
"Approve": "Approve",
433+
"Reject": "Reject",
434+
"Nothing to moderate": "Nothing to moderate",
435+
"Refresh queue": "Refresh queue",
436+
"Registration moderation": "Registration moderation",
437+
"Review anonymous catalog registrations. Approving an entry publishes it; rejecting leaves it hidden.": "Review anonymous catalog registrations. Approving an entry publishes it; rejecting leaves it hidden.",
438+
"Loading pending registrations…": "Loading pending registrations…",
439+
"There are no pending registrations right now.": "There are no pending registrations right now.",
440+
"Could not load the moderation queue": "Could not load the moderation queue",
441+
"{label} approved and published": "{label} approved and published",
442+
"{label} rejected": "{label} rejected",
443+
"{label} has no identifier": "{label} has no identifier",
444+
"Could not update the {label}": "Could not update the {label}",
445+
"Review moderation": "Review moderation",
446+
"Review pending ratings and testimonials. Approving a review publishes it; rejecting leaves it hidden.": "Review pending ratings and testimonials. Approving a review publishes it; rejecting leaves it hidden.",
447+
"Loading pending reviews…": "Loading pending reviews…",
448+
"There are no pending reviews right now.": "There are no pending reviews right now.",
449+
"Ratings & reviews": "Ratings & reviews",
450+
"Loading reviews": "Loading reviews",
451+
"Could not load reviews": "Could not load reviews",
452+
"Write a review": "Write a review",
453+
"1 review": "1 review",
454+
"{count} reviews": "{count} reviews",
455+
"No reviews yet": "No reviews yet",
456+
"Be the first to share your experience.": "Be the first to share your experience.",
457+
"Your review will be visible to other municipalities once an administrator approves it.": "Your review will be visible to other municipalities once an administrator approves it.",
458+
"Title": "Title",
459+
"Summarise your experience in a few words": "Summarise your experience in a few words",
460+
"Rating (1-10)": "Rating (1-10)",
461+
"Select a rating": "Select a rating",
462+
"Testimonial": "Testimonial",
463+
"What was your experience with this software?": "What was your experience with this software?",
464+
"Submit review": "Submit review",
465+
"Thank you — your review was submitted for moderation": "Thank you — your review was submitted for moderation",
466+
"Could not submit your review": "Could not submit your review"
432467
}
433468
}

l10n/nl.js

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,42 @@ OC.L10N.register(
406406
"Attach the applications that make up this suite. Only applications already in the catalogue can be attached — creating a new application is not part of this wizard." : "Koppel de applicaties waaruit deze suite bestaat. Alleen applicaties die al in de catalogus staan kunnen worden gekoppeld — het aanmaken van een nieuwe applicatie maakt geen deel uit van deze wizard.",
407407
"Could not load applications. Please try again." : "Kon de applicaties niet laden. Probeer het opnieuw.",
408408
"Applications ({count})" : "Applicaties ({count})",
409-
"No applications attached yet." : "Nog geen applicaties gekoppeld."
409+
"No applications attached yet." : "Nog geen applicaties gekoppeld.",
410+
"Approve" : "Goedkeuren",
411+
"Reject" : "Afwijzen",
412+
"Nothing to moderate" : "Niets te modereren",
413+
"Refresh queue" : "Wachtrij vernieuwen",
414+
"Registration moderation" : "Registratiemoderatie",
415+
"Review anonymous catalog registrations. Approving an entry publishes it; rejecting leaves it hidden." : "Beoordeel anonieme catalogusregistraties. Goedkeuren publiceert een item; afwijzen houdt het verborgen.",
416+
"Loading pending registrations…" : "Openstaande registraties laden…",
417+
"There are no pending registrations right now." : "Er zijn op dit moment geen openstaande registraties.",
418+
"Could not load the moderation queue" : "Kon de moderatiewachtrij niet laden",
419+
"{label} approved and published" : "{label} goedgekeurd en gepubliceerd",
420+
"{label} rejected" : "{label} afgewezen",
421+
"{label} has no identifier" : "{label} heeft geen identificatie",
422+
"Could not update the {label}" : "Kon {label} niet bijwerken",
423+
"Review moderation" : "Beoordelingsmoderatie",
424+
"Review pending ratings and testimonials. Approving a review publishes it; rejecting leaves it hidden." : "Beoordeel openstaande waarderingen en testimonials. Goedkeuren publiceert een beoordeling; afwijzen houdt deze verborgen.",
425+
"Loading pending reviews…" : "Openstaande beoordelingen laden…",
426+
"There are no pending reviews right now." : "Er zijn op dit moment geen openstaande beoordelingen.",
427+
"Ratings & reviews" : "Waarderingen & beoordelingen",
428+
"Loading reviews" : "Beoordelingen laden",
429+
"Could not load reviews" : "Kon beoordelingen niet laden",
430+
"Write a review" : "Schrijf een beoordeling",
431+
"1 review" : "1 beoordeling",
432+
"{count} reviews" : "{count} beoordelingen",
433+
"No reviews yet" : "Nog geen beoordelingen",
434+
"Be the first to share your experience." : "Wees de eerste die een ervaring deelt.",
435+
"Your review will be visible to other municipalities once an administrator approves it." : "Uw beoordeling is zichtbaar voor andere gemeenten zodra een beheerder deze goedkeurt.",
436+
"Title" : "Titel",
437+
"Summarise your experience in a few words" : "Vat uw ervaring in een paar woorden samen",
438+
"Rating (1-10)" : "Waardering (1-10)",
439+
"Select a rating" : "Selecteer een waardering",
440+
"Testimonial" : "Testimonial",
441+
"What was your experience with this software?" : "Wat was uw ervaring met deze software?",
442+
"Submit review" : "Beoordeling indienen",
443+
"Thank you — your review was submitted for moderation" : "Bedankt — uw beoordeling is ingediend ter moderatie",
444+
"Could not submit your review" : "Kon uw beoordeling niet indienen"
410445
},
411446
"nplurals=2; plural=(n != 1);"
412447
);

0 commit comments

Comments
 (0)