mirror of
https://github.com/Drop-OSS/drop.git
synced 2026-08-19 21:11:30 +10:00
Add age ratings (#451)
* Base age rating system * Cleanup enum leftovers * Missed files * GB importer * Linter settings * Translations * Prettier and linting --------- Co-authored-by: Robert Clabough <robert@clabough.tech>
This commit is contained in:
co-authored by
Robert Clabough
parent
8e75a0bf56
commit
97c6f2c8a6
@@ -0,0 +1,65 @@
|
||||
import { type } from "arktype";
|
||||
import type { AgeRatingOrganization } from "~/prisma/client/enums";
|
||||
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import prisma from "~/server/internal/db/database";
|
||||
import {
|
||||
getAvailableRatings,
|
||||
RATINGS_FOR_ORGANIZATION,
|
||||
} from "~/utils/ageRatings";
|
||||
|
||||
const PatchAgeRatings = type({
|
||||
ageRatings: type({
|
||||
organization: "string",
|
||||
rating: "string",
|
||||
}).array(),
|
||||
}).configure(throwingArktype);
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["game:update"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
const body = await readDropValidatedBody(h3, PatchAgeRatings);
|
||||
const id = getRouterParam(h3, "id")!;
|
||||
|
||||
for (const ar of body.ageRatings) {
|
||||
if (!(ar.organization in RATINGS_FOR_ORGANIZATION)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: `Invalid organization: ${ar.organization}`,
|
||||
});
|
||||
}
|
||||
const validRatings = getAvailableRatings(
|
||||
ar.organization as AgeRatingOrganization,
|
||||
);
|
||||
if (!validRatings.includes(ar.rating)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: `Invalid rating "${ar.rating}" for ${ar.organization}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const game = await prisma.game.findUnique({
|
||||
where: { id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!game) throw createError({ statusCode: 404, message: "Game not found" });
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.gameAgeRating.deleteMany({
|
||||
where: { gameId: id },
|
||||
}),
|
||||
prisma.gameAgeRating.createMany({
|
||||
data: body.ageRatings.map((ar) => ({
|
||||
gameId: id,
|
||||
organization: ar.organization as AgeRatingOrganization,
|
||||
rating: ar.rating,
|
||||
})),
|
||||
}),
|
||||
]);
|
||||
|
||||
return await prisma.gameAgeRating.findMany({
|
||||
where: { gameId: id },
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,7 @@ export type AdminFetchGameType = Prisma.GameGetPayload<{
|
||||
};
|
||||
};
|
||||
tags: true;
|
||||
ageRatings: true;
|
||||
};
|
||||
}>;
|
||||
|
||||
@@ -111,6 +112,7 @@ export default defineEventHandler<
|
||||
},
|
||||
},
|
||||
tags: true,
|
||||
ageRatings: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ export default defineEventHandler(async (h3) => {
|
||||
},
|
||||
},
|
||||
tags: true,
|
||||
ageRatings: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CompanyModel } from "~/prisma/client/models";
|
||||
import { MetadataSource } from "~/prisma/client/enums";
|
||||
import { AgeRatingOrganization, MetadataSource } from "~/prisma/client/enums";
|
||||
import type { MetadataProvider } from ".";
|
||||
import { MissingMetadataProviderConfig } from ".";
|
||||
import type {
|
||||
@@ -9,11 +9,20 @@ import type {
|
||||
_FetchCompanyMetadataParams,
|
||||
CompanyMetadata,
|
||||
GameMetadataRating,
|
||||
GameMetadataAgeRating,
|
||||
} from "./types";
|
||||
import TurndownService from "turndown";
|
||||
import { DateTime } from "luxon";
|
||||
import type { TaskRunContext } from "../tasks";
|
||||
import type { NitroFetchOptions, NitroFetchRequest } from "nitropack";
|
||||
import {
|
||||
ESRBRating,
|
||||
PEGIRating,
|
||||
CEROrating,
|
||||
USKRating,
|
||||
GRACRating,
|
||||
ACBRating,
|
||||
} from "~/utils/ageRatings";
|
||||
|
||||
interface GiantBombResponseType<T> {
|
||||
error: "OK" | string;
|
||||
@@ -79,6 +88,67 @@ interface ReviewResult {
|
||||
site_detail_url: string;
|
||||
}
|
||||
|
||||
interface GameRatingResult {
|
||||
id: number;
|
||||
name: string;
|
||||
rating_board: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ReleaseResult {
|
||||
guid: string;
|
||||
name: string;
|
||||
game_rating?: GameRatingResult;
|
||||
}
|
||||
|
||||
const GB_BOARD_TO_ORG: Record<string, AgeRatingOrganization> = {
|
||||
ESRB: AgeRatingOrganization.ESRB,
|
||||
PEGI: AgeRatingOrganization.PEGI,
|
||||
CERO: AgeRatingOrganization.CERO,
|
||||
USK: AgeRatingOrganization.USK,
|
||||
GRAC: AgeRatingOrganization.GRAC,
|
||||
OFLC: AgeRatingOrganization.ACB,
|
||||
ACB: AgeRatingOrganization.ACB,
|
||||
};
|
||||
|
||||
function lowercaseNormMap(
|
||||
ratings: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.values(ratings).map((v) => [v.toLowerCase(), v]),
|
||||
);
|
||||
}
|
||||
|
||||
const GB_RATING_NORMALIZE: Record<
|
||||
AgeRatingOrganization,
|
||||
Record<string, string>
|
||||
> = {
|
||||
[AgeRatingOrganization.ESRB]: {
|
||||
...lowercaseNormMap(ESRBRating),
|
||||
"early childhood": ESRBRating.EC,
|
||||
everyone: ESRBRating.E,
|
||||
"everyone 10+": ESRBRating.E10,
|
||||
teen: ESRBRating.T,
|
||||
mature: ESRBRating.M,
|
||||
"mature 17+": ESRBRating.M,
|
||||
"adults only": ESRBRating.AO,
|
||||
"adults only 18+": ESRBRating.AO,
|
||||
},
|
||||
[AgeRatingOrganization.PEGI]: lowercaseNormMap(PEGIRating),
|
||||
[AgeRatingOrganization.CERO]: lowercaseNormMap(CEROrating),
|
||||
[AgeRatingOrganization.USK]: lowercaseNormMap(USKRating),
|
||||
[AgeRatingOrganization.GRAC]: lowercaseNormMap(GRACRating),
|
||||
[AgeRatingOrganization.ACB]: {
|
||||
...lowercaseNormMap(ACBRating),
|
||||
"ma 15+": ACBRating.MA15,
|
||||
"r 18+": ACBRating.R18,
|
||||
"refused classification": ACBRating.RC,
|
||||
},
|
||||
[AgeRatingOrganization.ClassInd]: {},
|
||||
};
|
||||
|
||||
interface CompanySearchResult {
|
||||
guid: string;
|
||||
deck: string | null;
|
||||
@@ -246,6 +316,43 @@ export class GiantBombProvider implements MetadataProvider {
|
||||
|
||||
const tags = (gameData.genres ?? []).map((e) => e.name);
|
||||
|
||||
// Fetch age ratings from releases
|
||||
const ageRatings: GameMetadataAgeRating[] = [];
|
||||
try {
|
||||
const releasesResult = await this.request<Array<ReleaseResult>>(
|
||||
"releases",
|
||||
"",
|
||||
{
|
||||
filter: `game:${gameData.guid}`,
|
||||
field_list: "guid,name,game_rating",
|
||||
},
|
||||
);
|
||||
|
||||
const seenOrgs = new Set<AgeRatingOrganization>();
|
||||
for (const release of releasesResult.results) {
|
||||
if (!release.game_rating?.rating_board) continue;
|
||||
|
||||
const boardName = release.game_rating.rating_board.name;
|
||||
const org = GB_BOARD_TO_ORG[boardName];
|
||||
if (!org || seenOrgs.has(org)) continue;
|
||||
|
||||
const ratingName = release.game_rating.name.toLowerCase();
|
||||
const normalized = GB_RATING_NORMALIZE[org]?.[ratingName];
|
||||
if (!normalized) continue;
|
||||
|
||||
seenOrgs.add(org);
|
||||
ageRatings.push({ organization: org, rating: normalized });
|
||||
}
|
||||
|
||||
if (ageRatings.length > 0) {
|
||||
context?.logger.info(
|
||||
`Found ${ageRatings.length} age ratings: ${ageRatings.map((r) => `${r.organization}: ${r.rating}`).join(", ")}`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
context?.logger.warn(`Failed to fetch age ratings from releases: ${e}`);
|
||||
}
|
||||
|
||||
const metadata: GameMetadata = {
|
||||
id: gameData.guid,
|
||||
name: gameData.name,
|
||||
@@ -256,6 +363,7 @@ export class GiantBombProvider implements MetadataProvider {
|
||||
tags,
|
||||
|
||||
reviews,
|
||||
ageRatings,
|
||||
|
||||
publishers,
|
||||
developers,
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import type { CompanyModel } from "~/prisma/client/models";
|
||||
import { MetadataSource } from "~/prisma/client/enums";
|
||||
import { AgeRatingOrganization, MetadataSource } from "~/prisma/client/enums";
|
||||
import {
|
||||
ESRBRating,
|
||||
PEGIRating,
|
||||
CEROrating,
|
||||
USKRating,
|
||||
GRACRating,
|
||||
ClassIndRating,
|
||||
ACBRating,
|
||||
} from "~/utils/ageRatings";
|
||||
import type { MetadataProvider } from ".";
|
||||
import { MissingMetadataProviderConfig } from ".";
|
||||
import type {
|
||||
GameMetadataSearchResult,
|
||||
GameMetadataAgeRating,
|
||||
_FetchGameMetadataParams,
|
||||
GameMetadata,
|
||||
_FetchCompanyMetadataParams,
|
||||
@@ -81,6 +91,69 @@ interface IGDBSearchStub extends IGDBItem {
|
||||
summary: string;
|
||||
}
|
||||
|
||||
interface IGDBAgeRating extends IGDBItem {
|
||||
category: number; // 1=ESRB, 2=PEGI, 3=CERO, 4=USK, 5=GRAC, 6=CLASS_IND, 7=ACB
|
||||
rating: number; // Specific rating level enum value
|
||||
rating_cover_url?: string;
|
||||
}
|
||||
|
||||
const IGDB_CATEGORY_TO_ORG: Record<number, AgeRatingOrganization> = {
|
||||
1: AgeRatingOrganization.ESRB,
|
||||
2: AgeRatingOrganization.PEGI,
|
||||
3: AgeRatingOrganization.CERO,
|
||||
4: AgeRatingOrganization.USK,
|
||||
5: AgeRatingOrganization.GRAC,
|
||||
6: AgeRatingOrganization.ClassInd,
|
||||
7: AgeRatingOrganization.ACB,
|
||||
};
|
||||
|
||||
const IGDB_RATING_TO_STRING: Record<number, string> = {
|
||||
// PEGI
|
||||
1: PEGIRating["3"],
|
||||
2: PEGIRating["7"],
|
||||
3: PEGIRating["12"],
|
||||
4: PEGIRating["16"],
|
||||
5: PEGIRating["18"],
|
||||
// ESRB
|
||||
7: ESRBRating.EC,
|
||||
8: ESRBRating.E,
|
||||
9: ESRBRating.E10,
|
||||
10: ESRBRating.T,
|
||||
11: ESRBRating.M,
|
||||
12: ESRBRating.AO,
|
||||
// CERO
|
||||
13: CEROrating.A,
|
||||
14: CEROrating.B,
|
||||
15: CEROrating.C,
|
||||
16: CEROrating.D,
|
||||
17: CEROrating.Z,
|
||||
// USK
|
||||
18: USKRating["0"],
|
||||
19: USKRating["6"],
|
||||
20: USKRating["12"],
|
||||
21: USKRating["16"],
|
||||
22: USKRating["18"],
|
||||
// GRAC
|
||||
23: GRACRating.ALL,
|
||||
24: GRACRating["12"],
|
||||
25: GRACRating["15"],
|
||||
26: GRACRating["18"],
|
||||
// CLASS_IND
|
||||
28: ClassIndRating.L,
|
||||
29: ClassIndRating["10"],
|
||||
30: ClassIndRating["12"],
|
||||
31: ClassIndRating["14"],
|
||||
32: ClassIndRating["16"],
|
||||
33: ClassIndRating["18"],
|
||||
// ACB
|
||||
34: ACBRating.G,
|
||||
35: ACBRating.PG,
|
||||
36: ACBRating.M,
|
||||
37: ACBRating.MA15,
|
||||
38: ACBRating.R18,
|
||||
39: ACBRating.RC,
|
||||
};
|
||||
|
||||
// https://api-docs.igdb.com/?shell#game
|
||||
interface IGDBGameFull extends IGDBSearchStub {
|
||||
age_ratings?: IGDBID[];
|
||||
@@ -302,6 +375,34 @@ export class IGDBProvider implements MetadataProvider {
|
||||
return results;
|
||||
}
|
||||
|
||||
private async getAgeRatings(
|
||||
ageRatingIds: IGDBID[] | undefined,
|
||||
): Promise<GameMetadataAgeRating[]> {
|
||||
if (!ageRatingIds?.length) return [];
|
||||
|
||||
const results: GameMetadataAgeRating[] = [];
|
||||
for (const id of ageRatingIds) {
|
||||
const response = await this.request<IGDBAgeRating>(
|
||||
"age_ratings",
|
||||
`where id = ${id}; fields category,rating,rating_cover_url;`,
|
||||
);
|
||||
|
||||
for (const ar of response) {
|
||||
const organization = IGDB_CATEGORY_TO_ORG[ar.category];
|
||||
const rating = IGDB_RATING_TO_STRING[ar.rating];
|
||||
if (!organization || !rating) continue;
|
||||
|
||||
results.push({
|
||||
organization,
|
||||
rating,
|
||||
ratingCoverUrl: ar.rating_cover_url,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
name() {
|
||||
return "IGDB";
|
||||
}
|
||||
@@ -444,6 +545,7 @@ export class IGDBProvider implements MetadataProvider {
|
||||
};
|
||||
|
||||
const genres = await this.getGenres(currentGame.genres);
|
||||
const ageRatings = await this.getAgeRatings(currentGame.age_ratings);
|
||||
|
||||
let description = "";
|
||||
let shortDescription = "";
|
||||
@@ -468,6 +570,7 @@ export class IGDBProvider implements MetadataProvider {
|
||||
|
||||
genres,
|
||||
reviews: [review],
|
||||
ageRatings,
|
||||
|
||||
publishers,
|
||||
developers,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
_FetchGameMetadataParams,
|
||||
_FetchCompanyMetadataParams,
|
||||
GameMetadata,
|
||||
GameMetadataAgeRating,
|
||||
GameMetadataSearchResult,
|
||||
InternalGameMetadataResult,
|
||||
CompanyMetadata,
|
||||
@@ -176,6 +177,20 @@ export class MetadataHandler {
|
||||
return results;
|
||||
}
|
||||
|
||||
private parseAgeRatings(ageRatings: GameMetadataAgeRating[], gameId: string) {
|
||||
return ageRatings.map((ar) => ({
|
||||
where: {
|
||||
gameOrganizationKey: {
|
||||
gameId,
|
||||
organization: ar.organization,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
...ar,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async createGame(
|
||||
result: { sourceId: string; id: string; name: string },
|
||||
libraryId: string,
|
||||
@@ -287,6 +302,12 @@ export class MetadataHandler {
|
||||
ratings: {
|
||||
connectOrCreate: metadataHandler.parseRatings(metadata.reviews),
|
||||
},
|
||||
ageRatings: {
|
||||
connectOrCreate: metadataHandler.parseAgeRatings(
|
||||
metadata.ageRatings,
|
||||
gameId,
|
||||
),
|
||||
},
|
||||
tags: {
|
||||
connect: await metadataHandler.parseTags(metadata.tags),
|
||||
},
|
||||
|
||||
@@ -35,6 +35,7 @@ export class ManualMetadataProvider implements MetadataProvider {
|
||||
developers: [],
|
||||
tags: [],
|
||||
reviews: [],
|
||||
ageRatings: [],
|
||||
|
||||
icon: iconId,
|
||||
coverId: iconId,
|
||||
|
||||
@@ -445,6 +445,7 @@ export class PCGamingWikiProvider implements MetadataProvider {
|
||||
tags: this.compileTags(game),
|
||||
|
||||
reviews: pageContent.reception.filter((v) => typeof v !== "undefined"),
|
||||
ageRatings: [],
|
||||
publishers,
|
||||
developers,
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { MetadataSource } from "~/prisma/client/enums";
|
||||
import { AgeRatingOrganization, MetadataSource } from "~/prisma/client/enums";
|
||||
import {
|
||||
ESRBRating,
|
||||
PEGIRating,
|
||||
USKRating,
|
||||
ACBRating,
|
||||
} from "~/utils/ageRatings";
|
||||
import type { MetadataProvider } from ".";
|
||||
import type {
|
||||
GameMetadataSearchResult,
|
||||
@@ -7,6 +13,7 @@ import type {
|
||||
_FetchCompanyMetadataParams,
|
||||
CompanyMetadata,
|
||||
GameMetadataRating,
|
||||
GameMetadataAgeRating,
|
||||
} from "./types";
|
||||
import type { TaskRunContext } from "../tasks";
|
||||
import * as jdenticon from "jdenticon";
|
||||
@@ -143,6 +150,12 @@ interface SteamTagsPackage {
|
||||
};
|
||||
}
|
||||
|
||||
interface SteamRatingEntry {
|
||||
rating: string;
|
||||
descriptors?: string;
|
||||
required_age?: string;
|
||||
}
|
||||
|
||||
interface SteamWebAppDetailsSmall {
|
||||
type: string;
|
||||
name: string;
|
||||
@@ -162,6 +175,7 @@ interface SteamWebAppDetailsSmall {
|
||||
mac_requirements: { minimum: string; recommended: string };
|
||||
linux_requirements: { minimum: string; recommended: string };
|
||||
legal_notice: string;
|
||||
ratings?: Record<string, SteamRatingEntry>;
|
||||
}
|
||||
|
||||
interface SteamWebAppDetailsLarge extends SteamWebAppDetailsSmall {
|
||||
@@ -178,6 +192,28 @@ interface SteamWebAppDetailsPackage {
|
||||
};
|
||||
}
|
||||
|
||||
const STEAM_RATING_KEY_TO_ORG: Record<string, AgeRatingOrganization> = {
|
||||
esrb: AgeRatingOrganization.ESRB,
|
||||
pegi: AgeRatingOrganization.PEGI,
|
||||
usk: AgeRatingOrganization.USK,
|
||||
oflc: AgeRatingOrganization.ACB,
|
||||
};
|
||||
|
||||
function lowercaseNormMap(
|
||||
ratings: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.values(ratings).map((v) => [v.toLowerCase(), v]),
|
||||
);
|
||||
}
|
||||
|
||||
const STEAM_RATING_NORMALIZE: Record<string, Record<string, string>> = {
|
||||
esrb: lowercaseNormMap(ESRBRating),
|
||||
pegi: lowercaseNormMap(PEGIRating),
|
||||
usk: lowercaseNormMap(USKRating),
|
||||
oflc: lowercaseNormMap(ACBRating),
|
||||
};
|
||||
|
||||
export class SteamProvider implements MetadataProvider {
|
||||
name() {
|
||||
return "Steam";
|
||||
@@ -363,7 +399,7 @@ export class SteamProvider implements MetadataProvider {
|
||||
context?.logger.info("Fetching detailed description and reviews...");
|
||||
const webAppDetails = (await this._getWebAppDetails(
|
||||
id,
|
||||
"metacritic",
|
||||
"metacritic,ratings",
|
||||
)) as SteamWebAppDetailsLarge;
|
||||
|
||||
const detailedDescription =
|
||||
@@ -407,6 +443,13 @@ export class SteamProvider implements MetadataProvider {
|
||||
`Steam reviews: ${steamReviewCount} reviews, ${steamRating}% positive`,
|
||||
);
|
||||
|
||||
const ageRatings = this._extractAgeRatings(webAppDetails?.ratings);
|
||||
if (ageRatings.length > 0) {
|
||||
context?.logger.info(
|
||||
`Found ${ageRatings.length} age ratings: ${ageRatings.map((r) => `${r.organization}: ${r.rating}`).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (webAppDetails?.metacritic) {
|
||||
reviews.push({
|
||||
metadataId: id,
|
||||
@@ -437,6 +480,7 @@ export class SteamProvider implements MetadataProvider {
|
||||
developers,
|
||||
tags,
|
||||
reviews,
|
||||
ageRatings,
|
||||
icon,
|
||||
bannerId: banner,
|
||||
coverId: cover,
|
||||
@@ -795,6 +839,24 @@ export class SteamProvider implements MetadataProvider {
|
||||
return appData;
|
||||
}
|
||||
|
||||
private _extractAgeRatings(
|
||||
ratings: Record<string, SteamRatingEntry> | undefined,
|
||||
): GameMetadataAgeRating[] {
|
||||
if (!ratings) return [];
|
||||
|
||||
const results: GameMetadataAgeRating[] = [];
|
||||
for (const [key, entry] of Object.entries(ratings)) {
|
||||
const org = STEAM_RATING_KEY_TO_ORG[key];
|
||||
if (!org) continue;
|
||||
|
||||
const normalized =
|
||||
STEAM_RATING_NORMALIZE[key]?.[entry.rating] ??
|
||||
entry.rating.toUpperCase();
|
||||
results.push({ organization: org, rating: normalized });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private _getImageUrl(filename: string, format?: string): string {
|
||||
if (!filename || filename.trim().length === 0) return "";
|
||||
|
||||
|
||||
+8
@@ -1,4 +1,5 @@
|
||||
import type { Company, GameRating } from "~/prisma/client";
|
||||
import type { AgeRatingOrganization } from "~/prisma/client/enums";
|
||||
import type { TransactionDataType } from "../objects/transactional";
|
||||
import type { ObjectReference } from "../objects/objectHandler";
|
||||
|
||||
@@ -27,6 +28,12 @@ export type GameMetadataRating = Pick<
|
||||
| "mReviewRating"
|
||||
>;
|
||||
|
||||
export interface GameMetadataAgeRating {
|
||||
organization: AgeRatingOrganization;
|
||||
rating: string;
|
||||
ratingCoverUrl?: string;
|
||||
}
|
||||
|
||||
export interface GameMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -42,6 +49,7 @@ export interface GameMetadata {
|
||||
tags: string[];
|
||||
|
||||
reviews: GameMetadataRating[];
|
||||
ageRatings: GameMetadataAgeRating[];
|
||||
|
||||
// Created with another utility function
|
||||
icon: ObjectReference;
|
||||
|
||||
Reference in New Issue
Block a user