mirror of https://github.com/requarks/wiki
parent
a7330974a2
commit
7e6fcc09c3
@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE "pageRatings" (
|
||||||
|
"pageId" uuid,
|
||||||
|
"userId" uuid,
|
||||||
|
"kind" varchar(16) NOT NULL,
|
||||||
|
"value" integer NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "pageRatings_pkey" PRIMARY KEY("pageId","userId")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "pages" ADD COLUMN "ratings" jsonb DEFAULT '{}' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "pages" DROP COLUMN "ratingScore";--> statement-breakpoint
|
||||||
|
ALTER TABLE "pages" DROP COLUMN "ratingCount";--> statement-breakpoint
|
||||||
|
CREATE INDEX "pageRatings_userId_idx" ON "pageRatings" ("userId");--> statement-breakpoint
|
||||||
|
ALTER TABLE "pageRatings" ADD CONSTRAINT "pageRatings_pageId_pages_id_fkey" FOREIGN KEY ("pageId") REFERENCES "pages"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "pageRatings" ADD CONSTRAINT "pageRatings_userId_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE;
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,188 @@
|
|||||||
|
import { and, eq, sql } from 'drizzle-orm'
|
||||||
|
import { pageRatings as ratingsTable, pages as pagesTable } from '../db/schema.ts'
|
||||||
|
|
||||||
|
/** The two scales a site can rate its pages on. `off` is the absence of one. */
|
||||||
|
export const RATING_MODES = ['thumbs', 'stars'] as const
|
||||||
|
export type RatingMode = (typeof RATING_MODES)[number]
|
||||||
|
|
||||||
|
/** The totals of one scale, as cached on the page. */
|
||||||
|
interface RatingTotals {
|
||||||
|
count: number
|
||||||
|
sum: number
|
||||||
|
up: number
|
||||||
|
down: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `pages.ratings`: the totals per scale, absent for a scale nobody has rated the page on. */
|
||||||
|
export type PageRatingsCache = Partial<Record<RatingMode, RatingTotals>>
|
||||||
|
|
||||||
|
/** How a page has been rated, under the mode in force. */
|
||||||
|
export interface RatingSummary {
|
||||||
|
mode: RatingMode
|
||||||
|
/** How many readers have rated it. */
|
||||||
|
count: number
|
||||||
|
/** Mean of the ratings, 1 to 5 for stars and -1 to 1 for thumbs. 0 when nobody has rated it. */
|
||||||
|
average: number
|
||||||
|
/** Thumbs up. Always 0 under stars. */
|
||||||
|
up: number
|
||||||
|
/** Thumbs down. Always 0 under stars. */
|
||||||
|
down: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page ratings model
|
||||||
|
*
|
||||||
|
* One rating per reader per page, on whichever scale the site uses — see the table's comment in
|
||||||
|
* `db/schema.ts`. Who may rate is the route's question (a logged in reader who may read the page);
|
||||||
|
* everything here is about the scale and the totals.
|
||||||
|
*
|
||||||
|
* **The totals are cached on the page**, in `pages.ratings`, and a page view reads them from there:
|
||||||
|
* pages are read far more often than they are rated, so the aggregate is worked out on the write.
|
||||||
|
* Every scale is kept, so switching the site between thumbs and stars and back finds the totals where
|
||||||
|
* they were.
|
||||||
|
*/
|
||||||
|
class PageRatings {
|
||||||
|
/**
|
||||||
|
* The scale this site rates on, or null when ratings are off for it.
|
||||||
|
*
|
||||||
|
* `features.ratingsMode` under General → Features. A page's own `allowRatings` is the other half,
|
||||||
|
* and is the caller's to check, since it is a property of the page in hand.
|
||||||
|
*/
|
||||||
|
modeFor(siteId: string): RatingMode | null {
|
||||||
|
const mode = WIKI.sites[siteId]?.config?.features?.ratingsMode
|
||||||
|
return (RATING_MODES as readonly string[]).includes(mode) ? mode : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `value` is a rating the scale accepts: ±1 for thumbs, a whole 1 to 5 for stars. */
|
||||||
|
isValid(mode: RatingMode, value: number): boolean {
|
||||||
|
if (!Number.isInteger(value)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return mode === 'thumbs' ? value === 1 || value === -1 : value >= 1 && value <= 5
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a page has been rated under `mode`, from the totals cached on it. No query. */
|
||||||
|
summaryFromCache(cache: PageRatingsCache | null | undefined, mode: RatingMode): RatingSummary {
|
||||||
|
const totals = cache?.[mode]
|
||||||
|
const count = totals?.count ?? 0
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
count,
|
||||||
|
average: count > 0 ? Math.round(((totals?.sum ?? 0) / count) * 100) / 100 : 0,
|
||||||
|
up: mode === 'thumbs' ? (totals?.up ?? 0) : 0,
|
||||||
|
down: mode === 'thumbs' ? (totals?.down ?? 0) : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This reader's own rating of a page under `mode`, or 0 for none.
|
||||||
|
*
|
||||||
|
* The one part of a page view's rating that cannot come off the page, being the reader's own: a
|
||||||
|
* single lookup on the primary key, and none for a guest. A rating given under the other scale
|
||||||
|
* reads as none, since it cannot be drawn on this one and rating again replaces it.
|
||||||
|
*/
|
||||||
|
async valueFor(pageId: string, userId: string | null, mode: RatingMode): Promise<number> {
|
||||||
|
if (!userId) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
const [row] = await WIKI.db
|
||||||
|
.select({ value: ratingsTable.value })
|
||||||
|
.from(ratingsTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(ratingsTable.pageId, pageId),
|
||||||
|
eq(ratingsTable.userId, userId),
|
||||||
|
eq(ratingsTable.kind, mode)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return row?.value ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate a page, replacing whatever this reader had given it before, on either scale.
|
||||||
|
*
|
||||||
|
* @returns The page's totals as they stand after the rating.
|
||||||
|
*/
|
||||||
|
async rate({
|
||||||
|
pageId,
|
||||||
|
userId,
|
||||||
|
mode,
|
||||||
|
value
|
||||||
|
}: {
|
||||||
|
pageId: string
|
||||||
|
userId: string
|
||||||
|
mode: RatingMode
|
||||||
|
value: number
|
||||||
|
}): Promise<PageRatingsCache> {
|
||||||
|
return this.#changeAndRecount(pageId, (tx) =>
|
||||||
|
tx
|
||||||
|
.insert(ratingsTable)
|
||||||
|
.values({ pageId, userId, kind: mode, value })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [ratingsTable.pageId, ratingsTable.userId],
|
||||||
|
set: { kind: mode, value, updatedAt: new Date() }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Withdraw this reader's rating of a page. Idempotent: no row is the outcome asked for.
|
||||||
|
*
|
||||||
|
* @returns The page's totals as they stand after the withdrawal.
|
||||||
|
*/
|
||||||
|
async unrate({ pageId, userId }: { pageId: string; userId: string }): Promise<PageRatingsCache> {
|
||||||
|
return this.#changeAndRecount(pageId, (tx) =>
|
||||||
|
tx
|
||||||
|
.delete(ratingsTable)
|
||||||
|
.where(and(eq(ratingsTable.pageId, pageId), eq(ratingsTable.userId, userId)))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a change to a page's ratings and rewrite the totals cached on it, in one transaction.
|
||||||
|
*
|
||||||
|
* The page row is locked first. Two readers rating at once would otherwise each count the rows
|
||||||
|
* without the other's uncommitted one, and whichever committed last would cache a total that is one
|
||||||
|
* rating short for good. Recounted rather than adjusted by the difference, so a cache that has
|
||||||
|
* drifted (a rating removed by a deleted account's cascade) is put right by the next rating.
|
||||||
|
*/
|
||||||
|
async #changeAndRecount(
|
||||||
|
pageId: string,
|
||||||
|
change: (tx: any) => Promise<unknown>
|
||||||
|
): Promise<PageRatingsCache> {
|
||||||
|
return WIKI.db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.select({ id: pagesTable.id })
|
||||||
|
.from(pagesTable)
|
||||||
|
.where(eq(pagesTable.id, pageId))
|
||||||
|
.for('update')
|
||||||
|
await change(tx)
|
||||||
|
const rows = await tx
|
||||||
|
.select({
|
||||||
|
kind: ratingsTable.kind,
|
||||||
|
count: sql<number>`count(*)::int`,
|
||||||
|
sum: sql<number>`coalesce(sum(${ratingsTable.value}), 0)::int`,
|
||||||
|
up: sql<number>`(count(*) filter (where ${ratingsTable.value} > 0))::int`,
|
||||||
|
down: sql<number>`(count(*) filter (where ${ratingsTable.value} < 0))::int`
|
||||||
|
})
|
||||||
|
.from(ratingsTable)
|
||||||
|
.where(eq(ratingsTable.pageId, pageId))
|
||||||
|
.groupBy(ratingsTable.kind)
|
||||||
|
const cache: PageRatingsCache = {}
|
||||||
|
for (const row of rows) {
|
||||||
|
if ((RATING_MODES as readonly string[]).includes(row.kind)) {
|
||||||
|
cache[row.kind as RatingMode] = {
|
||||||
|
count: Number(row.count),
|
||||||
|
sum: Number(row.sum),
|
||||||
|
up: Number(row.up),
|
||||||
|
down: Number(row.down)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await tx.update(pagesTable).set({ ratings: cache }).where(eq(pagesTable.id, pageId))
|
||||||
|
return cache
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pageRatings = new PageRatings()
|
||||||
Loading…
Reference in new issue