Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -200,11 +200,20 @@
// until an admin approves). Anti-spam rate-limited.
['name' => 'intake#submit', 'url' => '/api/intake/register', 'verb' => 'POST'],

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

// CATALOG RATINGS (softwarecatalog#375) — authenticated review
// submission (author/status always server-stamped, never from the
// client) + public approved-only aggregate for module/dienst detail.
['name' => 'review#submit', 'url' => '/api/reviews', 'verb' => 'POST'],
['name' => 'review#aggregate', 'url' => '/api/reviews/aggregate', 'verb' => 'GET'],

// ORGANISATION MERGE (gemeentelijke herindeling / leveranciersovername) —
// admin-gated (isAdmin guard in the controller body, no-admin-idor safe).
// @spec openspec/specs/organisation-merge/spec.md#requirement-both-merge-endpoints-must-be-admin-only-with-an-explicit-per-object-authorization-guard
Expand Down
114 changes: 114 additions & 0 deletions docs/features/catalog-ratings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<!--
- SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
- SPDX-License-Identifier: EUPL-1.2
-->

# Catalog ratings

Turns the previously dormant `beoordeeling` (review) schema into a working,
**moderated** ratings-and-testimonials feature for modules and services —
and closes the authorization hole it shipped with (world-readable, no
create/update/delete rules, no attributable author). See
[VNG Softwarecatalogus issue #49](https://github.com/VNG-Realisatie/Softwarecatalogus/issues/49)
and softwarecatalog#375.

Specification: [`openspec/specs/catalog-ratings/spec.md`](../../openspec/specs/catalog-ratings/spec.md).

## Why it existed but didn't work

The `beoordeeling` schema was part of the published VNG data model but had
never been wired up: `authorization` was `{"read": ["public"]}` with **no**
create/update/delete rules at all, and no author or owning-organisation
binding. Shipping a ratings UI on top of that as-is would have been
world-readable with undefined write rules and no accountability. This
change fixes the schema first, then builds the feature on top of the fixed
schema.

## Submitting a review

From a module's detail page, a signed-in catalog user clicks **"Write a
review"** (`ReviewsPanel.vue`, a body widget on `ModuleDetail`), which opens
`SubmitReviewModal.vue`: a title, a 1-10 rating, and a testimonial. There is
**no "your name" field** — the author is always the authenticated Nextcloud
session, bound server-side by `ReviewService`; anything the client sends for
`auteur` is discarded.

```
POST /apps/softwarecatalog/api/reviews
{ "review": {"naam": "Solid intake flow", "waardering": 9, "beschrijvingLang": "..."},
"subjectType": "module", "subjectId": "<module uuid>" }
```

Every submission lands `status: "pending"` — it is not yet visible to
anyone outside the catalog's internal groups.

## Moderation

Reviews are approved/rejected through the **same** `ModerationQueue.vue`
component already used for anonymous organisation registration, now
parameterised by a `type` prop (`organisatie`, default, or `beoordeeling`).
A second instance renders in **Settings → Review moderation**, backed by the
same admin-gated `ModerationController`/`ModerationService`
(`#[AuthorizedAdminSetting]`), selected via `?type=beoordeeling`. Approving
sets `status: "approved"`; rejecting sets `status: "rejected"` and the
review stays hidden.

## Fail-closed public read

`beoordeeling.authorization.read` is no longer an unconditional `["public"]`
grant. It is `[{"group":"public","match":{"status":"approved"}}, <internal
catalog groups>]` — unauthenticated readers only ever see `approved`
reviews; `pending`/`rejected` reviews are invisible to them. This is
declared in a new **fragment**, `lib/Settings/register.d/catalog-ratings.json`
(ADR-037) — the shipped monolith `softwarecatalogus_register.json` is never
edited directly (an edit there is a silent no-op on installed instances).

A subtlety in the fragment merge itself was fixed as part of closing this
hole: the generic register-fragment merge concatenates list values (correct
for most schema properties), which would have left the dangerous bare
`"public"` entry in place even after the fragment "added" a narrower rule.
`SettingsService::deepMergeConfig()` now replaces (rather than
concatenates) list values within any `authorization` block specifically, so
the fragment genuinely removes the wide-open base rule.

## Aggregate rating

Module (and, once a `DienstDetail` page exists — see Known gaps below,
dienst) detail pages show an average rating + review count, computed by
`ReviewAggregateService` from **approved reviews only**. A module with zero
approved reviews shows a null average / zero count rather than an error.

## Authorization summary

| Action | Who |
|---|---|
| Read approved reviews | Anyone (public) |
| Read pending/rejected reviews | Internal catalog groups + the review's own author (owner privilege) |
| Create | Authenticated catalog-user groups (never anonymous) |
| Update | The review's author (owner privilege) or an org-scoped admin group |
| Delete | `software-catalog-admins` only, or the review's author (owner privilege) |

## Known gaps / follow-ups

- **No `DienstDetail` page yet.** The submit/aggregate backend is
subject-type-agnostic (`module` or `dienst`), and `beoordeeling` already
supports a `diensten` relation, but the softwarecatalog manifest has no
`/diensten/:id` detail route today (`Diensten` is a `type: custom` faceted
index with no per-row detail page) — that is a pre-existing gap unrelated
to the authorization fix this change makes. Filed as a follow-up to wire
`ReviewsPanel` onto that page once it exists.
- **Residual direct-API risk.** A user already in an authorized `create`
group could bypass `ReviewController` and call OpenRegister's generic
object API directly, setting `auteur`/`status` themselves on that path.
This is an existing, accepted trust boundary shared by every other schema
in this app; the public read gate is enforced independently of which path
wrote the object.

## Screenshots

Not captured in this change — per this repo's convention (see
`organisation-merge.md`), Playwright screenshot capture against a live
instance was out of bounds for this session (no live Nextcloud instance
without touching the shared dev environment). Follow-up: capture the
"Write a review" flow, the aggregate rating panel, and the review moderation
queue per ADR-010 once verified against a running instance.
37 changes: 36 additions & 1 deletion l10n/en_US.js
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,42 @@ OC.L10N.register(
"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.",
"Could not load applications. Please try again." : "Could not load applications. Please try again.",
"Applications ({count})" : "Applications ({count})",
"No applications attached yet." : "No applications attached yet."
"No applications attached yet." : "No applications attached yet.",
"Approve" : "Approve",
"Reject" : "Reject",
"Nothing to moderate" : "Nothing to moderate",
"Refresh queue" : "Refresh queue",
"Registration moderation" : "Registration moderation",
"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.",
"Loading pending registrations…" : "Loading pending registrations…",
"There are no pending registrations right now." : "There are no pending registrations right now.",
"Could not load the moderation queue" : "Could not load the moderation queue",
"{label} approved and published" : "{label} approved and published",
"{label} rejected" : "{label} rejected",
"{label} has no identifier" : "{label} has no identifier",
"Could not update the {label}" : "Could not update the {label}",
"Review moderation" : "Review moderation",
"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.",
"Loading pending reviews…" : "Loading pending reviews…",
"There are no pending reviews right now." : "There are no pending reviews right now.",
"Ratings & reviews" : "Ratings & reviews",
"Loading reviews" : "Loading reviews",
"Could not load reviews" : "Could not load reviews",
"Write a review" : "Write a review",
"1 review" : "1 review",
"{count} reviews" : "{count} reviews",
"No reviews yet" : "No reviews yet",
"Be the first to share your experience." : "Be the first to share your experience.",
"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.",
"Title" : "Title",
"Summarise your experience in a few words" : "Summarise your experience in a few words",
"Rating (1-10)" : "Rating (1-10)",
"Select a rating" : "Select a rating",
"Testimonial" : "Testimonial",
"What was your experience with this software?" : "What was your experience with this software?",
"Submit review" : "Submit review",
"Thank you — your review was submitted for moderation" : "Thank you — your review was submitted for moderation",
"Could not submit your review" : "Could not submit your review"
},
"nplurals=2; plural=(n != 1);"
);
37 changes: 36 additions & 1 deletion l10n/en_US.json
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,41 @@
"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.",
"Could not load applications. Please try again.": "Could not load applications. Please try again.",
"Applications ({count})": "Applications ({count})",
"No applications attached yet.": "No applications attached yet."
"No applications attached yet.": "No applications attached yet.",
"Approve": "Approve",
"Reject": "Reject",
"Nothing to moderate": "Nothing to moderate",
"Refresh queue": "Refresh queue",
"Registration moderation": "Registration moderation",
"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.",
"Loading pending registrations…": "Loading pending registrations…",
"There are no pending registrations right now.": "There are no pending registrations right now.",
"Could not load the moderation queue": "Could not load the moderation queue",
"{label} approved and published": "{label} approved and published",
"{label} rejected": "{label} rejected",
"{label} has no identifier": "{label} has no identifier",
"Could not update the {label}": "Could not update the {label}",
"Review moderation": "Review moderation",
"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.",
"Loading pending reviews…": "Loading pending reviews…",
"There are no pending reviews right now.": "There are no pending reviews right now.",
"Ratings & reviews": "Ratings & reviews",
"Loading reviews": "Loading reviews",
"Could not load reviews": "Could not load reviews",
"Write a review": "Write a review",
"1 review": "1 review",
"{count} reviews": "{count} reviews",
"No reviews yet": "No reviews yet",
"Be the first to share your experience.": "Be the first to share your experience.",
"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.",
"Title": "Title",
"Summarise your experience in a few words": "Summarise your experience in a few words",
"Rating (1-10)": "Rating (1-10)",
"Select a rating": "Select a rating",
"Testimonial": "Testimonial",
"What was your experience with this software?": "What was your experience with this software?",
"Submit review": "Submit review",
"Thank you — your review was submitted for moderation": "Thank you — your review was submitted for moderation",
"Could not submit your review": "Could not submit your review"
}
}
37 changes: 36 additions & 1 deletion l10n/nl.js
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,42 @@ OC.L10N.register(
"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.",
"Could not load applications. Please try again." : "Kon de applicaties niet laden. Probeer het opnieuw.",
"Applications ({count})" : "Applicaties ({count})",
"No applications attached yet." : "Nog geen applicaties gekoppeld."
"No applications attached yet." : "Nog geen applicaties gekoppeld.",
"Approve" : "Goedkeuren",
"Reject" : "Afwijzen",
"Nothing to moderate" : "Niets te modereren",
"Refresh queue" : "Wachtrij vernieuwen",
"Registration moderation" : "Registratiemoderatie",
"Review anonymous catalog registrations. Approving an entry publishes it; rejecting leaves it hidden." : "Beoordeel anonieme catalogusregistraties. Goedkeuren publiceert een item; afwijzen houdt het verborgen.",
"Loading pending registrations…" : "Openstaande registraties laden…",
"There are no pending registrations right now." : "Er zijn op dit moment geen openstaande registraties.",
"Could not load the moderation queue" : "Kon de moderatiewachtrij niet laden",
"{label} approved and published" : "{label} goedgekeurd en gepubliceerd",
"{label} rejected" : "{label} afgewezen",
"{label} has no identifier" : "{label} heeft geen identificatie",
"Could not update the {label}" : "Kon {label} niet bijwerken",
"Review moderation" : "Beoordelingsmoderatie",
"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.",
"Loading pending reviews…" : "Openstaande beoordelingen laden…",
"There are no pending reviews right now." : "Er zijn op dit moment geen openstaande beoordelingen.",
"Ratings & reviews" : "Waarderingen & beoordelingen",
"Loading reviews" : "Beoordelingen laden",
"Could not load reviews" : "Kon beoordelingen niet laden",
"Write a review" : "Schrijf een beoordeling",
"1 review" : "1 beoordeling",
"{count} reviews" : "{count} beoordelingen",
"No reviews yet" : "Nog geen beoordelingen",
"Be the first to share your experience." : "Wees de eerste die een ervaring deelt.",
"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.",
"Title" : "Titel",
"Summarise your experience in a few words" : "Vat uw ervaring in een paar woorden samen",
"Rating (1-10)" : "Waardering (1-10)",
"Select a rating" : "Selecteer een waardering",
"Testimonial" : "Testimonial",
"What was your experience with this software?" : "Wat was uw ervaring met deze software?",
"Submit review" : "Beoordeling indienen",
"Thank you — your review was submitted for moderation" : "Bedankt — uw beoordeling is ingediend ter moderatie",
"Could not submit your review" : "Kon uw beoordeling niet indienen"
},
"nplurals=2; plural=(n != 1);"
);
Loading