Skip to content

Commit 7e6fcc0

Browse files
committed
feat: page ratings + last edited by sections
1 parent a733097 commit 7e6fcc0

28 files changed

Lines changed: 8239 additions & 150 deletions

File tree

‎backend/api/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ async function routes(app: FastifyInstance) {
5050
app.register(import('./mail.ts'), { prefix: '/mail' })
5151
app.register(import('./navigation.ts'))
5252
app.register(import('./pages.ts'))
53+
app.register(import('./ratings.ts'))
5354
app.register(import('./scheduler.ts'), { prefix: '/scheduler' })
5455
app.register(import('./sites.ts'), { prefix: '/sites' })
5556
app.register(import('./storage.ts'))

‎backend/api/pages.ts‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -676,7 +676,11 @@ async function routes(app: FastifyInstance) {
676676
is what makes a page view one request instead of four.
677677
*/
678678
const actorId = actor?.id ?? null
679-
const [approvalState, isWatching, commentsCount, blog] = await Promise.all([
679+
// -> Both switches: the site's ratings mode and the page's own `allowRatings`
680+
const ratingMode = page.allowRatings
681+
? WIKI.models.pageRatings.modeFor(req.params.siteId)
682+
: null
683+
const [approvalState, isWatching, commentsCount, blog, ownRating] = await Promise.all([
680684
WIKI.models.approvals.pageViewerState(req, req.params.siteId, {
681685
id: page.id,
682686
path: page.path,
@@ -699,11 +703,19 @@ async function routes(app: FastifyInstance) {
699703
which is one lookup on the unique `(siteId, locale, path)` index over the page's own
700704
ancestors, and no lookup at all for a page at the site root.
701705
*/
702-
WIKI.models.blogs.blogFor(req.params.siteId, page.locale, page.path)
706+
WIKI.models.blogs.blogFor(req.params.siteId, page.locale, page.path),
707+
/*
708+
The reader's own rating, which is theirs and so cannot be cached on the page the way the
709+
totals are: one lookup on the primary key, and none for a guest or where ratings are off.
710+
*/
711+
ratingMode ? WIKI.models.pageRatings.valueFor(page.id, actorId, ratingMode) : 0
703712
])
713+
const { ratings, ...pageFields } = page
704714
return {
705-
...page,
715+
...pageFields,
706716
commentsCount,
717+
// -> Off the totals cached on the row just loaded, so no aggregate per view
718+
rating: ratingMode ? WIKI.models.pageRatings.summaryFromCache(ratings, ratingMode) : null,
707719
/*
708720
Only what the page view draws: a post shows the name of the blog it is in and links to it.
709721
The blog's own settings are not a fact about this page -- the front page carries them, and
@@ -713,7 +725,8 @@ async function routes(app: FastifyInstance) {
713725
viewer: {
714726
permissions: pagePermissionsFor(req, page),
715727
...approvalState,
716-
isWatching
728+
isWatching,
729+
rating: ownRating
717730
}
718731
}
719732
}

‎backend/api/ratings.ts‎

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { audit } from '../helpers/audit.ts'
2+
import { actorFrom, mayOnPage, unlockedFor } from './pages.ts'
3+
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
4+
import type { RatingMode } from '../models/pageRatings.ts'
5+
import type { Page } from '../models/pages.ts'
6+
7+
const pageParams = {
8+
type: 'object',
9+
properties: {
10+
siteId: { type: 'string', format: 'uuid' },
11+
pageId: { type: 'string', format: 'uuid' }
12+
},
13+
required: ['siteId', 'pageId']
14+
}
15+
16+
const ratingResponse = {
17+
description: 'How the page is rated now, and what the caller gave it',
18+
type: 'object',
19+
properties: {
20+
ok: { type: 'boolean' },
21+
rating: { $ref: 'PageRatingSummary#' },
22+
value: {
23+
type: 'integer',
24+
description: 'The caller’s own rating, 0 once withdrawn.'
25+
}
26+
}
27+
}
28+
29+
/**
30+
* The page being rated and the scale it is rated on, or a refusal.
31+
*
32+
* Rating belongs to an account — one opinion per person needs a person to hang it on — so an
33+
* anonymous request never gets as far as the page. Beyond that it is the same gate as reading: a page
34+
* somebody may not read is answered as though it were not there. Both switches have to be on, the
35+
* site's ratings mode and the page's own `allowRatings`.
36+
*/
37+
async function loadRateable(
38+
req: FastifyRequest<{ Params: { siteId: string; pageId: string } }>,
39+
reply: FastifyReply
40+
): Promise<{ userId: string; mode: RatingMode; page: Page } | null> {
41+
const actor = actorFrom(req)
42+
if (!actor) {
43+
reply.unauthorized('Rating a page requires a logged in user.')
44+
return null
45+
}
46+
const mode = WIKI.models.pageRatings.modeFor(req.params.siteId)
47+
if (!mode) {
48+
reply.forbidden('Ratings are turned off for this site.')
49+
return null
50+
}
51+
const page = await WIKI.models.pages.getPage({
52+
siteId: req.params.siteId,
53+
id: req.params.pageId,
54+
unlocked: (id: string) => unlockedFor(req, id)
55+
})
56+
if (!page || !mayOnPage(req, 'read:pages', page)) {
57+
reply.notFound('This page does not exist.')
58+
return null
59+
}
60+
if (!page.allowRatings) {
61+
reply.forbidden('Ratings are turned off for this page.')
62+
return null
63+
}
64+
return { userId: actor.id, mode, page }
65+
}
66+
67+
/**
68+
* Page Ratings API Routes
69+
*
70+
* A reader's thumbs or stars for a page. How a page is rated so far comes with the page itself
71+
* (`rating`, and the reader's own under `viewer.rating`), so there is no read route here.
72+
*
73+
* No route-level `permissions` on either: the test is `read:pages` on the page, which a group's page
74+
* rules decide and the hook in `index.ts` cannot see.
75+
*/
76+
async function routes(app: FastifyInstance) {
77+
/**
78+
* RATE A PAGE
79+
*/
80+
app.put<{ Params: { siteId: string; pageId: string }; Body: { value: number } }>(
81+
'/sites/:siteId/pages/:pageId/rating',
82+
{
83+
schema: {
84+
summary: 'Rate a page',
85+
description:
86+
'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.',
87+
tags: ['Pages'],
88+
params: pageParams,
89+
body: {
90+
type: 'object',
91+
required: ['value'],
92+
properties: {
93+
value: { type: 'integer', minimum: -1, maximum: 5 }
94+
}
95+
},
96+
response: { 200: ratingResponse }
97+
}
98+
},
99+
async (req, reply) => {
100+
const target = await loadRateable(req, reply)
101+
if (!target) {
102+
return reply
103+
}
104+
const { userId, mode, page } = target
105+
if (!WIKI.models.pageRatings.isValid(mode, req.body.value)) {
106+
return reply.badRequest(
107+
mode === 'thumbs'
108+
? 'A thumbs rating is 1 or -1.'
109+
: 'A star rating is a whole number from 1 to 5.'
110+
)
111+
}
112+
const totals = await WIKI.models.pageRatings.rate({
113+
pageId: page.id,
114+
userId,
115+
mode,
116+
value: req.body.value
117+
})
118+
119+
await audit(req, 'page', 'ratePage', {
120+
pageId: page.id,
121+
siteId: req.params.siteId,
122+
locale: page.locale,
123+
path: page.path,
124+
mode,
125+
value: req.body.value
126+
})
127+
128+
return {
129+
ok: true,
130+
rating: WIKI.models.pageRatings.summaryFromCache(totals, mode),
131+
value: req.body.value
132+
}
133+
}
134+
)
135+
136+
/**
137+
* WITHDRAW A RATING
138+
*/
139+
app.delete<{ Params: { siteId: string; pageId: string } }>(
140+
'/sites/:siteId/pages/:pageId/rating',
141+
{
142+
schema: {
143+
summary: 'Withdraw a rating',
144+
description:
145+
'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.',
146+
tags: ['Pages'],
147+
params: pageParams,
148+
response: { 200: ratingResponse }
149+
}
150+
},
151+
async (req, reply) => {
152+
const target = await loadRateable(req, reply)
153+
if (!target) {
154+
return reply
155+
}
156+
const { userId, mode, page } = target
157+
const totals = await WIKI.models.pageRatings.unrate({ pageId: page.id, userId })
158+
159+
await audit(req, 'page', 'unratePage', {
160+
pageId: page.id,
161+
siteId: req.params.siteId,
162+
locale: page.locale,
163+
path: page.path
164+
})
165+
166+
return {
167+
ok: true,
168+
rating: WIKI.models.pageRatings.summaryFromCache(totals, mode),
169+
value: 0
170+
}
171+
}
172+
)
173+
}
174+
175+
export default routes

‎backend/api/schemas/page.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
125125
allowComments: { type: 'boolean' },
126126
allowContributions: { type: 'boolean' },
127127
allowRatings: { type: 'boolean' },
128+
showLastEditedBy: { type: 'boolean' },
128129
showSidebar: { type: 'boolean' },
129130
showTags: { type: 'boolean' },
130131
showToc: { type: 'boolean' },
@@ -229,6 +230,12 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
229230
description:
230231
'How many comments this page has, which is what the Talk tab’s badge counts. Always 0 unless the site uses the built-in comments provider. Present when a page is fetched on its own.'
231232
},
233+
rating: {
234+
anyOf: [{ type: 'null' }, { $ref: 'PageRatingSummary#' }],
235+
description:
236+
'How readers have rated the page, on the site’s current scale. Null when ratings are off for the site or for the page. Present when a page is fetched on its own.'
237+
},
238+
showLastEditedBy: { type: 'boolean' },
232239
showSidebar: { type: 'boolean' },
233240
showTags: { type: 'boolean' },
234241
showToc: { type: 'boolean' },
@@ -246,6 +253,7 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
246253
navigationMode: { type: 'string' },
247254
authorId: { type: 'string', format: 'uuid' },
248255
authorName: { type: 'string' },
256+
authorHasAvatar: { type: 'boolean' },
249257
createdAt: { type: 'string', format: 'date-time' },
250258
updatedAt: { type: 'string', format: 'date-time' },
251259
blog: {
@@ -287,6 +295,11 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
287295
description:
288296
'The requester has asked to be told about changes to this page. Always false without an account, since a watch belongs to one.'
289297
},
298+
rating: {
299+
type: 'integer',
300+
description:
301+
'The requester’s own rating of this page on the site’s current scale, or 0 for none. Always 0 without an account, since a rating belongs to one.'
302+
},
290303
pendingSubmissions: {
291304
type: 'array',
292305
items: { $ref: 'PageEditSubmission#' },
@@ -297,6 +310,28 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
297310
}
298311
})
299312

313+
/**
314+
* PAGE RATING SUMMARY - How readers have rated a page, on the site's current scale
315+
*/
316+
app.addSchema({
317+
$id: 'PageRatingSummary',
318+
type: 'object',
319+
properties: {
320+
mode: {
321+
type: 'string',
322+
enum: ['thumbs', 'stars'],
323+
description: 'The scale. Only ratings given on it are counted.'
324+
},
325+
count: { type: 'integer', description: 'How many readers have rated the page.' },
326+
average: {
327+
type: 'number',
328+
description: 'Mean rating: 1 to 5 for stars, -1 to 1 for thumbs. 0 when nobody has rated.'
329+
},
330+
up: { type: 'integer', description: 'Thumbs up. Always 0 under stars.' },
331+
down: { type: 'integer', description: 'Thumbs down. Always 0 under stars.' }
332+
}
333+
})
334+
300335
/**
301336
* WATCHED PAGE - A page somebody asked to be told about, as their inbox lists it
302337
*/

‎backend/api/schemas/site.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,18 +91,22 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
9191
collaborativeEditing: {
9292
type: 'boolean'
9393
},
94-
ratings: {
95-
type: 'boolean'
96-
},
9794
ratingsMode: {
9895
type: 'string',
99-
enum: ['off', 'stars', 'thumbs']
96+
enum: ['off', 'stars', 'thumbs'],
97+
description:
98+
'Whether readers may rate pages, and on which scale. Off by default. A page can still opt out on its own with `allowRatings`; only a logged in reader who may read the page can rate it.'
10099
},
101100
comments: {
102101
type: 'boolean',
103102
description:
104103
'Whether this site has comments at all. Which provider handles them is `comments.provider`; this turns every one of them off without losing that choice, and a page can still opt out on its own with `allowComments`.'
105104
},
105+
lastEditedBy: {
106+
type: 'boolean',
107+
description:
108+
'Whether a page may show who last edited it, in its sidebar. A page can still opt out on its own with `showLastEditedBy`.'
109+
},
106110
reasonForChange: {
107111
type: 'string',
108112
enum: ['off', 'optional', 'required']

‎backend/api/sites.ts‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -487,11 +487,6 @@ async function routes(app: FastifyInstance) {
487487
}
488488
}
489489

490-
// -> Keep the legacy `features.ratings` flag in sync with the ratings mode
491-
if (config.features?.ratingsMode !== undefined) {
492-
config.features.ratings = config.features.ratingsMode !== 'off'
493-
}
494-
495490
/*
496491
The pasted-uploads destination is stored in one form, so that what the admin area reads back is
497492
what an upload will do with it -- `assets/`, `./assets` and `assets` are the same folder, and
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
CREATE TABLE "pageRatings" (
2+
"pageId" uuid,
3+
"userId" uuid,
4+
"kind" varchar(16) NOT NULL,
5+
"value" integer NOT NULL,
6+
"createdAt" timestamp DEFAULT now() NOT NULL,
7+
"updatedAt" timestamp DEFAULT now() NOT NULL,
8+
CONSTRAINT "pageRatings_pkey" PRIMARY KEY("pageId","userId")
9+
);
10+
--> statement-breakpoint
11+
ALTER TABLE "pages" ADD COLUMN "ratings" jsonb DEFAULT '{}' NOT NULL;--> statement-breakpoint
12+
ALTER TABLE "pages" DROP COLUMN "ratingScore";--> statement-breakpoint
13+
ALTER TABLE "pages" DROP COLUMN "ratingCount";--> statement-breakpoint
14+
CREATE INDEX "pageRatings_userId_idx" ON "pageRatings" ("userId");--> statement-breakpoint
15+
ALTER TABLE "pageRatings" ADD CONSTRAINT "pageRatings_pageId_pages_id_fkey" FOREIGN KEY ("pageId") REFERENCES "pages"("id") ON DELETE CASCADE;--> statement-breakpoint
16+
ALTER TABLE "pageRatings" ADD CONSTRAINT "pageRatings_userId_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE;

0 commit comments

Comments
 (0)