You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
wiki/backend/api/ratings.ts

176 lines
5.3 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { audit } from '../helpers/audit.ts'
import { actorFrom, mayOnPage, unlockedFor } from './pages.ts'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
import type { RatingMode } from '../models/pageRatings.ts'
import type { Page } from '../models/pages.ts'
const pageParams = {
type: 'object',
properties: {
siteId: { type: 'string', format: 'uuid' },
pageId: { type: 'string', format: 'uuid' }
},
required: ['siteId', 'pageId']
}
const ratingResponse = {
description: 'How the page is rated now, and what the caller gave it',
type: 'object',
properties: {
ok: { type: 'boolean' },
rating: { $ref: 'PageRatingSummary#' },
value: {
type: 'integer',
description: 'The caller’s own rating, 0 once withdrawn.'
}
}
}
/**
* The page being rated and the scale it is rated on, or a refusal.
*
* Rating belongs to an account — one opinion per person needs a person to hang it on — so an
* anonymous request never gets as far as the page. Beyond that it is the same gate as reading: a page
* somebody may not read is answered as though it were not there. Both switches have to be on, the
* site's ratings mode and the page's own `allowRatings`.
*/
async function loadRateable(
req: FastifyRequest<{ Params: { siteId: string; pageId: string } }>,
reply: FastifyReply
): Promise<{ userId: string; mode: RatingMode; page: Page } | null> {
const actor = actorFrom(req)
if (!actor) {
reply.unauthorized('Rating a page requires a logged in user.')
return null
}
const mode = WIKI.models.pageRatings.modeFor(req.params.siteId)
if (!mode) {
reply.forbidden('Ratings are turned off for this site.')
return null
}
const page = await WIKI.models.pages.getPage({
siteId: req.params.siteId,
id: req.params.pageId,
unlocked: (id: string) => unlockedFor(req, id)
})
if (!page || !mayOnPage(req, 'read:pages', page)) {
reply.notFound('This page does not exist.')
return null
}
if (!page.allowRatings) {
reply.forbidden('Ratings are turned off for this page.')
return null
}
return { userId: actor.id, mode, page }
}
/**
* Page Ratings API Routes
*
* A reader's thumbs or stars for a page. How a page is rated so far comes with the page itself
* (`rating`, and the reader's own under `viewer.rating`), so there is no read route here.
*
* No route-level `permissions` on either: the test is `read:pages` on the page, which a group's page
* rules decide and the hook in `index.ts` cannot see.
*/
async function routes(app: FastifyInstance) {
/**
* RATE A PAGE
*/
app.put<{ Params: { siteId: string; pageId: string }; Body: { value: number } }>(
'/sites/:siteId/pages/:pageId/rating',
{
schema: {
summary: 'Rate a page',
description:
'Records the caller’s rating of the page, replacing any they gave it before. The scale is the site’s ratings mode: `1` or `-1` for thumbs, `1` to `5` for stars.\n\nNeeds a logged in user who may read the page, with ratings on for both the site and the page.',
tags: ['Pages'],
params: pageParams,
body: {
type: 'object',
required: ['value'],
properties: {
value: { type: 'integer', minimum: -1, maximum: 5 }
}
},
response: { 200: ratingResponse }
}
},
async (req, reply) => {
const target = await loadRateable(req, reply)
if (!target) {
return reply
}
const { userId, mode, page } = target
if (!WIKI.models.pageRatings.isValid(mode, req.body.value)) {
return reply.badRequest(
mode === 'thumbs'
? 'A thumbs rating is 1 or -1.'
: 'A star rating is a whole number from 1 to 5.'
)
}
const totals = await WIKI.models.pageRatings.rate({
pageId: page.id,
userId,
mode,
value: req.body.value
})
await audit(req, 'page', 'ratePage', {
pageId: page.id,
siteId: req.params.siteId,
locale: page.locale,
path: page.path,
mode,
value: req.body.value
})
return {
ok: true,
rating: WIKI.models.pageRatings.summaryFromCache(totals, mode),
value: req.body.value
}
}
)
/**
* WITHDRAW A RATING
*/
app.delete<{ Params: { siteId: string; pageId: string } }>(
'/sites/:siteId/pages/:pageId/rating',
{
schema: {
summary: 'Withdraw a rating',
description:
'Forgets the caller’s rating of the page. A page they had not rated answers the same way, since the outcome asked for already holds.',
tags: ['Pages'],
params: pageParams,
response: { 200: ratingResponse }
}
},
async (req, reply) => {
const target = await loadRateable(req, reply)
if (!target) {
return reply
}
const { userId, mode, page } = target
const totals = await WIKI.models.pageRatings.unrate({ pageId: page.id, userId })
await audit(req, 'page', 'unratePage', {
pageId: page.id,
siteId: req.params.siteId,
locale: page.locale,
path: page.path
})
return {
ok: true,
rating: WIKI.models.pageRatings.summaryFromCache(totals, mode),
value: 0
}
}
)
}
export default routes