feat(platform): harden access scoping and delivery baseline
This commit is contained in:
@@ -14,6 +14,7 @@ import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday
|
||||
import { createTRPCRouter, adminProcedure, protectedProcedure, type TRPCContext } from "../trpc.js";
|
||||
|
||||
type HolidayCalendarScope = HolidayCalendarScopeInput;
|
||||
type HolidayReadContext = Pick<TRPCContext, "db">;
|
||||
|
||||
const HOLIDAY_SCOPE = {
|
||||
COUNTRY: "COUNTRY",
|
||||
@@ -49,6 +50,401 @@ function clampDate(date: Date): Date {
|
||||
return value;
|
||||
}
|
||||
|
||||
function fmtDate(value: Date | null | undefined): string | null {
|
||||
return value ? value.toISOString().slice(0, 10) : null;
|
||||
}
|
||||
|
||||
function formatIsoDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatHolidayCalendarEntryDetail(entry: {
|
||||
id: string;
|
||||
date: Date;
|
||||
name: string;
|
||||
isRecurringAnnual?: boolean | null;
|
||||
source?: string | null;
|
||||
}) {
|
||||
return {
|
||||
id: entry.id,
|
||||
date: formatIsoDate(entry.date),
|
||||
name: entry.name,
|
||||
isRecurringAnnual: entry.isRecurringAnnual ?? false,
|
||||
source: entry.source ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function formatHolidayCalendarDetail(calendar: {
|
||||
id: string;
|
||||
name: string;
|
||||
scopeType: string;
|
||||
stateCode?: string | null;
|
||||
isActive?: boolean | null;
|
||||
priority?: number | null;
|
||||
country?: { id: string; code: string; name: string } | null;
|
||||
metroCity?: { id: string; name: string } | null;
|
||||
_count?: { entries?: number | null } | null;
|
||||
entries?: Array<{
|
||||
id: string;
|
||||
date: Date;
|
||||
name: string;
|
||||
isRecurringAnnual?: boolean | null;
|
||||
source?: string | null;
|
||||
}> | null;
|
||||
}) {
|
||||
const entries = calendar.entries?.map(formatHolidayCalendarEntryDetail) ?? [];
|
||||
|
||||
return {
|
||||
id: calendar.id,
|
||||
name: calendar.name,
|
||||
scopeType: calendar.scopeType,
|
||||
stateCode: calendar.stateCode ?? null,
|
||||
isActive: calendar.isActive ?? true,
|
||||
priority: calendar.priority ?? 0,
|
||||
country: calendar.country
|
||||
? {
|
||||
id: calendar.country.id,
|
||||
code: calendar.country.code,
|
||||
name: calendar.country.name,
|
||||
}
|
||||
: null,
|
||||
metroCity: calendar.metroCity
|
||||
? {
|
||||
id: calendar.metroCity.id,
|
||||
name: calendar.metroCity.name,
|
||||
}
|
||||
: null,
|
||||
entryCount: calendar._count?.entries ?? entries.length,
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
function formatResolvedHolidayDetail(holiday: {
|
||||
date: string;
|
||||
name: string;
|
||||
scopeType: string;
|
||||
calendarName: string;
|
||||
sourceType: string;
|
||||
}) {
|
||||
return {
|
||||
date: holiday.date,
|
||||
name: holiday.name,
|
||||
scope: holiday.scopeType,
|
||||
calendarName: holiday.calendarName,
|
||||
sourceType: holiday.sourceType,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeResolvedHolidaysDetail(holidays: Array<{
|
||||
date: string;
|
||||
name: string;
|
||||
scope: string;
|
||||
calendarName: string;
|
||||
sourceType: string;
|
||||
}>) {
|
||||
const byScope = new Map<string, number>();
|
||||
const bySourceType = new Map<string, number>();
|
||||
const byCalendar = new Map<string, number>();
|
||||
|
||||
for (const holiday of holidays) {
|
||||
byScope.set(holiday.scope, (byScope.get(holiday.scope) ?? 0) + 1);
|
||||
bySourceType.set(holiday.sourceType, (bySourceType.get(holiday.sourceType) ?? 0) + 1);
|
||||
byCalendar.set(holiday.calendarName, (byCalendar.get(holiday.calendarName) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
byScope: [...byScope.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([scope, count]) => ({ scope, count })),
|
||||
bySourceType: [...bySourceType.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([sourceType, count]) => ({ sourceType, count })),
|
||||
byCalendar: [...byCalendar.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([calendarName, count]) => ({ calendarName, count })),
|
||||
};
|
||||
}
|
||||
|
||||
const ResolveHolidaysInputSchema = z.object({
|
||||
periodStart: z.coerce.date(),
|
||||
periodEnd: z.coerce.date(),
|
||||
countryId: z.string().optional(),
|
||||
countryCode: z.string().trim().min(1).optional(),
|
||||
stateCode: z.string().trim().min(1).optional(),
|
||||
metroCityId: z.string().optional(),
|
||||
metroCityName: z.string().trim().min(1).optional(),
|
||||
}).superRefine((input, issueCtx) => {
|
||||
if (!input.countryId && !input.countryCode) {
|
||||
issueCtx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Either countryId or countryCode is required.",
|
||||
path: ["countryId"],
|
||||
});
|
||||
}
|
||||
if (input.periodEnd < input.periodStart) {
|
||||
issueCtx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "periodEnd must be on or after periodStart.",
|
||||
path: ["periodEnd"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const ResolveResourceHolidaysInputSchema = z.object({
|
||||
resourceId: z.string(),
|
||||
periodStart: z.coerce.date(),
|
||||
periodEnd: z.coerce.date(),
|
||||
}).superRefine((input, issueCtx) => {
|
||||
if (input.periodEnd < input.periodStart) {
|
||||
issueCtx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "periodEnd must be on or after periodStart.",
|
||||
path: ["periodEnd"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function readCalendarsSnapshot(
|
||||
ctx: HolidayReadContext,
|
||||
input?: {
|
||||
includeInactive?: boolean | undefined;
|
||||
countryCode?: string | undefined;
|
||||
scopeType?: "COUNTRY" | "STATE" | "CITY" | undefined;
|
||||
stateCode?: string | undefined;
|
||||
metroCity?: string | undefined;
|
||||
},
|
||||
) {
|
||||
const db = asHolidayCalendarDb(ctx.db);
|
||||
const where = {
|
||||
...(input?.includeInactive ? {} : { isActive: true }),
|
||||
...(input?.countryCode
|
||||
? {
|
||||
country: { code: { equals: input.countryCode.trim().toUpperCase(), mode: "insensitive" as const } },
|
||||
}
|
||||
: {}),
|
||||
...(input?.scopeType ? { scopeType: input.scopeType } : {}),
|
||||
...(input?.stateCode ? { stateCode: input.stateCode.trim().toUpperCase() } : {}),
|
||||
...(input?.metroCity
|
||||
? {
|
||||
metroCity: { name: { contains: input.metroCity.trim(), mode: "insensitive" as const } },
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
return db.holidayCalendar.findMany({
|
||||
where,
|
||||
include: {
|
||||
country: { select: { id: true, code: true, name: true } },
|
||||
metroCity: { select: { id: true, name: true } },
|
||||
_count: { select: { entries: true } },
|
||||
entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
|
||||
},
|
||||
orderBy: [
|
||||
{ country: { name: "asc" } },
|
||||
{ scopeType: "asc" },
|
||||
{ priority: "desc" },
|
||||
{ name: "asc" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function readCalendarByIdentifierSnapshot(ctx: HolidayReadContext, identifier: string) {
|
||||
const db = asHolidayCalendarDb(ctx.db);
|
||||
const trimmedIdentifier = identifier.trim();
|
||||
|
||||
let calendar = await db.holidayCalendar.findUnique({
|
||||
where: { id: trimmedIdentifier },
|
||||
include: {
|
||||
country: { select: { id: true, code: true, name: true } },
|
||||
metroCity: { select: { id: true, name: true } },
|
||||
entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
|
||||
},
|
||||
});
|
||||
|
||||
if (!calendar) {
|
||||
calendar = await db.holidayCalendar.findFirst({
|
||||
where: { name: { equals: trimmedIdentifier, mode: "insensitive" } },
|
||||
include: {
|
||||
country: { select: { id: true, code: true, name: true } },
|
||||
metroCity: { select: { id: true, name: true } },
|
||||
entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!calendar) {
|
||||
calendar = await db.holidayCalendar.findFirst({
|
||||
where: { name: { contains: trimmedIdentifier, mode: "insensitive" } },
|
||||
include: {
|
||||
country: { select: { id: true, code: true, name: true } },
|
||||
metroCity: { select: { id: true, name: true } },
|
||||
entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!calendar) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: `Holiday calendar not found: ${trimmedIdentifier}` });
|
||||
}
|
||||
|
||||
return calendar;
|
||||
}
|
||||
|
||||
async function readPreviewResolvedHolidaysSnapshot(
|
||||
ctx: HolidayReadContext,
|
||||
input: z.infer<typeof PreviewResolvedHolidaysSchema>,
|
||||
) {
|
||||
const country = await findUniqueOrThrow(
|
||||
ctx.db.country.findUnique({
|
||||
where: { id: input.countryId },
|
||||
select: { id: true, code: true, name: true },
|
||||
}),
|
||||
"Country",
|
||||
);
|
||||
|
||||
const metroCity = input.metroCityId
|
||||
? await ctx.db.metroCity.findUnique({
|
||||
where: { id: input.metroCityId },
|
||||
select: { id: true, name: true, countryId: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
|
||||
periodStart: new Date(`${input.year}-01-01T00:00:00.000Z`),
|
||||
periodEnd: new Date(`${input.year}-12-31T00:00:00.000Z`),
|
||||
countryId: input.countryId,
|
||||
countryCode: country.code,
|
||||
federalState: input.stateCode?.trim().toUpperCase() ?? null,
|
||||
metroCityId: input.metroCityId ?? null,
|
||||
metroCityName: metroCity?.name ?? null,
|
||||
});
|
||||
|
||||
return {
|
||||
locationContext: {
|
||||
countryId: input.countryId,
|
||||
countryCode: country.code,
|
||||
stateCode: input.stateCode?.trim().toUpperCase() ?? null,
|
||||
metroCityId: input.metroCityId ?? null,
|
||||
metroCity: metroCity?.name ?? null,
|
||||
year: input.year,
|
||||
},
|
||||
holidays: resolved.map((holiday) => ({
|
||||
date: holiday.date,
|
||||
name: holiday.name,
|
||||
scopeType: holiday.scope,
|
||||
calendarName: holiday.calendarName,
|
||||
sourceType: holiday.sourceType,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function readResolvedHolidaysSnapshot(
|
||||
ctx: HolidayReadContext,
|
||||
input: z.infer<typeof ResolveHolidaysInputSchema>,
|
||||
) {
|
||||
let resolvedCountryCode = input.countryCode?.trim().toUpperCase() ?? null;
|
||||
|
||||
if (!resolvedCountryCode && input.countryId) {
|
||||
const country = await findUniqueOrThrow(
|
||||
ctx.db.country.findUnique({
|
||||
where: { id: input.countryId },
|
||||
select: { code: true },
|
||||
}),
|
||||
"Country",
|
||||
);
|
||||
resolvedCountryCode = country.code;
|
||||
}
|
||||
|
||||
const metroCityName = input.metroCityId
|
||||
? (await ctx.db.metroCity.findUnique({
|
||||
where: { id: input.metroCityId },
|
||||
select: { name: true },
|
||||
}))?.name ?? null
|
||||
: input.metroCityName?.trim() ?? null;
|
||||
|
||||
const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
|
||||
periodStart: input.periodStart,
|
||||
periodEnd: input.periodEnd,
|
||||
countryId: input.countryId ?? null,
|
||||
countryCode: resolvedCountryCode,
|
||||
federalState: input.stateCode?.trim().toUpperCase() ?? null,
|
||||
metroCityId: input.metroCityId ?? null,
|
||||
metroCityName,
|
||||
});
|
||||
|
||||
return {
|
||||
periodStart: input.periodStart.toISOString().slice(0, 10),
|
||||
periodEnd: input.periodEnd.toISOString().slice(0, 10),
|
||||
locationContext: {
|
||||
countryId: input.countryId ?? null,
|
||||
countryCode: resolvedCountryCode,
|
||||
federalState: input.stateCode?.trim().toUpperCase() ?? null,
|
||||
metroCityId: input.metroCityId ?? null,
|
||||
metroCity: metroCityName,
|
||||
},
|
||||
holidays: resolved.map((holiday) => ({
|
||||
date: holiday.date,
|
||||
name: holiday.name,
|
||||
scopeType: holiday.scope,
|
||||
calendarName: holiday.calendarName,
|
||||
sourceType: holiday.sourceType,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function readResolvedResourceHolidaysSnapshot(
|
||||
ctx: HolidayReadContext,
|
||||
input: z.infer<typeof ResolveResourceHolidaysInputSchema>,
|
||||
) {
|
||||
const resource = await findUniqueOrThrow(
|
||||
ctx.db.resource.findUnique({
|
||||
where: { id: input.resourceId },
|
||||
select: {
|
||||
id: true,
|
||||
eid: true,
|
||||
displayName: true,
|
||||
federalState: true,
|
||||
countryId: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true, name: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
}),
|
||||
"Resource",
|
||||
);
|
||||
|
||||
const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
|
||||
periodStart: input.periodStart,
|
||||
periodEnd: input.periodEnd,
|
||||
countryId: resource.countryId ?? null,
|
||||
countryCode: resource.country?.code ?? null,
|
||||
federalState: resource.federalState ?? null,
|
||||
metroCityId: resource.metroCityId ?? null,
|
||||
metroCityName: resource.metroCity?.name ?? null,
|
||||
});
|
||||
|
||||
return {
|
||||
periodStart: input.periodStart.toISOString().slice(0, 10),
|
||||
periodEnd: input.periodEnd.toISOString().slice(0, 10),
|
||||
resource: {
|
||||
id: resource.id,
|
||||
eid: resource.eid,
|
||||
name: resource.displayName,
|
||||
country: resource.country?.name ?? resource.country?.code ?? null,
|
||||
countryCode: resource.country?.code ?? null,
|
||||
federalState: resource.federalState ?? null,
|
||||
metroCity: resource.metroCity?.name ?? null,
|
||||
},
|
||||
holidays: resolved.map((holiday) => ({
|
||||
date: holiday.date,
|
||||
name: holiday.name,
|
||||
scopeType: holiday.scope,
|
||||
calendarName: holiday.calendarName,
|
||||
sourceType: holiday.sourceType,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function assertEntryDateAvailable(
|
||||
db: HolidayCalendarDb,
|
||||
input: {
|
||||
@@ -153,26 +549,40 @@ async function assertScopeConsistency(
|
||||
|
||||
export const holidayCalendarRouter = createTRPCRouter({
|
||||
listCalendars: protectedProcedure
|
||||
.input(z.object({ includeInactive: z.boolean().optional() }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
const db = asHolidayCalendarDb(ctx.db);
|
||||
const where = input?.includeInactive ? undefined : { isActive: true };
|
||||
.input(z.object({
|
||||
includeInactive: z.boolean().optional(),
|
||||
countryCode: z.string().trim().min(1).optional(),
|
||||
scopeType: z.enum(["COUNTRY", "STATE", "CITY"]).optional(),
|
||||
stateCode: z.string().trim().min(1).optional(),
|
||||
metroCity: z.string().trim().min(1).optional(),
|
||||
}).optional())
|
||||
.query(async ({ ctx, input }) => readCalendarsSnapshot(ctx, input)),
|
||||
|
||||
return db.holidayCalendar.findMany({
|
||||
...(where ? { where } : {}),
|
||||
include: {
|
||||
country: { select: { id: true, code: true, name: true } },
|
||||
metroCity: { select: { id: true, name: true } },
|
||||
_count: { select: { entries: true } },
|
||||
entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
|
||||
},
|
||||
orderBy: [
|
||||
{ country: { name: "asc" } },
|
||||
{ scopeType: "asc" },
|
||||
{ priority: "desc" },
|
||||
{ name: "asc" },
|
||||
],
|
||||
});
|
||||
listCalendarsDetail: protectedProcedure
|
||||
.input(z.object({
|
||||
includeInactive: z.boolean().optional(),
|
||||
countryCode: z.string().trim().min(1).optional(),
|
||||
scopeType: z.enum(["COUNTRY", "STATE", "CITY"]).optional(),
|
||||
stateCode: z.string().trim().min(1).optional(),
|
||||
metroCity: z.string().trim().min(1).optional(),
|
||||
}).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
const calendars = await readCalendarsSnapshot(ctx, input);
|
||||
return {
|
||||
count: calendars.length,
|
||||
calendars: calendars.map(formatHolidayCalendarDetail),
|
||||
};
|
||||
}),
|
||||
|
||||
getCalendarByIdentifier: protectedProcedure
|
||||
.input(z.object({ identifier: z.string().trim().min(1) }))
|
||||
.query(async ({ ctx, input }) => readCalendarByIdentifierSnapshot(ctx, input.identifier)),
|
||||
|
||||
getCalendarByIdentifierDetail: protectedProcedure
|
||||
.input(z.object({ identifier: z.string().trim().min(1) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const calendar = await readCalendarByIdentifierSnapshot(ctx, input.identifier);
|
||||
return formatHolidayCalendarDetail(calendar);
|
||||
}),
|
||||
|
||||
getCalendarById: protectedProcedure
|
||||
@@ -323,7 +733,7 @@ export const holidayCalendarRouter = createTRPCRouter({
|
||||
source: "ui",
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
return { success: true, id: existing.id, name: existing.name };
|
||||
}),
|
||||
|
||||
createEntry: adminProcedure
|
||||
@@ -430,42 +840,61 @@ export const holidayCalendarRouter = createTRPCRouter({
|
||||
source: "ui",
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
return { success: true, id: existing.id, name: existing.name };
|
||||
}),
|
||||
|
||||
previewResolvedHolidays: protectedProcedure
|
||||
.input(PreviewResolvedHolidaysSchema)
|
||||
.query(async ({ ctx, input }) => (await readPreviewResolvedHolidaysSnapshot(ctx, input)).holidays),
|
||||
|
||||
previewResolvedHolidaysDetail: protectedProcedure
|
||||
.input(PreviewResolvedHolidaysSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const country = await findUniqueOrThrow(
|
||||
ctx.db.country.findUnique({
|
||||
where: { id: input.countryId },
|
||||
select: { code: true },
|
||||
}),
|
||||
"Country",
|
||||
);
|
||||
const resolved = await readPreviewResolvedHolidaysSnapshot(ctx, input);
|
||||
const holidays = resolved.holidays.map(formatResolvedHolidayDetail);
|
||||
return {
|
||||
count: holidays.length,
|
||||
locationContext: resolved.locationContext,
|
||||
summary: summarizeResolvedHolidaysDetail(holidays),
|
||||
holidays,
|
||||
};
|
||||
}),
|
||||
|
||||
const metroCity = input.metroCityId
|
||||
? await ctx.db.metroCity.findUnique({
|
||||
where: { id: input.metroCityId },
|
||||
select: { name: true },
|
||||
})
|
||||
: null;
|
||||
resolveHolidays: protectedProcedure
|
||||
.input(ResolveHolidaysInputSchema)
|
||||
.query(async ({ ctx, input }) => readResolvedHolidaysSnapshot(ctx, input)),
|
||||
|
||||
const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
|
||||
periodStart: new Date(`${input.year}-01-01T00:00:00.000Z`),
|
||||
periodEnd: new Date(`${input.year}-12-31T00:00:00.000Z`),
|
||||
countryId: input.countryId,
|
||||
countryCode: country.code,
|
||||
federalState: input.stateCode?.trim().toUpperCase() ?? null,
|
||||
metroCityId: input.metroCityId ?? null,
|
||||
metroCityName: metroCity?.name ?? null,
|
||||
});
|
||||
resolveHolidaysDetail: protectedProcedure
|
||||
.input(ResolveHolidaysInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const resolved = await readResolvedHolidaysSnapshot(ctx, input);
|
||||
const holidays = resolved.holidays.map(formatResolvedHolidayDetail);
|
||||
return {
|
||||
periodStart: resolved.periodStart,
|
||||
periodEnd: resolved.periodEnd,
|
||||
locationContext: resolved.locationContext,
|
||||
count: holidays.length,
|
||||
summary: summarizeResolvedHolidaysDetail(holidays),
|
||||
holidays,
|
||||
};
|
||||
}),
|
||||
|
||||
return resolved.map((holiday) => ({
|
||||
date: holiday.date,
|
||||
name: holiday.name,
|
||||
scopeType: holiday.scope,
|
||||
calendarName: holiday.calendarName,
|
||||
}));
|
||||
resolveResourceHolidays: protectedProcedure
|
||||
.input(ResolveResourceHolidaysInputSchema)
|
||||
.query(async ({ ctx, input }) => readResolvedResourceHolidaysSnapshot(ctx, input)),
|
||||
|
||||
resolveResourceHolidaysDetail: protectedProcedure
|
||||
.input(ResolveResourceHolidaysInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const resolved = await readResolvedResourceHolidaysSnapshot(ctx, input);
|
||||
const holidays = resolved.holidays.map(formatResolvedHolidayDetail);
|
||||
return {
|
||||
periodStart: resolved.periodStart,
|
||||
periodEnd: resolved.periodEnd,
|
||||
resource: resolved.resource,
|
||||
count: holidays.length,
|
||||
summary: summarizeResolvedHolidaysDetail(holidays),
|
||||
holidays,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user