Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/booking/booking.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ import { BookingRevenueModule } from "./revenue/booking-revenue.module";
import { Field } from "../fields/entities/field.entity";
import { FieldsModule } from "../fields/fields.module";
import { MembershipPricingHistory } from "./entities/membership-pricing-history.entity";
import { CancelledBooking } from "./entities/cancelled-booking.entity";

@Module({
imports: [
TypeOrmModule.forFeature([
Booking,
CancelledBooking,
FieldSlot,
UserAccount,
MembershipPlan,
Expand Down
68 changes: 64 additions & 4 deletions src/booking/booking.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { GroundOwnerAccount } from "../auth/entities/ground-owner.entity";
import { UserAccount } from "../auth/entities/user.entity";
import { AuthenticatedAccount } from "../auth/types/authenticated-account.type";
import { Booking } from "./entities/booking.entity";
import { CancelledBooking } from "./entities/cancelled-booking.entity";
import { MembershipPlan } from "./entities/membership-plan.entity";
import { Field } from "../fields/entities/field.entity";
import { FieldSlot } from "../fields/entities/field-slot.entity";
Expand All @@ -25,6 +26,8 @@ export class BookingService {
constructor(
@InjectRepository(Booking)
private readonly bookingsRepository: Repository<Booking>,
@InjectRepository(CancelledBooking)
private readonly cancelledBookingsRepository: Repository<CancelledBooking>,
@InjectRepository(FieldSlot)
private readonly fieldSlotsRepository: Repository<FieldSlot>,
@InjectRepository(UserAccount)
Expand Down Expand Up @@ -144,8 +147,25 @@ export class BookingService {
}
// ---------------------------------------------------------------------------

const booking = await manager.getRepository(Booking).save(
manager.getRepository(Booking).create({
const bookingRepo = manager.getRepository(Booking);

// Ensure there's no active booking for this slot (status <> 'cancelled').
const activeBooking = await bookingRepo
.createQueryBuilder("booking")
.where("booking.slot_id = :slotId", { slotId: slot.id })
.andWhere("booking.status <> :cancelled", {
cancelled: "cancelled",
})
.setLock("pessimistic_write")
.getOne();

if (activeBooking) {
throw new ConflictException("Slot already has an active booking");
}

// Create a new booking row (preserve cancelled history rows separately).
const booking = await bookingRepo.save(
bookingRepo.create({
fieldId: slot.fieldId,
slotId: slot.id,
userId: user.id,
Expand Down Expand Up @@ -536,8 +556,27 @@ export class BookingService {
throw new NotFoundException("Slot not found");
}

booking.status = "cancelled";
await bookingRepository.save(booking);
// Archive cancelled booking into `cancelled_bookings` and remove original
const cancelledRepo = manager.getRepository(CancelledBooking);

await cancelledRepo.save(
cancelledRepo.create({
originalBookingId: booking.id,
fieldId: booking.fieldId,
slotId: booking.slotId,
userId: booking.userId,
bookingType: booking.bookingType,
baseAmount: booking.baseAmount,
totalAmount: booking.totalAmount,
discount: booking.discount,
extraAmount: booking.extraAmount,
discountAmount: booking.discountAmount,
createdAt: booking.createdAt,
cancelledBy: account.id,
}),
);

await bookingRepository.delete({ id: booking.id });

slot.status = "available";
slot.slotType = "normal";
Expand Down Expand Up @@ -867,6 +906,27 @@ export class BookingService {
bookingType = "membership";
}

// Ensure there's no active booking for this slot (status <> 'cancelled').
const activeBooking = await bookingRepository
.createQueryBuilder("booking")
.where("booking.slot_id = :slotId", { slotId: lockedSlot.id })
.andWhere("booking.status <> :cancelled", {
cancelled: "cancelled",
})
.setLock("pessimistic_write")
.getOne();

if (activeBooking) {
failedBookings.push({
slotDate: slot.slotDate,
startTime: slot.startTime,
endTime: slot.endTime,
error: "Slot already booked",
});
continue;
}

// Create a new booking row (preserve cancelled history rows separately).
const booking = await bookingRepository.save(
bookingRepository.create({
fieldId: slot.fieldId,
Expand Down
92 changes: 92 additions & 0 deletions src/booking/entities/cancelled-booking.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { UserAccount } from "../../auth/entities/user.entity";
import { Field } from "../../fields/entities/field.entity";
import { FieldSlot } from "../../fields/entities/field-slot.entity";

@Entity({ name: "cancelled_bookings" })
export class CancelledBooking {
@PrimaryGeneratedColumn("uuid")
id!: string;

@Column({ name: "original_booking_id", type: "uuid", nullable: true })
originalBookingId?: string;

@Column({ name: "field_id", type: "uuid" })
fieldId!: string;

@ManyToOne(() => Field, { onDelete: "CASCADE" })
@JoinColumn({ name: "field_id" })
field!: Field;

@Column({ name: "slot_id", type: "uuid" })
slotId!: string;

@ManyToOne(() => FieldSlot, { onDelete: "CASCADE" })
@JoinColumn({ name: "slot_id" })
slot!: FieldSlot;

@Column({ name: "user_id", type: "uuid" })
userId!: string;

@ManyToOne(() => UserAccount, { onDelete: "CASCADE" })
@JoinColumn({ name: "user_id" })
user!: UserAccount;
Comment thread
AyushAdh1 marked this conversation as resolved.
Outdated

@Column({ name: "booking_type", type: "varchar", nullable: true })
bookingType?: string;

@Column({
name: "base_amount",
type: "numeric",
precision: 12,
scale: 2,
default: 0,
})
baseAmount!: string;

@Column({
name: "total_amount",
type: "numeric",
precision: 12,
scale: 2,
default: 0,
})
totalAmount!: string;

@Column({ name: "discount", type: "boolean", default: false })
discount!: boolean;

@Column({
name: "extra_amount",
type: "numeric",
precision: 12,
scale: 2,
default: 0,
})
extraAmount!: string;

@Column({
name: "discount_amount",
type: "numeric",
precision: 12,
scale: 2,
default: 0,
})
discountAmount!: string;

@CreateDateColumn({ name: "created_at" })
createdAt!: Date;
Comment thread
AyushAdh1 marked this conversation as resolved.
Outdated

@Column({ name: "cancelled_at", type: "timestamptz", default: () => "now()" })
cancelledAt!: Date;

@Column({ name: "cancelled_by", type: "uuid", nullable: true })
cancelledBy?: string;
}
46 changes: 42 additions & 4 deletions src/booking/membership-plan.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { Field } from "../fields/entities/field.entity";
import { FieldSlot } from "../fields/entities/field-slot.entity";
import { FieldsService } from "../fields/fields.service";
import { Booking } from "./entities/booking.entity";
import { CancelledBooking } from "./entities/cancelled-booking.entity";
import { CurrentAccount } from "../auth/decorators/current-account.decorator";
import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard";
import { AuthenticatedAccount } from "../auth/types/authenticated-account.type";
Expand All @@ -52,6 +53,8 @@ export class MembershipPlanController {
private readonly fieldSlotRepo: Repository<FieldSlot>,
@InjectRepository(Booking)
private readonly bookingRepo: Repository<Booking>,
@InjectRepository(CancelledBooking)
private readonly cancelledBookingRepo: Repository<CancelledBooking>,
@InjectRepository(MembershipPricingHistory)
private readonly pricingHistoryRepo: Repository<MembershipPricingHistory>,
private readonly fieldsService: FieldsService,
Expand Down Expand Up @@ -533,8 +536,27 @@ export class MembershipPlanController {
.getMany();

for (const booking of membershipBookings) {
booking.status = "cancelled";
await bookingRepo.save(booking);
// move booking to cancelled_bookings and delete original
const cancelledRepo = manager.getRepository(CancelledBooking);

await cancelledRepo.save(
cancelledRepo.create({
originalBookingId: booking.id,
fieldId: booking.fieldId,
slotId: booking.slotId,
userId: booking.userId,
bookingType: booking.bookingType,
baseAmount: booking.baseAmount,
totalAmount: booking.totalAmount,
discount: booking.discount,
extraAmount: booking.extraAmount,
discountAmount: booking.discountAmount,
createdAt: booking.createdAt,
cancelledBy: currentUser.id,
}),
);

await bookingRepo.delete({ id: booking.id });

booking.slot.status = "available";
booking.slot.slotType = "normal";
Expand Down Expand Up @@ -805,8 +827,24 @@ export class MembershipPlanController {

for (const booking of bookingsToRelease) {
if (booking.status === "booked") {
booking.status = "cancelled";
await bookingRepo.save(booking);
await this.cancelledBookingRepo.save(
this.cancelledBookingRepo.create({
originalBookingId: booking.id,
fieldId: booking.fieldId,
slotId: booking.slotId,
userId: booking.userId,
bookingType: booking.bookingType,
baseAmount: booking.baseAmount,
totalAmount: booking.totalAmount,
discount: booking.discount,
extraAmount: booking.extraAmount,
discountAmount: booking.discountAmount,
createdAt: booking.createdAt,
cancelledBy: currentUser.id,
}),
);

await bookingRepo.delete({ id: booking.id });
cancelledCount++;
}

Expand Down
2 changes: 2 additions & 0 deletions src/data-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { FieldScheduleSettings } from "./fields/entities/field-schedule-settings
import { FieldSlot } from "./fields/entities/field-slot.entity";
import { MembershipPlan } from "./booking/entities/membership-plan.entity";
import { Booking } from "./booking/entities/booking.entity";
import { CancelledBooking } from "./booking/entities/cancelled-booking.entity";
import { join } from "path";

function resolveSslConfig(sslMode: string) {
Expand Down Expand Up @@ -79,6 +80,7 @@ const AppDataSource = new DataSource({
FieldSlot,
MembershipPlan,
Booking,
CancelledBooking,
],
migrations: [join(__dirname, "migrations", "*{.ts,.js}")],
});
Expand Down
32 changes: 32 additions & 0 deletions src/migrations/1780001000000-MakeBookingsSlotIdPartialUnique.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class MakeBookingsSlotIdPartialUnique1780001000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Remove any existing unique constraint/index on slot_id so we can create
// a partial unique index that ignores cancelled bookings.
await queryRunner.query(
`ALTER TABLE "bookings" DROP CONSTRAINT IF EXISTS "UQ_bookings_slot_id"`,
);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_bookings_slot_id"`);
// Some older deployments may have generated a hashed index name; attempt to drop common variants.
await queryRunner.query(
`DROP INDEX IF EXISTS "IDX_409d5b76fb2b0501a8c72dd4ee"`,
);

// Create a unique index for slot_id only when booking status is not 'cancelled'.
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_bookings_slot_id_active_unique" ON "bookings" ("slot_id") WHERE status <> 'cancelled'`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
// Revert: drop the partial unique index and restore the original unique constraint
await queryRunner.query(
`DROP INDEX IF EXISTS "IDX_bookings_slot_id_active_unique"`,
);
// Restore the original unique constraint on slot_id
await queryRunner.query(
`ALTER TABLE "bookings" ADD CONSTRAINT IF NOT EXISTS "UQ_bookings_slot_id" UNIQUE ("slot_id")`,
);
}
}
48 changes: 48 additions & 0 deletions src/migrations/1780002000000-CreateCancelledBookingsTable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class CreateCancelledBookingsTable1780002000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "cancelled_bookings" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"original_booking_id" uuid,
"field_id" uuid NOT NULL,
"slot_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"booking_type" varchar,
"base_amount" numeric(12,2) DEFAULT 0,
"total_amount" numeric(12,2) DEFAULT 0,
"discount" boolean DEFAULT false,
"extra_amount" numeric(12,2) DEFAULT 0,
"discount_amount" numeric(12,2) DEFAULT 0,
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
"cancelled_at" timestamptz NOT NULL DEFAULT now(),
"cancelled_by" uuid,
CONSTRAINT "PK_cancelled_bookings_id" PRIMARY KEY ("id")
)
`);
Comment thread
AyushAdh1 marked this conversation as resolved.

await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_cancelled_bookings_field_id" ON "cancelled_bookings" ("field_id")`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_cancelled_bookings_user_id" ON "cancelled_bookings" ("user_id")`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_cancelled_bookings_slot_id" ON "cancelled_bookings" ("slot_id")`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS "IDX_cancelled_bookings_slot_id"`,
);
await queryRunner.query(
`DROP INDEX IF EXISTS "IDX_cancelled_bookings_user_id"`,
);
await queryRunner.query(
`DROP INDEX IF EXISTS "IDX_cancelled_bookings_field_id"`,
);
await queryRunner.query(`DROP TABLE IF EXISTS "cancelled_bookings"`);
}
}
Loading