Merge pull request 'improve-booking-logic' (#11) from improve-booking-logic into development

Reviewed-on: https://gitea.2pidev.com/jselesan/playzer/pulls/11
This commit is contained in:
2026-05-06 20:12:39 +00:00
34 changed files with 653 additions and 462 deletions

View File

@@ -12,6 +12,7 @@ enum CourtBookingStatus {
CONFIRMED
CANCELLED
COMPLETED
NOSHOW
}
model Sport {
@@ -94,3 +95,20 @@ model CourtBooking {
@@index([bookingDate])
@@map("court_bookings")
}
model CourtBookingLog {
id String @id @db.Uuid
bookingCode String @map("booking_code") @db.VarChar(8)
courtId String @map("court_id") @db.Uuid
bookingDate DateTime @map("booking_date") @db.Date
startTime String @map("start_time") @db.VarChar(5)
endTime String @map("end_time") @db.VarChar(5)
previousStatus CourtBookingStatus @map("previous_status")
newStatus CourtBookingStatus @map("new_status")
customerName String @map("customer_name") @db.VarChar(120)
customerPhone String @map("customer_phone") @db.VarChar(30)
changedAt DateTime @default(now()) @map("changed_at")
@@map("court_booking_logs")
@@index([courtId, newStatus])
}

View File

@@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "court_booking_logs" (
"id" UUID NOT NULL,
"booking_code" UUID NOT NULL,
"court_id" UUID NOT NULL,
"booking_date" DATE NOT NULL,
"start_time" VARCHAR(5) NOT NULL,
"end_time" VARCHAR(5) NOT NULL,
"previous_status" "CourtBookingStatus" NOT NULL,
"new_status" "CourtBookingStatus" NOT NULL,
"changed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "court_booking_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "court_booking_logs_court_id_new_status_idx" ON "court_booking_logs"("court_id", "new_status");

View File

@@ -0,0 +1,13 @@
/*
Warnings:
- Added the required column `customer_name` to the `court_booking_logs` table without a default value. This is not possible if the table is not empty.
- Added the required column `customer_phone` to the `court_booking_logs` table without a default value. This is not possible if the table is not empty.
*/
-- AlterEnum
ALTER TYPE "CourtBookingStatus" ADD VALUE 'NOSHOW';
-- AlterTable
ALTER TABLE "court_booking_logs" ADD COLUMN "customer_name" VARCHAR(120) NOT NULL,
ADD COLUMN "customer_phone" VARCHAR(30) NOT NULL;

View File

@@ -0,0 +1,9 @@
/*
Warnings:
- Changed the type of `booking_code` on the `court_booking_logs` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
*/
-- AlterTable
ALTER TABLE "court_booking_logs" DROP COLUMN "booking_code",
ADD COLUMN "booking_code" VARCHAR(8) NOT NULL;

View File

@@ -9,8 +9,8 @@ import { requestIdMiddleware } from './lib/http/request-id';
export function createApp() {
const app = new Hono<AppEnv>();
app.use('*', requestIdMiddleware)
app.use('*', errorHandler)
app.use('*', requestIdMiddleware);
app.use('*', errorHandler);
const allowedOrigins = (Bun.env.CORS_ORIGIN ?? 'http://localhost:5173,http://127.0.0.1:5173')
.split(',')

View File

@@ -77,6 +77,11 @@ export type CourtPriceRule = Prisma.CourtPriceRuleModel
*
*/
export type CourtBooking = Prisma.CourtBookingModel
/**
* Model CourtBookingLog
*
*/
export type CourtBookingLog = Prisma.CourtBookingLogModel
/**
* Model OnboardingRequest
*

View File

@@ -101,6 +101,11 @@ export type CourtPriceRule = Prisma.CourtPriceRuleModel
*
*/
export type CourtBooking = Prisma.CourtBookingModel
/**
* Model CourtBookingLog
*
*/
export type CourtBookingLog = Prisma.CourtBookingLogModel
/**
* Model OnboardingRequest
*

View File

@@ -33,7 +33,8 @@ export type DayOfWeek = (typeof DayOfWeek)[keyof typeof DayOfWeek]
export const CourtBookingStatus = {
CONFIRMED: 'CONFIRMED',
CANCELLED: 'CANCELLED',
COMPLETED: 'COMPLETED'
COMPLETED: 'COMPLETED',
NOSHOW: 'NOSHOW'
} as const
export type CourtBookingStatus = (typeof CourtBookingStatus)[keyof typeof CourtBookingStatus]

File diff suppressed because one or more lines are too long

View File

@@ -396,6 +396,7 @@ export const ModelName = {
CourtAvailability: 'CourtAvailability',
CourtPriceRule: 'CourtPriceRule',
CourtBooking: 'CourtBooking',
CourtBookingLog: 'CourtBookingLog',
OnboardingRequest: 'OnboardingRequest',
PasswordResetRequest: 'PasswordResetRequest',
Plan: 'Plan'
@@ -414,7 +415,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions
}
meta: {
modelProps: "user" | "session" | "account" | "verification" | "complex" | "complexUser" | "complexInvitation" | "sport" | "court" | "courtAvailability" | "courtPriceRule" | "courtBooking" | "onboardingRequest" | "passwordResetRequest" | "plan"
modelProps: "user" | "session" | "account" | "verification" | "complex" | "complexUser" | "complexInvitation" | "sport" | "court" | "courtAvailability" | "courtPriceRule" | "courtBooking" | "courtBookingLog" | "onboardingRequest" | "passwordResetRequest" | "plan"
txIsolationLevel: TransactionIsolationLevel
}
model: {
@@ -1306,6 +1307,80 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
}
}
}
CourtBookingLog: {
payload: Prisma.$CourtBookingLogPayload<ExtArgs>
fields: Prisma.CourtBookingLogFieldRefs
operations: {
findUnique: {
args: Prisma.CourtBookingLogFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload> | null
}
findUniqueOrThrow: {
args: Prisma.CourtBookingLogFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>
}
findFirst: {
args: Prisma.CourtBookingLogFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload> | null
}
findFirstOrThrow: {
args: Prisma.CourtBookingLogFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>
}
findMany: {
args: Prisma.CourtBookingLogFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>[]
}
create: {
args: Prisma.CourtBookingLogCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>
}
createMany: {
args: Prisma.CourtBookingLogCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.CourtBookingLogCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>[]
}
delete: {
args: Prisma.CourtBookingLogDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>
}
update: {
args: Prisma.CourtBookingLogUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>
}
deleteMany: {
args: Prisma.CourtBookingLogDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.CourtBookingLogUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.CourtBookingLogUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>[]
}
upsert: {
args: Prisma.CourtBookingLogUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>
}
aggregate: {
args: Prisma.CourtBookingLogAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateCourtBookingLog>
}
groupBy: {
args: Prisma.CourtBookingLogGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CourtBookingLogGroupByOutputType>[]
}
count: {
args: Prisma.CourtBookingLogCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CourtBookingLogCountAggregateOutputType> | number
}
}
}
OnboardingRequest: {
payload: Prisma.$OnboardingRequestPayload<ExtArgs>
fields: Prisma.OnboardingRequestFieldRefs
@@ -1738,6 +1813,23 @@ export const CourtBookingScalarFieldEnum = {
export type CourtBookingScalarFieldEnum = (typeof CourtBookingScalarFieldEnum)[keyof typeof CourtBookingScalarFieldEnum]
export const CourtBookingLogScalarFieldEnum = {
id: 'id',
bookingCode: 'bookingCode',
courtId: 'courtId',
bookingDate: 'bookingDate',
startTime: 'startTime',
endTime: 'endTime',
previousStatus: 'previousStatus',
newStatus: 'newStatus',
customerName: 'customerName',
customerPhone: 'customerPhone',
changedAt: 'changedAt'
} as const
export type CourtBookingLogScalarFieldEnum = (typeof CourtBookingLogScalarFieldEnum)[keyof typeof CourtBookingLogScalarFieldEnum]
export const OnboardingRequestScalarFieldEnum = {
id: 'id',
fullName: 'fullName',
@@ -2068,6 +2160,7 @@ export type GlobalOmitConfig = {
courtAvailability?: Prisma.CourtAvailabilityOmit
courtPriceRule?: Prisma.CourtPriceRuleOmit
courtBooking?: Prisma.CourtBookingOmit
courtBookingLog?: Prisma.CourtBookingLogOmit
onboardingRequest?: Prisma.OnboardingRequestOmit
passwordResetRequest?: Prisma.PasswordResetRequestOmit
plan?: Prisma.PlanOmit

View File

@@ -63,6 +63,7 @@ export const ModelName = {
CourtAvailability: 'CourtAvailability',
CourtPriceRule: 'CourtPriceRule',
CourtBooking: 'CourtBooking',
CourtBookingLog: 'CourtBookingLog',
OnboardingRequest: 'OnboardingRequest',
PasswordResetRequest: 'PasswordResetRequest',
Plan: 'Plan'
@@ -255,6 +256,23 @@ export const CourtBookingScalarFieldEnum = {
export type CourtBookingScalarFieldEnum = (typeof CourtBookingScalarFieldEnum)[keyof typeof CourtBookingScalarFieldEnum]
export const CourtBookingLogScalarFieldEnum = {
id: 'id',
bookingCode: 'bookingCode',
courtId: 'courtId',
bookingDate: 'bookingDate',
startTime: 'startTime',
endTime: 'endTime',
previousStatus: 'previousStatus',
newStatus: 'newStatus',
customerName: 'customerName',
customerPhone: 'customerPhone',
changedAt: 'changedAt'
} as const
export type CourtBookingLogScalarFieldEnum = (typeof CourtBookingLogScalarFieldEnum)[keyof typeof CourtBookingLogScalarFieldEnum]
export const OnboardingRequestScalarFieldEnum = {
id: 'id',
fullName: 'fullName',

View File

@@ -20,6 +20,7 @@ export type * from './models/Court'
export type * from './models/CourtAvailability'
export type * from './models/CourtPriceRule'
export type * from './models/CourtBooking'
export type * from './models/CourtBookingLog'
export type * from './models/OnboardingRequest'
export type * from './models/PasswordResetRequest'
export type * from './models/Plan'

View File

@@ -1,58 +1,58 @@
// lib/errors.ts
export type ValidationIssue = {
path: string
message: string
}
path: string;
message: string;
};
export type AppError =
| {
type: 'validation'
message: string
issues?: ValidationIssue[]
type: 'validation';
message: string;
issues?: ValidationIssue[];
}
| {
type: 'not_found'
message: string
type: 'not_found';
message: string;
}
| {
type: 'conflict'
message: string
type: 'conflict';
message: string;
}
| {
type: 'unauthorized'
message: string
type: 'unauthorized';
message: string;
}
| {
type: 'forbidden'
message: string
type: 'forbidden';
message: string;
}
| {
type: 'unexpected'
message: string
}
type: 'unexpected';
message: string;
};
export const Errors = {
validation(message: string, issues?: ValidationIssue[]): AppError {
return { type: 'validation', message, issues }
return { type: 'validation', message, issues };
},
notFound(message = 'Resource not found'): AppError {
return { type: 'not_found', message }
return { type: 'not_found', message };
},
conflict(message: string): AppError {
return { type: 'conflict', message }
return { type: 'conflict', message };
},
unauthorized(message = 'Unauthorized'): AppError {
return { type: 'unauthorized', message }
return { type: 'unauthorized', message };
},
forbidden(message = 'Forbidden'): AppError {
return { type: 'forbidden', message }
return { type: 'forbidden', message };
},
unexpected(message = 'Unexpected error'): AppError {
return { type: 'unexpected', message }
}
}
return { type: 'unexpected', message };
},
};

View File

@@ -1,20 +1,20 @@
// http/error-handler.ts
import type { MiddlewareHandler } from 'hono'
import { unexpectedProblem } from './problem-builders'
import { logger } from '../logger'
import type { MiddlewareHandler } from 'hono';
import { logger } from '../logger';
import { unexpectedProblem } from './problem-builders';
export const errorHandler: MiddlewareHandler = async (c, next) => {
try {
await next()
await next();
} catch (error) {
const requestId = c.get('requestId') as string | undefined
const instance = requestId ? `/requests/${requestId}` : undefined
const requestId = c.get('requestId') as string | undefined;
const instance = requestId ? `/requests/${requestId}` : undefined;
logger.error({
requestId,
error
})
error,
});
return c.json(unexpectedProblem({ instance }), 500)
}
return c.json(unexpectedProblem({ instance }), 500);
}
};

View File

@@ -1,7 +1,7 @@
import type { Context } from 'hono'
import type { ContentfulStatusCode } from 'hono/utils/http-status'
import type { Result } from '@/lib/result'
import { mapAppErrorToProblem } from './problem-mapper'
import type { Result } from '@/lib/result';
import type { Context } from 'hono';
import type { ContentfulStatusCode } from 'hono/utils/http-status';
import { mapAppErrorToProblem } from './problem-mapper';
export function handleResult<T>(
c: Context,
@@ -9,12 +9,12 @@ export function handleResult<T>(
successStatus: ContentfulStatusCode = 200
) {
if (!result.ok) {
const requestId = c.get('requestId') as string | undefined
const instance = requestId ? `/requests/${requestId}` : undefined
const problem = mapAppErrorToProblem(result.error, instance)
const requestId = c.get('requestId') as string | undefined;
const instance = requestId ? `/requests/${requestId}` : undefined;
const problem = mapAppErrorToProblem(result.error, instance);
return c.json(problem, problem.status)
return c.json(problem, problem.status);
}
return c.json(result.value, successStatus)
return c.json(result.value, successStatus);
}

View File

@@ -1,10 +1,10 @@
// http/problem-builders.ts
import type { ProblemDetails } from './problem-details'
import type { ProblemDetails } from './problem-details';
export function validationProblem(params: {
detail?: string
instance?: string
errors?: Record<string, string[]>
detail?: string;
instance?: string;
errors?: Record<string, string[]>;
}): ProblemDetails {
return {
type: 'https://api.myapp.dev/problems/validation',
@@ -12,18 +12,18 @@ export function validationProblem(params: {
status: 400,
detail: params.detail ?? 'Invalid request data',
instance: params.instance,
errors: params.errors
}
errors: params.errors,
};
}
export function unexpectedProblem(params?: {
instance?: string
instance?: string;
}): ProblemDetails {
return {
type: 'https://api.myapp.dev/problems/unexpected',
title: 'Internal Server Error',
status: 500,
detail: 'Internal server error',
instance: params?.instance
}
instance: params?.instance,
};
}

View File

@@ -1,10 +1,10 @@
import type { ContentfulStatusCode } from 'hono/utils/http-status'
import type { ContentfulStatusCode } from 'hono/utils/http-status';
export type ProblemDetails = {
type: string
title: string
status: ContentfulStatusCode
detail?: string
instance?: string
errors?: Record<string, string[]>
}
type: string;
title: string;
status: ContentfulStatusCode;
detail?: string;
instance?: string;
errors?: Record<string, string[]>;
};

View File

@@ -1,53 +1,50 @@
import type { ContentfulStatusCode } from 'hono/utils/http-status'
import type { AppError } from '@/lib/errors'
import type { ProblemDetails } from './problem-details'
import type { AppError } from '@/lib/errors';
import type { ContentfulStatusCode } from 'hono/utils/http-status';
import type { ProblemDetails } from './problem-details';
function statusFromError(error: AppError): ContentfulStatusCode {
switch (error.type) {
case 'validation':
return 400
return 400;
case 'unauthorized':
return 401
return 401;
case 'forbidden':
return 403
return 403;
case 'not_found':
return 404
return 404;
case 'conflict':
return 409
return 409;
case 'unexpected':
default:
return 500
return 500;
}
}
function titleFromStatus(status: ContentfulStatusCode): string {
switch (status) {
case 400:
return 'Bad Request'
return 'Bad Request';
case 401:
return 'Unauthorized'
return 'Unauthorized';
case 403:
return 'Forbidden'
return 'Forbidden';
case 404:
return 'Not Found'
return 'Not Found';
case 409:
return 'Conflict'
return 'Conflict';
default:
return 'Internal Server Error'
return 'Internal Server Error';
}
}
export function mapAppErrorToProblem(
error: AppError,
instance?: string
): ProblemDetails {
const status = statusFromError(error)
export function mapAppErrorToProblem(error: AppError, instance?: string): ProblemDetails {
const status = statusFromError(error);
return {
type: `https://api.myapp.dev/problems/${error.type}`,
title: titleFromStatus(status),
status,
detail: status === 500 ? 'Internal server error' : error.message,
instance
}
instance,
};
}

View File

@@ -1,16 +1,16 @@
import type { MiddlewareHandler } from 'hono'
import type { MiddlewareHandler } from 'hono';
type Env = {
Variables: {
requestId: string
}
}
requestId: string;
};
};
export const requestIdMiddleware: MiddlewareHandler<Env> = async (c, next) => {
const requestId = crypto.randomUUID()
const requestId = crypto.randomUUID();
c.set('requestId', requestId)
c.header('X-Request-Id', requestId)
c.set('requestId', requestId);
c.header('X-Request-Id', requestId);
await next()
}
await next();
};

View File

@@ -1,52 +1,48 @@
import { zValidator } from '@hono/zod-validator';
import type { ZodSchema } from 'zod';
import { validationProblem } from './problem-builders';
import { zodIssuesToRecord } from './zod-issues';
import { zValidator } from '@hono/zod-validator'
import type { ZodSchema } from 'zod'
import { validationProblem } from './problem-builders'
import { zodIssuesToRecord } from './zod-issues'
type ValidationTarget = 'json' | 'query' | 'param' | 'header' | 'form';
type ValidationTarget = 'json' | 'query' | 'param' | 'header' | 'form'
function makeValidator<TSchema extends ZodSchema>(
target: ValidationTarget,
schema: TSchema
) {
function makeValidator<TSchema extends ZodSchema>(target: ValidationTarget, schema: TSchema) {
return zValidator(target, schema, (result, c) => {
if (result.success) {
return
return;
}
const requestId = c.get('requestId') as string | undefined
const instance = requestId ? `/requests/${requestId}` : undefined
const requestId = c.get('requestId') as string | undefined;
const instance = requestId ? `/requests/${requestId}` : undefined;
return c.json(
validationProblem({
detail: `Invalid ${target} data`,
instance,
errors: zodIssuesToRecord(result.error.issues)
errors: zodIssuesToRecord(result.error.issues),
}),
400
)
})
);
});
}
export const validate = {
json<TSchema extends ZodSchema>(schema: TSchema) {
return makeValidator('json', schema)
return makeValidator('json', schema);
},
query<TSchema extends ZodSchema>(schema: TSchema) {
return makeValidator('query', schema)
return makeValidator('query', schema);
},
param<TSchema extends ZodSchema>(schema: TSchema) {
return makeValidator('param', schema)
return makeValidator('param', schema);
},
header<TSchema extends ZodSchema>(schema: TSchema) {
return makeValidator('header', schema)
return makeValidator('header', schema);
},
form<TSchema extends ZodSchema>(schema: TSchema) {
return makeValidator('form', schema)
}
}
return makeValidator('form', schema);
},
};

View File

@@ -1,18 +1,16 @@
type IssueLike = {
path: PropertyKey[]
message: string
}
path: PropertyKey[];
message: string;
};
export function zodIssuesToRecord(
issues: IssueLike[]
): Record<string, string[]> {
const out: Record<string, string[]> = {}
export function zodIssuesToRecord(issues: IssueLike[]): Record<string, string[]> {
const out: Record<string, string[]> = {};
for (const issue of issues) {
const key = issue.path.length ? issue.path.join('.') : 'root'
out[key] ??= []
out[key].push(issue.message)
const key = issue.path.length ? issue.path.join('.') : 'root';
out[key] ??= [];
out[key].push(issue.message);
}
return out
return out;
}

View File

@@ -1,14 +1,12 @@
// lib/result.ts
import type { AppError } from '@/lib/errors';
export type Result<T, E = AppError> =
| { ok: true; value: T }
| { ok: false; error: E }
export type Result<T, E = AppError> = { ok: true; value: T } | { ok: false; error: E };
export function ok<T>(value: T): Result<T> {
return { ok: true, value }
return { ok: true, value };
}
export function err<E>(error: E): Result<never, E> {
return { ok: false, error }
return { ok: false, error };
}

View File

@@ -164,7 +164,7 @@ function mapBookingResponse(booking: {
endTime: string;
customerName: string;
customerPhone: string;
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED';
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW';
createdAt: Date;
updatedAt: Date;
court: {
@@ -494,34 +494,42 @@ export async function updateAdminBookingStatus(
);
}
const updated = await db.courtBooking.update({
where: { id: booking.id },
if (input.status === 'NOSHOW' && booking.status !== 'CONFIRMED') {
throw new AdminBookingServiceError(
'Solo se pueden marcar como no show las reservas confirmadas.',
409
);
}
await db.courtBookingLog.create({
data: {
status:
input.status === 'COMPLETED' ? CourtBookingStatus.COMPLETED : CourtBookingStatus.CANCELLED,
},
include: {
court: {
select: {
id: true,
name: true,
sport: {
select: {
id: true,
name: true,
slug: true,
},
},
complex: {
select: {
id: true,
complexName: true,
},
},
},
},
id: uuidv7(),
bookingCode: booking.bookingCode,
courtId: booking.court.id,
bookingDate: booking.bookingDate,
startTime: booking.startTime,
endTime: booking.endTime,
customerName: booking.customerName,
customerPhone: booking.customerPhone,
previousStatus: booking.status,
newStatus: input.status,
changedAt: new Date(),
},
});
return mapBookingResponse(updated);
if (input.status === 'COMPLETED' || input.status === 'NOSHOW') {
await db.courtBooking.update({
where: { id: booking.id },
data: {
status: input.status,
},
});
} else {
// if the booking is cancelled we delete it to free up the slot, but we keep a log of it with the cancelled status
await db.courtBooking.delete({
where: { id: booking.id },
});
}
return mapBookingResponse({ ...booking, status: input.status });
}

View File

@@ -1,7 +1,7 @@
import { Prisma, Sport } from '@/generated/prisma/client';
import { Errors } from '@/lib/errors';
import { db } from '@/lib/prisma';
import { err, ok, Result } from '@/lib/result';
import { Result, err, ok } from '@/lib/result';
import { v7 as uuidv7 } from 'uuid';
export type CreateSportInput = {

View File

@@ -1,3 +1,4 @@
import { validate } from '@/lib/http/validate';
import { requireAuth } from '@/middlewares/require-auth.middleware';
import { requireSuperAdmin } from '@/middlewares/require-super-admin.middleware';
import { createSportHandler } from '@/modules/sport/handlers/create-sport.handler';
@@ -8,7 +9,6 @@ import { zValidator } from '@hono/zod-validator';
import { createSportSchema, updateSportSchema } from '@repo/api-contract';
import { Hono } from 'hono';
import { z } from 'zod';
import { validate } from '@/lib/http/validate'
export const sportRoutes = new Hono<AppEnv>();
const sportIdParamsSchema = z.object({ id: z.uuid() });

View File

@@ -1,11 +1,9 @@
import { beforeEach, expect, mock, test } from 'bun:test';
import type { InviteComplexUserResponse } from '@repo/api-contract';
import { createInviteComplexUserHandler } from '@/modules/complex/handlers/invite-complex-user.handler';
import type { InviteComplexUserResponse } from '@repo/api-contract';
const inviteComplexUserMock = mock(
async () => undefined as unknown as InviteComplexUserResponse
);
const inviteComplexUserMock = mock(async () => undefined as unknown as InviteComplexUserResponse);
class MockComplexMembersError extends Error {
status: 400 | 403 | 404 | 409;

View File

@@ -1,10 +1,10 @@
import { beforeEach, expect, mock, test } from 'bun:test';
import { prismaMock, sendMailMock, transactionMock } from '../support/prisma.mock';
import {
ComplexMembersError,
inviteComplexUser,
} from '@/modules/complex/services/complex-members.service';
import { prismaMock, sendMailMock, transactionMock } from '../support/prisma.mock';
beforeEach(() => {
prismaMock._reset();

View File

@@ -20,9 +20,7 @@ mock.module('@/modules/sport/services/sport.service', () => ({
createSport: createSportMock,
}));
const { createSportHandler } = await import(
'@/modules/sport/handlers/create-sport.handler'
);
const { createSportHandler } = await import('@/modules/sport/handlers/create-sport.handler');
type HandlerContext = {
get: (key: 'requestId') => string | undefined;

View File

@@ -5,14 +5,16 @@ import type { PrismaClient } from '@/generated/prisma/client';
import type { PrismaClientMock } from 'bun-mock-prisma';
export const prismaMock = createPrismaMock<PrismaClient>() as PrismaClientMock<PrismaClient>;
export const sendMailMock = mock(async (_input: {
export const sendMailMock = mock(
async (_input: {
to: string;
subject: string;
html: string;
text: string;
}) => undefined);
export const transactionMock = mock(async <T>(fn: (tx: PrismaClientMock<PrismaClient>) => Promise<T>) =>
fn(prismaMock)
}) => undefined
);
export const transactionMock = mock(
async <T>(fn: (tx: PrismaClientMock<PrismaClient>) => Promise<T>) => fn(prismaMock)
);
export const dbMock = {
complexUser: prismaMock.complexUser,

View File

@@ -112,7 +112,9 @@ function makeTimelineHours(range: BookingTimeRange) {
}
function isActiveBooking(booking: AdminBooking) {
return booking.status === 'CONFIRMED' || booking.status === 'COMPLETED';
return (
booking.status === 'CONFIRMED' || booking.status === 'COMPLETED' || booking.status === 'NOSHOW'
);
}
function getCourtBookings(court: Court, bookings: AdminBooking[], selectedDate: string) {
@@ -255,7 +257,10 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
[bookingsQuery.data?.bookings, selectedDate]
);
const courts = useMemo(() => courtsQuery.data?.sort((a, b) => a.name.localeCompare(b.name)) ?? [], [courtsQuery.data]);
const courts = useMemo(
() => courtsQuery.data?.sort((a, b) => a.name.localeCompare(b.name)) ?? [],
[courtsQuery.data]
);
const filteredCourts = useMemo(() => {
if (selectedSportId === 'all') return courts;
@@ -293,7 +298,6 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
}, []);
const currentTime = useMemo(() => {
if (!isTodayIso(selectedDate)) return null;
const nowMinutes = timeToMinutes(now);
const start = timeToMinutes(visibleTimeRange.start);
@@ -345,10 +349,12 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
(segment?: BookingTimelineSegment) => {
setSelectedSegment(segment ?? null);
setBookingToolsOpen(true);
console.log(JSON.stringify(segment, null, 2))
}, [selectedDate]);
console.log(JSON.stringify(segment, null, 2));
},
[selectedDate]
);
const closeBookingTools = () => setBookingToolsOpen(false)
const closeBookingTools = () => setBookingToolsOpen(false);
useEffect(() => {
const handleOpenCreateBooking = () => {
@@ -449,7 +455,7 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
bookingToolsOpen,
openBookingTools,
closeBookingTools,
selectedSegment
selectedSegment,
}),
[
bookings,
@@ -480,7 +486,7 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
updateBookingStatus,
viewMode,
visibleTimeRange,
bookingToolsOpen
bookingToolsOpen,
]
);

View File

@@ -1,6 +1,6 @@
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { ChevronLeft, ChevronRight, Dumbbell, Plus, Users } from 'lucide-react';
import { ChevronLeft, ChevronRight, Dumbbell, Plus, UserX, Users } from 'lucide-react';
import { useBooking } from '../booking-provider';
import type { BookingCourtSchedule, BookingTimelineSegment } from '../booking.types';
import { timeToMinutes } from '../lib/booking-time';
@@ -228,7 +228,8 @@ function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: Booki
'border-maintenance/80 bg-maintenance/75 text-maintenance-foreground',
segment.booking?.status === 'COMPLETED' &&
'border-reserved/70 bg-reserved/25 dark:text-reserved-foreground text-gray-600 line-through',
segment.booking?.status === 'NOSHOW' &&
'border-amber-500/60 bg-amber-500/50 text-amber-950 dark:text-amber-200'
)}
style={{
left: `calc(${left}% + 4px)`,
@@ -241,6 +242,8 @@ function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: Booki
startTime: segment.startTime,
sportId: schedule.court.sportId,
});
} else if (segment.booking?.status === 'NOSHOW') {
return;
} else {
if (event.shiftKey && segment.booking?.status === 'CONFIRMED') {
updateBookingStatus(segment.booking.id, 'COMPLETED');
@@ -262,14 +265,20 @@ function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: Booki
</span>
) : (
<>
<div className="flex items-center justify-between">
<span className="truncate text-sm font-medium">
{segment.startTime} - {segment.endTime}
</span>
{segment.booking?.status === 'NOSHOW' && (
<span title="No Show - El cliente no se presentó">
<UserX className="size-3 shrink-0" />
</span>
)}
</div>
<span className="mt-1 flex items-center gap-1 truncate opacity-90">
<Users className="size-3" />
{segment.booking?.customerName ?? 'Mantenimiento'}
</span>
</>
)}
</button>

View File

@@ -1,7 +1,18 @@
import { ResponsiveDialog, ResponsiveDialogClose, ResponsiveDialogContent, ResponsiveDialogDescription, ResponsiveDialogFooter, ResponsiveDialogHeader, ResponsiveDialogTitle } from "@/components/ui/responsive-dialog";
import { useBooking } from "../booking-provider";
import { Button } from "@/components/ui/button";
import { Button } from '@/components/ui/button';
import {
ResponsiveDialog,
ResponsiveDialogClose,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from '@/components/ui/responsive-dialog';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
import {
AlertTriangle,
BadgeCheck,
CheckCircle2,
Clock,
MapPin,
@@ -9,65 +20,55 @@ import {
Phone,
User,
XCircle,
AlertTriangle,
BadgeCheck,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
} from 'lucide-react';
import { useBooking } from '../booking-provider';
function formatDate(date: string | undefined) {
if (!date) return "";
if (!date) return '';
const formatted = new Intl.DateTimeFormat("es-AR", {
weekday: "long",
day: "numeric",
month: "long",
const formatted = new Intl.DateTimeFormat('es-AR', {
weekday: 'long',
day: 'numeric',
month: 'long',
}).format(new Date(`${date}T00:00:00`));
return formatted
.split(" ")
.split(' ')
.map((word, index) => {
if (index === 0 || index === 3) {
return word.charAt(0).toUpperCase() + word.slice(1);
}
return word;
})
.join(" ");
.join(' ');
}
const statusConfig: Record<string, { label: string; className: string }> = {
CONFIRMED: {
label: "Reservada",
className: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
label: 'Reservada',
className: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30',
},
COMPLETED: {
label: "Completada",
className: "bg-sky-500/15 text-sky-400 border-sky-500/30",
label: 'Completada',
className: 'bg-sky-500/15 text-sky-400 border-sky-500/30',
},
CANCELLED: {
label: "Cancelada",
className: "bg-red-500/15 text-red-400 border-red-500/30",
label: 'Cancelada',
className: 'bg-red-500/15 text-red-400 border-red-500/30',
},
NOSHOW: {
label: "No show",
className: "bg-amber-500/15 text-amber-400 border-amber-500/30",
label: 'No show',
className: 'bg-amber-500/15 text-amber-400 border-amber-500/30',
},
};
export function BookingToolsDialog() {
const {
selectedSegment,
bookingToolsOpen,
closeBookingTools,
updateBookingStatus,
} = useBooking();
const { selectedSegment, bookingToolsOpen, closeBookingTools, updateBookingStatus } =
useBooking();
const status = statusConfig[selectedSegment?.booking?.status ?? "CONFIRMED"] ?? {
const status = statusConfig[selectedSegment?.booking?.status ?? 'CONFIRMED'] ?? {
label: selectedSegment?.booking?.status,
className: "bg-slate-500/15 text-slate-300 border-slate-500/30",
className: 'bg-slate-500/15 text-slate-300 border-slate-500/30',
};
const sendWhatsappReminder = () => {
@@ -77,13 +78,10 @@ export function BookingToolsDialog() {
const url = `https://wa.me/${phone}?text=${encodeURIComponent(message)}`;
window.open(url, '_blank');
}
};
return (
<ResponsiveDialog
open={bookingToolsOpen}
onOpenChange={(open) => !open && closeBookingTools()}
>
<ResponsiveDialog open={bookingToolsOpen} onOpenChange={(open) => !open && closeBookingTools()}>
<ResponsiveDialogContent
className="data-[variant=dialog]:max-w-5xl"
onOpenAutoFocus={(event) => event.preventDefault()}
@@ -100,7 +98,7 @@ export function BookingToolsDialog() {
<DetailItem
icon={<MapPin className="h-5 w-5" />}
label="Cancha"
value={selectedSegment?.booking?.courtName ?? "-"}
value={selectedSegment?.booking?.courtName ?? '-'}
helper={selectedSegment?.booking?.sport?.name}
withDivider
/>
@@ -116,7 +114,7 @@ export function BookingToolsDialog() {
<DetailItem
icon={<User className="h-5 w-5" />}
label="Cliente"
value={selectedSegment?.booking?.customerName ?? "-"}
value={selectedSegment?.booking?.customerName ?? '-'}
/>
<DetailItem
@@ -138,12 +136,10 @@ export function BookingToolsDialog() {
<DetailItem
icon={<Phone className="h-5 w-5" />}
label="Teléfono"
value={selectedSegment?.booking?.customerPhone ?? "-"}
value={selectedSegment?.booking?.customerPhone ?? '-'}
className="pt-10"
/>
</div>
</section>
<Separator className="dark:bg-slate-800 bg-slate-300" />
@@ -161,7 +157,10 @@ export function BookingToolsDialog() {
title="Completar reserva"
description="El cliente se presentó para usar la cancha."
className="border-emerald-500/40 bg-emerald-500/10 text-emerald-600 text-lg hover:bg-emerald-500/15 hover:text-esmerald-800"
onClick={() => updateBookingStatus(selectedSegment?.booking?.id!, 'COMPLETED')}
onClick={() => {
updateBookingStatus(selectedSegment?.booking?.id!, 'COMPLETED');
closeBookingTools();
}}
/>
<ActionButton
@@ -169,7 +168,10 @@ export function BookingToolsDialog() {
title="Cancelar reserva"
description="El cliente avisó que no va a venir. La cancha vuelve a estar disponible."
className="border-red-500/40 bg-red-500/10 text-red-600 text-lg hover:bg-red-500/15 hover:text-red-800"
onClick={() => updateBookingStatus(selectedSegment?.booking?.id!, 'CANCELLED')}
onClick={() => {
updateBookingStatus(selectedSegment?.booking?.id!, 'CANCELLED');
closeBookingTools();
}}
/>
<ActionButton
@@ -185,7 +187,10 @@ export function BookingToolsDialog() {
title="Marcar como No Show"
description="El cliente no vino y no canceló."
className="border-amber-500/40 bg-amber-500/10 text-amber-600 text-lg hover:bg-amber-500/15 hover:text-amber-800"
onClick={() => updateBookingStatus(selectedSegment?.booking?.id!, 'NOSHOW')}
onClick={() => {
updateBookingStatus(selectedSegment?.booking?.id!, 'NOSHOW');
closeBookingTools();
}}
/>
</div>
</section>
@@ -217,14 +222,8 @@ function DetailItem({
withDivider?: boolean;
className?: string;
}) {
return (
<div
className={cn(
"relative flex items-start gap-4 px-5 py-1",
className
)}
>
<div className={cn('relative flex items-start gap-4 px-5 py-1', className)}>
{withDivider && (
<div
className="
@@ -246,25 +245,18 @@ function DetailItem({
{icon}
</div>
<div className="min-w-0">
<p className="text-sm text-slate-400">
{label}
</p>
<p className="text-sm text-slate-400">{label}</p>
<p
className={cn(
"mt-1 text-[22px] leading-none font-semibold tracking-tight text-gray-600 dark:text-white",
'mt-1 text-[22px] leading-none font-semibold tracking-tight text-gray-600 dark:text-white',
valueClassName
)}
>
{value}
</p>
{helper && (
<p className="mt-1 text-sm text-slate-500">
{helper}
</p>
)}
{helper && <p className="mt-1 text-sm text-slate-500">{helper}</p>}
</div>
</div>
);
}
@@ -273,7 +265,7 @@ function ActionButton({
title,
description,
className,
onClick
onClick,
}: {
icon: React.ReactNode;
title: string;
@@ -299,5 +291,4 @@ function ActionButton({
</div>
</Button>
);
}

View File

@@ -4,7 +4,7 @@ const TIME_REGEX = /^([01]\d|2[0-3]):([0-5]\d)$/
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/
const BOOKING_CODE_REGEX = /^[A-Z0-9]{6,8}$/
export const bookingStatusSchema = z.enum(['CONFIRMED', 'CANCELLED', 'COMPLETED'])
export const bookingStatusSchema = z.enum(['CONFIRMED', 'CANCELLED', 'COMPLETED', 'NOSHOW'])
export const adminBookingSportSchema = z.object({
id: z.uuid(),