made create scheduling api
This commit is contained in:
@@ -1451,9 +1451,11 @@ model ScheduleHeader {
|
||||
activityVenue ActivityVenues @relation(fields: [activityVenueXid], references: [id], onDelete: Cascade)
|
||||
scheduleType String @map("schedule_type") @db.VarChar(30)
|
||||
startDate DateTime @map("start_date")
|
||||
endDate DateTime @map("end_date")
|
||||
byWeekday Boolean @default(false) @map("by_weekday")
|
||||
earlyCheckInMins Int @map("early_check_in_mins")
|
||||
endDate DateTime? @map("end_date")
|
||||
earlyCheckInMins Int? @map("early_check_in_mins")
|
||||
bookingCutOffMins Int? @map("booking_cut_off_mins")
|
||||
effectiveFromDt String? @map("effective_from_dt")
|
||||
effectiveToDt String? @map("effective_to_dt")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
@@ -1461,41 +1463,77 @@ model ScheduleHeader {
|
||||
ScheduleDetails ScheduleDetails[]
|
||||
Cancellations Cancellations[]
|
||||
ItineraryActivities ItineraryActivities[]
|
||||
scheduleOccurences ScheduleOccurences[]
|
||||
scheduleRecurrences ScheduleRecurrence[]
|
||||
|
||||
@@map("schedule_header")
|
||||
@@schema("sch")
|
||||
}
|
||||
|
||||
model ScheduleDetails {
|
||||
id Int @id @default(autoincrement())
|
||||
scheduleHeaderXid Int @map("schedule_header_xid")
|
||||
scheduleHeader ScheduleHeader @relation(fields: [scheduleHeaderXid], references: [id], onDelete: Cascade)
|
||||
startTime String @map("start_time") @db.VarChar(30)
|
||||
endTime String @map("end_time") @db.VarChar(30)
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
id Int @id @default(autoincrement())
|
||||
scheduleHeaderXid Int @map("schedule_header_xid")
|
||||
scheduleHeader ScheduleHeader @relation(fields: [scheduleHeaderXid], references: [id], onDelete: Cascade)
|
||||
occurenceDate DateTime? @map("occurence_date")
|
||||
weekDay String? @map("week_day") @db.VarChar(30)
|
||||
dayOfMonth Int? @map("day_of_month")
|
||||
startTime String @map("start_time") @db.VarChar(30)
|
||||
endTime String @map("end_time") @db.VarChar(30)
|
||||
maxCapacity Int @map("max_capacity")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
cancellations Cancellations[]
|
||||
|
||||
@@map("schedule_details")
|
||||
@@schema("sch")
|
||||
}
|
||||
|
||||
model ScheduleOccurences {
|
||||
id Int @id @default(autoincrement())
|
||||
scheduleHeaderXid Int @map("schedule_header_xid")
|
||||
scheduleHeader ScheduleHeader @relation(fields: [scheduleHeaderXid], references: [id], onDelete: Cascade)
|
||||
occurenceDate DateTime @map("occurence_date")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
@@map("schedule_occurences")
|
||||
@@schema("sch")
|
||||
}
|
||||
|
||||
model ScheduleRecurrence {
|
||||
id Int @id @default(autoincrement())
|
||||
scheduleHeaderXid Int @map("schedule_header_xid")
|
||||
scheduleHeader ScheduleHeader @relation(fields: [scheduleHeaderXid], references: [id], onDelete: Cascade)
|
||||
weekDay String? @map("week_day") @db.VarChar(30)
|
||||
dayOfMonth Int? @map("day_of_month")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
@@map("schedule_recurrence")
|
||||
@@schema("sch")
|
||||
}
|
||||
|
||||
model Cancellations {
|
||||
id Int @id @default(autoincrement())
|
||||
scheduleHeaderXid Int @map("schedule_header_xid")
|
||||
scheduleHeader ScheduleHeader @relation(fields: [scheduleHeaderXid], references: [id], onDelete: Cascade)
|
||||
occurenceDate DateTime @map("occurence_date")
|
||||
startTime String @map("start_time") @db.VarChar(30)
|
||||
endTime String @map("end_time") @db.VarChar(30)
|
||||
cancellationReason String @map("cancellation_reason")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
id Int @id @default(autoincrement())
|
||||
scheduleHeaderXid Int @map("schedule_header_xid")
|
||||
scheduleHeader ScheduleHeader @relation(fields: [scheduleHeaderXid], references: [id], onDelete: Cascade)
|
||||
occurenceDate DateTime @map("occurence_date")
|
||||
slotXid Int @map("slot_xid")
|
||||
slot ScheduleDetails @relation(fields: [slotXid], references: [id], onDelete: Cascade)
|
||||
cancellationReason String @map("cancellation_reason")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
@@map("cancellations")
|
||||
@@schema("act")
|
||||
@@schema("sch")
|
||||
}
|
||||
|
||||
// ITINERARY MODELS
|
||||
|
||||
@@ -446,3 +446,18 @@ mediaDeleteFroms3:
|
||||
- httpApi:
|
||||
path: /media/delete
|
||||
method: delete
|
||||
|
||||
createSchedulingForAct:
|
||||
handler: src/modules/host/handlers/Activity_Hub/Scheduling/createSchedulingOfAct.handler
|
||||
memorySize: 512
|
||||
package:
|
||||
patterns:
|
||||
- 'src/modules/host/handlers/Activity_Hub/Scheduling/**'
|
||||
- ${file(./serverless/patterns/base.yml):pattern1}
|
||||
- ${file(./serverless/patterns/base.yml):pattern2}
|
||||
- ${file(./serverless/patterns/base.yml):pattern3}
|
||||
- ${file(./serverless/patterns/base.yml):pattern4}
|
||||
events:
|
||||
- httpApi:
|
||||
path: /scheduling/create
|
||||
method: post
|
||||
|
||||
@@ -99,5 +99,12 @@ export const ACTIVITY_AM_DISPLAY_STATUS = {
|
||||
ACTIVITY_ENHANCING: 'Enhancing',
|
||||
NOT_LISTED: 'Not Listed',
|
||||
ACTIVITY_LISTED: 'Listed',
|
||||
ACTIVITY_REVISED:'Activity Revised'
|
||||
ACTIVITY_REVISED: 'Activity Revised'
|
||||
};
|
||||
|
||||
export const SCHEDULING_TYPE = {
|
||||
ONCE: 'ONCE',
|
||||
WEEKLY: 'WEEKLY',
|
||||
MONTHLY: 'MONTHLY',
|
||||
CUSTOM: 'CUSTOM',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { z } from 'zod';
|
||||
import { SCHEDULING_TYPE } from '../../constants/host.constant';
|
||||
|
||||
const WeekdayEnum = z.enum([
|
||||
'MONDAY',
|
||||
'TUESDAY',
|
||||
'WEDNESDAY',
|
||||
'THURSDAY',
|
||||
'FRIDAY',
|
||||
'SATURDAY',
|
||||
'SUNDAY',
|
||||
]);
|
||||
|
||||
export const scheduleActivity = z.object({
|
||||
activityXid: z.number(),
|
||||
|
||||
scheduleType: z.enum([
|
||||
SCHEDULING_TYPE.ONCE,
|
||||
SCHEDULING_TYPE.WEEKLY,
|
||||
SCHEDULING_TYPE.MONTHLY,
|
||||
SCHEDULING_TYPE.CUSTOM,
|
||||
]),
|
||||
|
||||
dateRange: z.object({
|
||||
startDate: z.string(),
|
||||
endDate: z.string().nullable().optional(),
|
||||
}),
|
||||
|
||||
rules: z.object({
|
||||
weekdays: z.array(WeekdayEnum).optional(),
|
||||
monthDates: z.array(z.number()).optional(),
|
||||
customDates: z.array(z.string()).optional(),
|
||||
}),
|
||||
|
||||
venues: z.array(
|
||||
z.object({
|
||||
venueXid: z.number(),
|
||||
slots: z.array(
|
||||
z.object({
|
||||
startTime: z.string(),
|
||||
endTime: z.string(),
|
||||
weekDay: WeekdayEnum.nullable().optional(),
|
||||
dayOfMonth: z.number().nullable().optional(),
|
||||
occurrenceDate: z.string().nullable().optional(),
|
||||
maxCapacity: z.number(),
|
||||
})
|
||||
).min(1),
|
||||
})
|
||||
),
|
||||
|
||||
earlyCheckInMins: z.number().optional(),
|
||||
bookingCutOffMins: z.number().optional(),
|
||||
isLateCheckingAllowed: z.boolean().optional(),
|
||||
isInstantBooking: z.boolean().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.scheduleType === 'WEEKLY' && !data.rules.weekdays?.length) {
|
||||
ctx.addIssue({ path: ['rules', 'weekdays'], message: 'Weekdays required for WEEKLY schedule', code: 'custom' });
|
||||
}
|
||||
|
||||
if (data.scheduleType === 'MONTHLY' && !data.rules.monthDates?.length) {
|
||||
ctx.addIssue({ path: ['rules', 'monthDates'], message: 'Month dates required for MONTHLY schedule', code: 'custom' });
|
||||
}
|
||||
|
||||
if (
|
||||
(data.scheduleType === 'CUSTOM' || data.scheduleType === 'ONCE') &&
|
||||
!data.rules.customDates?.length
|
||||
) {
|
||||
ctx.addIssue({ path: ['rules', 'customDates'], message: 'Custom dates required', code: 'custom' });
|
||||
}
|
||||
});
|
||||
|
||||
export type ScheduleActivityDTO = z.infer<typeof scheduleActivity>;
|
||||
|
||||
|
||||
39
src/modules/host/dto/createSchedulingOfAct.dto.ts
Normal file
39
src/modules/host/dto/createSchedulingOfAct.dto.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export interface ScheduleSlotDTO {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
weekDay?: 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'THURSDAY' | 'FRIDAY' | 'SATURDAY' | 'SUNDAY' | null;
|
||||
dayOfMonth?: number | null; // 1–31
|
||||
occurrenceDate?: string | null;
|
||||
maxCapacity: number;
|
||||
}
|
||||
|
||||
export interface ScheduleVenueDTO {
|
||||
venueXid: number;
|
||||
slots: ScheduleSlotDTO[];
|
||||
}
|
||||
|
||||
// export interface ScheduleActivityDTO {
|
||||
// activityXid: number;
|
||||
// scheduleType: 'ONCE' | 'WEEKLY' | 'MONTHLY' | 'CUSTOM';
|
||||
|
||||
// dateRange: {
|
||||
// startDate: string;
|
||||
// endDate?: string | null;
|
||||
// };
|
||||
|
||||
// rules: {
|
||||
// weekdays?: (
|
||||
// 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' |
|
||||
// 'THURSDAY' | 'FRIDAY' | 'SATURDAY' | 'SUNDAY'
|
||||
// )[];
|
||||
// monthDates?: number[];
|
||||
// customDates?: string[];
|
||||
// };
|
||||
|
||||
// venues: ScheduleVenueDTO[];
|
||||
|
||||
// earlyCheckInMins?: number;
|
||||
// bookingCutOffMins?: number;
|
||||
// isLateCheckingAllowed?: boolean;
|
||||
// isInstantBooking?: boolean;
|
||||
// }
|
||||
@@ -0,0 +1,89 @@
|
||||
import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda';
|
||||
import { safeHandler } from '../../../../../common/utils/handlers/safeHandler';
|
||||
import { prismaClient } from '../../../../../common/database/prisma.lambda.service';
|
||||
import { SchedulingService } from '../../../services/activityScheduling.service';
|
||||
import { HostService } from '../../../services/host.service';
|
||||
import ApiError from '../../../../../common/utils/helper/ApiError';
|
||||
import { verifyHostToken } from '../../../../../common/middlewares/jwt/authForHost';
|
||||
import { scheduleActivity } from '../../../../../common/utils/validation/host/createSchedulingOfAct.validation';
|
||||
import { z } from 'zod';
|
||||
|
||||
const schedulingService = new SchedulingService(prismaClient);
|
||||
const hostService = new HostService(prismaClient);
|
||||
|
||||
export const handler = safeHandler(async (
|
||||
event: APIGatewayProxyEvent,
|
||||
context?: Context
|
||||
): Promise<APIGatewayProxyResult> => {
|
||||
// Extract token from headers
|
||||
const token = event.headers['x-auth-token'] || event.headers['X-Auth-Token']
|
||||
if (!token) {
|
||||
throw new ApiError(400, 'This is a protected route. Please provide a valid token.');
|
||||
}
|
||||
|
||||
// Authenticate user using the shared authForHost function
|
||||
const userInfo = await verifyHostToken(token);
|
||||
const hostId = userInfo.id;
|
||||
|
||||
if (Number.isNaN(hostId)) {
|
||||
throw new ApiError(400, 'Host id must be a number');
|
||||
}
|
||||
|
||||
const host = await hostService.getHostIdByUserXid(hostId);
|
||||
if (!host) {
|
||||
throw new ApiError(404, 'Host not found');
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = event.body ? JSON.parse(event.body) : {};
|
||||
} catch {
|
||||
throw new ApiError(400, 'Invalid JSON payload');
|
||||
}
|
||||
|
||||
// ✅ Validate payload using Zod
|
||||
|
||||
const parsed = scheduleActivity.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
const msg = parsed.error.issues
|
||||
.map(e => e.message)
|
||||
.join(', ');
|
||||
throw new ApiError(400, `Validation failed: ${msg}`);
|
||||
}
|
||||
|
||||
const activity = await schedulingService.getActivityByXid(parsed.data.activityXid);
|
||||
if (!activity) {
|
||||
throw new ApiError(404, "Activity not found");
|
||||
}
|
||||
|
||||
if (parsed.data.venues && parsed.data.venues.length > 0) {
|
||||
for (const venue of parsed.data.venues) {
|
||||
const venueExists = await schedulingService.getVenueFromVenueXid(
|
||||
venue.venueXid,
|
||||
parsed.data.activityXid
|
||||
);
|
||||
|
||||
if (!venueExists) {
|
||||
throw new ApiError(
|
||||
404,
|
||||
`Venue with xid ${venue.venueXid} not found for this activity`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
await schedulingService.addSchedulingForActivity(parsed.data);
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
message: 'Scheduling details updated successfully',
|
||||
}),
|
||||
};
|
||||
});
|
||||
212
src/modules/host/services/activityScheduling.service.ts
Normal file
212
src/modules/host/services/activityScheduling.service.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { ScheduleActivityDTO } from '../../../common/utils/validation/host/createSchedulingOfAct.validation';
|
||||
import ApiError from '../../../common/utils/helper/ApiError';
|
||||
import { SCHEDULING_TYPE } from '../../../common/utils/constants/host.constant';
|
||||
|
||||
@Injectable()
|
||||
export class SchedulingService {
|
||||
constructor(private prisma: PrismaClient) { }
|
||||
|
||||
async addSchedulingForActivity(data: ScheduleActivityDTO) {
|
||||
const {
|
||||
activityXid,
|
||||
scheduleType,
|
||||
dateRange,
|
||||
rules,
|
||||
venues,
|
||||
earlyCheckInMins,
|
||||
bookingCutOffMins,
|
||||
} = data;
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const createdHeaders: number[] = [];
|
||||
|
||||
for (const venue of venues) {
|
||||
|
||||
const header = await tx.scheduleHeader.create({
|
||||
data: {
|
||||
activityXid,
|
||||
activityVenueXid: venue.venueXid,
|
||||
scheduleType,
|
||||
startDate: new Date(dateRange.startDate),
|
||||
endDate: dateRange.endDate ? new Date(dateRange.endDate) : null,
|
||||
earlyCheckInMins,
|
||||
bookingCutOffMins,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
createdHeaders.push(header.id);
|
||||
|
||||
// WEEKLY
|
||||
if (scheduleType === SCHEDULING_TYPE.WEEKLY) {
|
||||
await tx.scheduleRecurrence.createMany({
|
||||
data: rules.weekdays!.map(day => ({
|
||||
scheduleHeaderXid: header.id,
|
||||
weekDay: day,
|
||||
isActive: true,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// MONTHLY
|
||||
if (scheduleType === SCHEDULING_TYPE.MONTHLY) {
|
||||
await tx.scheduleRecurrence.createMany({
|
||||
data: rules.monthDates!.map(day => ({
|
||||
scheduleHeaderXid: header.id,
|
||||
dayOfMonth: day,
|
||||
isActive: true,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// CUSTOM / ONCE
|
||||
if (scheduleType === SCHEDULING_TYPE.CUSTOM || scheduleType === SCHEDULING_TYPE.ONCE) {
|
||||
await tx.scheduleOccurences.createMany({
|
||||
data: rules.customDates!.map(d => ({
|
||||
scheduleHeaderXid: header.id,
|
||||
occurenceDate: new Date(d),
|
||||
isActive: true,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Slots
|
||||
for (const slot of venue.slots) {
|
||||
await tx.scheduleDetails.create({
|
||||
data: {
|
||||
scheduleHeaderXid: header.id,
|
||||
occurenceDate: slot.occurrenceDate ? new Date(slot.occurrenceDate) : null,
|
||||
weekDay: slot.weekDay ?? null,
|
||||
dayOfMonth: slot.dayOfMonth ?? null,
|
||||
startTime: slot.startTime,
|
||||
endTime: slot.endTime,
|
||||
maxCapacity: slot.maxCapacity,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, scheduleHeaderIds: createdHeaders };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
async getAvailableSlotsForDate(
|
||||
activityXid: number,
|
||||
selectedDate: string
|
||||
) {
|
||||
const date = new Date(selectedDate);
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
throw new ApiError(400, 'Invalid date format');
|
||||
}
|
||||
|
||||
const weekDay = date.toLocaleDateString('en-US', { weekday: 'long' }).toUpperCase();
|
||||
const dayOfMonth = date.getDate();
|
||||
|
||||
/* --------------------------------
|
||||
1️⃣ FETCH ACTIVE SCHEDULE HEADERS
|
||||
-------------------------------- */
|
||||
const scheduleHeaders = await this.prisma.scheduleHeader.findMany({
|
||||
where: {
|
||||
activityXid,
|
||||
isActive: true,
|
||||
startDate: { lte: date },
|
||||
OR: [
|
||||
{ endDate: null },
|
||||
{ endDate: { gte: date } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
activityVenue: {
|
||||
select: {
|
||||
id: true,
|
||||
venueName: true,
|
||||
venueLabel: true,
|
||||
venueCapacity: true,
|
||||
},
|
||||
},
|
||||
scheduleRecurrences: {
|
||||
where: { isActive: true },
|
||||
},
|
||||
ScheduleDetails: {
|
||||
where: {
|
||||
isActive: true,
|
||||
OR: [
|
||||
{ occurenceDate: date }, // ONLY_ONCE / CUSTOM
|
||||
{ weekDay: weekDay }, // WEEKLY
|
||||
{ dayOfMonth: dayOfMonth }, // MONTHLY
|
||||
],
|
||||
},
|
||||
},
|
||||
Cancellations: {
|
||||
where: {
|
||||
occurenceDate: date,
|
||||
isActive: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!scheduleHeaders.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/* --------------------------------
|
||||
2️⃣ BUILD RESPONSE
|
||||
-------------------------------- */
|
||||
const response = [];
|
||||
|
||||
for (const header of scheduleHeaders) {
|
||||
const cancelledSlotIds = new Set(
|
||||
header.Cancellations.map(c => c.slotXid)
|
||||
);
|
||||
|
||||
const slots = header.ScheduleDetails
|
||||
.filter(slot => !cancelledSlotIds.has(slot.id))
|
||||
.map(slot => ({
|
||||
slotId: slot.id,
|
||||
startTime: slot.startTime,
|
||||
endTime: slot.endTime,
|
||||
maxCapacity: slot.maxCapacity,
|
||||
}));
|
||||
|
||||
if (!slots.length) continue;
|
||||
|
||||
response.push({
|
||||
venueXid: header.activityVenue.id,
|
||||
venueName: header.activityVenue.venueName,
|
||||
venueLabel: header.activityVenue.venueLabel,
|
||||
slots,
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async getVenueFromVenueXid(venueXid: number, activityXid: number) {
|
||||
return await this.prisma.activityVenues.findUnique({
|
||||
where: { id: venueXid, activityXid: activityXid, isActive: true },
|
||||
select: {
|
||||
id: true,
|
||||
venueName: true,
|
||||
venueLabel: true,
|
||||
venueCapacity: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async getActivityByXid(activityXid: number) {
|
||||
return await this.prisma.activities.findUnique({
|
||||
where: { id: activityXid, isActive: true },
|
||||
select: {
|
||||
id: true,
|
||||
activityTitle: true,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user