diff --git a/app/db/models/badges.server.ts b/app/db/models/badges.server.ts index 4d28bf0e1..15d562b8e 100644 --- a/app/db/models/badges.server.ts +++ b/app/db/models/badges.server.ts @@ -39,7 +39,7 @@ export function all() { return allStm.all() as All; } -export type OwnersByBadge = Array< +export type OwnersByBadgeId = Array< Pick & { count: number; } @@ -47,18 +47,37 @@ export type OwnersByBadge = Array< const ownersByBadgeIdStm = sql.prepare(` select - count("BadgeOwner"."badgeId") as count, - "User"."id", - "User"."discordId", - "User"."discordName", - "User"."discordDiscriminator" - from "BadgeOwner" - join "User" on "User"."id" = "BadgeOwner"."userId" - where "BadgeOwner"."badgeId" = $id - group by "User"."id" - order by count desc + count("BadgeOwner"."badgeId") as count, + "User"."id", + "User"."discordId", + "User"."discordName", + "User"."discordDiscriminator" + from "BadgeOwner" + join "User" on "User"."id" = "BadgeOwner"."userId" + where "BadgeOwner"."badgeId" = $id + group by "User"."id" + order by count desc `); export function ownersByBadgeId(id: Badge["id"]) { - return ownersByBadgeIdStm.all({ id }) as OwnersByBadge; + return ownersByBadgeIdStm.all({ id }) as OwnersByBadgeId; +} + +export type ManagersByBadgeId = Array< + Pick +>; + +const managersByBadgeIdStm = sql.prepare(` + select + "User"."id", + "User"."discordId", + "User"."discordName", + "User"."discordDiscriminator" + from "BadgeManager" + join "User" on "User"."id" = "BadgeManager"."userId" + where "BadgeManager"."badgeId" = $id +`); + +export function managersByBadgeId(id: Badge["id"]) { + return managersByBadgeIdStm.all({ id }) as ManagersByBadgeId; } diff --git a/app/db/seed.ts b/app/db/seed.ts index 7c14b1d72..0de6a9735 100644 --- a/app/db/seed.ts +++ b/app/db/seed.ts @@ -25,6 +25,7 @@ const basicSeeds = [ thisMonthsSuggestions, badgesToAdmin, badgesToUsers, + badgeManagers, ]; export function seed() { @@ -258,3 +259,14 @@ function badgesToUsers() { } } } + +function badgeManagers() { + // make N-ZAP user manager of several badges + for (let id = 1; id <= 10; id++) { + sql + .prepare( + `insert into "BadgeManager" ("badgeId", "userId") values ($id, $userId)` + ) + .run({ id, userId: 2 }); + } +} diff --git a/app/permissions.ts b/app/permissions.ts index 213bc585c..bc247474d 100644 --- a/app/permissions.ts +++ b/app/permissions.ts @@ -4,9 +4,26 @@ import type { PlusSuggestion, User, UserWithPlusTier } from "./db/types"; import { allTruthy } from "./utils/arrays"; import { ADMIN_DISCORD_ID, LOHI_TOKEN_HEADER_NAME } from "./constants"; import invariant from "tiny-invariant"; +import type { ManagersByBadgeId } from "./db/models/badges.server"; // TODO: 1) move "root checkers" to one file and utils to one file 2) make utils const for more terseness +export function canPerformAdminActions(user?: Pick) { + if (["development", "test"].includes(process.env.NODE_ENV)) return true; + + if (!user) return false; + return user.discordId === ADMIN_DISCORD_ID; +} + +function adminOverride(user?: Pick) { + if (canPerformAdminActions(user)) { + return () => true; + } + + return (canPerformActionAsNormalUser: boolean) => + canPerformActionAsNormalUser; +} + interface CanAddCommentToSuggestionArgs { user?: Pick; suggestions: plusSuggestions.FindVisibleForUser; @@ -205,16 +222,26 @@ function hasUserSuggestedThisMonth({ ); } -export function canPerformAdminActions(user?: Pick) { - if (["development", "test"].includes(process.env.NODE_ENV)) return true; - - if (!user) return false; - return user.discordId === ADMIN_DISCORD_ID; -} - export function canAccessLohiEndpoint(request: Request) { invariant(process.env["LOHI_TOKEN"], "LOHI_TOKEN is required"); return ( request.headers.get(LOHI_TOKEN_HEADER_NAME) === process.env["LOHI_TOKEN"] ); } + +interface CanEditBadgeOwnersArgs { + user?: Pick; + managers: ManagersByBadgeId; +} + +export function canEditBadgeOwners({ user, managers }: CanEditBadgeOwnersArgs) { + return adminOverride(user)(isBadgeManager({ user, managers })); +} + +function isBadgeManager({ + user, + managers, +}: Pick) { + if (!user) return false; + return managers.some((manager) => manager.id === user.id); +} diff --git a/app/routes/badges.tsx b/app/routes/badges.tsx index 06930e347..e838802c3 100644 --- a/app/routes/badges.tsx +++ b/app/routes/badges.tsx @@ -1,3 +1,4 @@ +import { json } from "@remix-run/node"; import type { LinksFunction, LoaderFunction } from "@remix-run/node"; import { NavLink, Outlet, useLoaderData } from "@remix-run/react"; import { Badge } from "~/components/Badge"; @@ -5,7 +6,6 @@ import { Main } from "~/components/Main"; import { db } from "~/db"; import type { All } from "~/db/models/badges.server"; import styles from "~/styles/badges.css"; -import { jsonCached } from "~/utils/remix"; export const links: LinksFunction = () => { return [{ rel: "stylesheet", href: styles }]; @@ -16,7 +16,7 @@ export interface BadgesLoaderData { } export const loader: LoaderFunction = () => { - return jsonCached({ badges: db.badges.all() }, 120); + return json({ badges: db.badges.all() }); }; export default function BadgesPageLayout() { diff --git a/app/routes/badges/$id.tsx b/app/routes/badges/$id.tsx index 1c86f030c..35f9d46c0 100644 --- a/app/routes/badges/$id.tsx +++ b/app/routes/badges/$id.tsx @@ -1,18 +1,25 @@ +import { json } from "@remix-run/node"; import type { LoaderFunction } from "@remix-run/node"; import { useLoaderData, useMatches, useParams } from "@remix-run/react"; import clsx from "clsx"; import { Badge } from "~/components/Badge"; +import { LinkButton } from "~/components/Button"; import { Redirect } from "~/components/Redirect"; import { db } from "~/db"; -import type { OwnersByBadge } from "~/db/models/badges.server"; +import type { + ManagersByBadgeId, + OwnersByBadgeId, +} from "~/db/models/badges.server"; import type { Badge as BadgeDBType } from "~/db/types"; -import { jsonCached } from "~/utils/remix"; +import { useUser } from "~/modules/auth"; +import { canEditBadgeOwners } from "~/permissions"; import { discordFullName } from "~/utils/strings"; import { BADGES_PAGE } from "~/utils/urls"; import type { BadgesLoaderData } from "../badges"; export interface BadgeDetailsLoaderData { - owners: OwnersByBadge; + owners: OwnersByBadgeId; + managers: ManagersByBadgeId; } export const loader: LoaderFunction = ({ params }) => { @@ -21,13 +28,14 @@ export const loader: LoaderFunction = ({ params }) => { throw new Response(null, { status: 404 }); } - return jsonCached( - { owners: db.badges.ownersByBadgeId(badgeId) }, - 120 - ); + return json({ + owners: db.badges.ownersByBadgeId(badgeId), + managers: db.badges.managersByBadgeId(badgeId), + }); }; export default function BadgeDetailsPage() { + const user = useUser(); const [, parentRoute] = useMatches(); const { badges } = parentRoute!.data as BadgesLoaderData; const params = useParams(); @@ -40,6 +48,11 @@ export default function BadgeDetailsPage() {
{badgeExplanationText(badge)}
+ {canEditBadgeOwners({ user, managers: data.managers }) ? ( + + Edit + + ) : null}
    {data.owners.map((owner) => ( diff --git a/app/utils/remix.ts b/app/utils/remix.ts index cabf5acbb..c41145a15 100644 --- a/app/utils/remix.ts +++ b/app/utils/remix.ts @@ -50,9 +50,3 @@ export function validate(condition: any): asserts condition { export function makeTitle(title: string) { return `${title} | sendou.ink`; } - -export function jsonCached(data: T, cachedForInMinutes: number) { - return json(data, { - headers: { "Cache-Control": `max-age=${cachedForInMinutes * 60}` }, - }); -}