Close dialog on status change
This commit is contained in:
@@ -9,8 +9,8 @@ import { requestIdMiddleware } from './lib/http/request-id';
|
|||||||
export function createApp() {
|
export function createApp() {
|
||||||
const app = new Hono<AppEnv>();
|
const app = new Hono<AppEnv>();
|
||||||
|
|
||||||
app.use('*', requestIdMiddleware)
|
app.use('*', requestIdMiddleware);
|
||||||
app.use('*', errorHandler)
|
app.use('*', errorHandler);
|
||||||
|
|
||||||
const allowedOrigins = (Bun.env.CORS_ORIGIN ?? 'http://localhost:5173,http://127.0.0.1:5173')
|
const allowedOrigins = (Bun.env.CORS_ORIGIN ?? 'http://localhost:5173,http://127.0.0.1:5173')
|
||||||
.split(',')
|
.split(',')
|
||||||
|
|||||||
@@ -1,58 +1,58 @@
|
|||||||
// lib/errors.ts
|
// lib/errors.ts
|
||||||
export type ValidationIssue = {
|
export type ValidationIssue = {
|
||||||
path: string
|
path: string;
|
||||||
message: string
|
message: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type AppError =
|
export type AppError =
|
||||||
| {
|
| {
|
||||||
type: 'validation'
|
type: 'validation';
|
||||||
message: string
|
message: string;
|
||||||
issues?: ValidationIssue[]
|
issues?: ValidationIssue[];
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'not_found'
|
type: 'not_found';
|
||||||
message: string
|
message: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'conflict'
|
type: 'conflict';
|
||||||
message: string
|
message: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'unauthorized'
|
type: 'unauthorized';
|
||||||
message: string
|
message: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'forbidden'
|
type: 'forbidden';
|
||||||
message: string
|
message: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'unexpected'
|
type: 'unexpected';
|
||||||
message: string
|
message: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const Errors = {
|
export const Errors = {
|
||||||
validation(message: string, issues?: ValidationIssue[]): AppError {
|
validation(message: string, issues?: ValidationIssue[]): AppError {
|
||||||
return { type: 'validation', message, issues }
|
return { type: 'validation', message, issues };
|
||||||
},
|
},
|
||||||
|
|
||||||
notFound(message = 'Resource not found'): AppError {
|
notFound(message = 'Resource not found'): AppError {
|
||||||
return { type: 'not_found', message }
|
return { type: 'not_found', message };
|
||||||
},
|
},
|
||||||
|
|
||||||
conflict(message: string): AppError {
|
conflict(message: string): AppError {
|
||||||
return { type: 'conflict', message }
|
return { type: 'conflict', message };
|
||||||
},
|
},
|
||||||
|
|
||||||
unauthorized(message = 'Unauthorized'): AppError {
|
unauthorized(message = 'Unauthorized'): AppError {
|
||||||
return { type: 'unauthorized', message }
|
return { type: 'unauthorized', message };
|
||||||
},
|
},
|
||||||
|
|
||||||
forbidden(message = 'Forbidden'): AppError {
|
forbidden(message = 'Forbidden'): AppError {
|
||||||
return { type: 'forbidden', message }
|
return { type: 'forbidden', message };
|
||||||
},
|
},
|
||||||
|
|
||||||
unexpected(message = 'Unexpected error'): AppError {
|
unexpected(message = 'Unexpected error'): AppError {
|
||||||
return { type: 'unexpected', message }
|
return { type: 'unexpected', message };
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
// http/error-handler.ts
|
// http/error-handler.ts
|
||||||
import type { MiddlewareHandler } from 'hono'
|
import type { MiddlewareHandler } from 'hono';
|
||||||
import { unexpectedProblem } from './problem-builders'
|
import { logger } from '../logger';
|
||||||
import { logger } from '../logger'
|
import { unexpectedProblem } from './problem-builders';
|
||||||
|
|
||||||
export const errorHandler: MiddlewareHandler = async (c, next) => {
|
export const errorHandler: MiddlewareHandler = async (c, next) => {
|
||||||
try {
|
try {
|
||||||
await next()
|
await next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const requestId = c.get('requestId') as string | undefined
|
const requestId = c.get('requestId') as string | undefined;
|
||||||
const instance = requestId ? `/requests/${requestId}` : undefined
|
const instance = requestId ? `/requests/${requestId}` : undefined;
|
||||||
|
|
||||||
logger.error({
|
logger.error({
|
||||||
requestId,
|
requestId,
|
||||||
error
|
error,
|
||||||
})
|
});
|
||||||
|
|
||||||
return c.json(unexpectedProblem({ instance }), 500)
|
return c.json(unexpectedProblem({ instance }), 500);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Context } from 'hono'
|
import type { Result } from '@/lib/result';
|
||||||
import type { ContentfulStatusCode } from 'hono/utils/http-status'
|
import type { Context } from 'hono';
|
||||||
import type { Result } from '@/lib/result'
|
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
||||||
import { mapAppErrorToProblem } from './problem-mapper'
|
import { mapAppErrorToProblem } from './problem-mapper';
|
||||||
|
|
||||||
export function handleResult<T>(
|
export function handleResult<T>(
|
||||||
c: Context,
|
c: Context,
|
||||||
@@ -9,12 +9,12 @@ export function handleResult<T>(
|
|||||||
successStatus: ContentfulStatusCode = 200
|
successStatus: ContentfulStatusCode = 200
|
||||||
) {
|
) {
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
const requestId = c.get('requestId') as string | undefined
|
const requestId = c.get('requestId') as string | undefined;
|
||||||
const instance = requestId ? `/requests/${requestId}` : undefined
|
const instance = requestId ? `/requests/${requestId}` : undefined;
|
||||||
const problem = mapAppErrorToProblem(result.error, instance)
|
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);
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
// http/problem-builders.ts
|
// http/problem-builders.ts
|
||||||
import type { ProblemDetails } from './problem-details'
|
import type { ProblemDetails } from './problem-details';
|
||||||
|
|
||||||
export function validationProblem(params: {
|
export function validationProblem(params: {
|
||||||
detail?: string
|
detail?: string;
|
||||||
instance?: string
|
instance?: string;
|
||||||
errors?: Record<string, string[]>
|
errors?: Record<string, string[]>;
|
||||||
}): ProblemDetails {
|
}): ProblemDetails {
|
||||||
return {
|
return {
|
||||||
type: 'https://api.myapp.dev/problems/validation',
|
type: 'https://api.myapp.dev/problems/validation',
|
||||||
@@ -12,18 +12,18 @@ export function validationProblem(params: {
|
|||||||
status: 400,
|
status: 400,
|
||||||
detail: params.detail ?? 'Invalid request data',
|
detail: params.detail ?? 'Invalid request data',
|
||||||
instance: params.instance,
|
instance: params.instance,
|
||||||
errors: params.errors
|
errors: params.errors,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unexpectedProblem(params?: {
|
export function unexpectedProblem(params?: {
|
||||||
instance?: string
|
instance?: string;
|
||||||
}): ProblemDetails {
|
}): ProblemDetails {
|
||||||
return {
|
return {
|
||||||
type: 'https://api.myapp.dev/problems/unexpected',
|
type: 'https://api.myapp.dev/problems/unexpected',
|
||||||
title: 'Internal Server Error',
|
title: 'Internal Server Error',
|
||||||
status: 500,
|
status: 500,
|
||||||
detail: 'Internal server error',
|
detail: 'Internal server error',
|
||||||
instance: params?.instance
|
instance: params?.instance,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { ContentfulStatusCode } from 'hono/utils/http-status'
|
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
||||||
|
|
||||||
export type ProblemDetails = {
|
export type ProblemDetails = {
|
||||||
type: string
|
type: string;
|
||||||
title: string
|
title: string;
|
||||||
status: ContentfulStatusCode
|
status: ContentfulStatusCode;
|
||||||
detail?: string
|
detail?: string;
|
||||||
instance?: string
|
instance?: string;
|
||||||
errors?: Record<string, string[]>
|
errors?: Record<string, string[]>;
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,53 +1,50 @@
|
|||||||
import type { ContentfulStatusCode } from 'hono/utils/http-status'
|
import type { AppError } from '@/lib/errors';
|
||||||
import type { AppError } from '@/lib/errors'
|
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
||||||
import type { ProblemDetails } from './problem-details'
|
import type { ProblemDetails } from './problem-details';
|
||||||
|
|
||||||
function statusFromError(error: AppError): ContentfulStatusCode {
|
function statusFromError(error: AppError): ContentfulStatusCode {
|
||||||
switch (error.type) {
|
switch (error.type) {
|
||||||
case 'validation':
|
case 'validation':
|
||||||
return 400
|
return 400;
|
||||||
case 'unauthorized':
|
case 'unauthorized':
|
||||||
return 401
|
return 401;
|
||||||
case 'forbidden':
|
case 'forbidden':
|
||||||
return 403
|
return 403;
|
||||||
case 'not_found':
|
case 'not_found':
|
||||||
return 404
|
return 404;
|
||||||
case 'conflict':
|
case 'conflict':
|
||||||
return 409
|
return 409;
|
||||||
case 'unexpected':
|
case 'unexpected':
|
||||||
default:
|
default:
|
||||||
return 500
|
return 500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function titleFromStatus(status: ContentfulStatusCode): string {
|
function titleFromStatus(status: ContentfulStatusCode): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 400:
|
case 400:
|
||||||
return 'Bad Request'
|
return 'Bad Request';
|
||||||
case 401:
|
case 401:
|
||||||
return 'Unauthorized'
|
return 'Unauthorized';
|
||||||
case 403:
|
case 403:
|
||||||
return 'Forbidden'
|
return 'Forbidden';
|
||||||
case 404:
|
case 404:
|
||||||
return 'Not Found'
|
return 'Not Found';
|
||||||
case 409:
|
case 409:
|
||||||
return 'Conflict'
|
return 'Conflict';
|
||||||
default:
|
default:
|
||||||
return 'Internal Server Error'
|
return 'Internal Server Error';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapAppErrorToProblem(
|
export function mapAppErrorToProblem(error: AppError, instance?: string): ProblemDetails {
|
||||||
error: AppError,
|
const status = statusFromError(error);
|
||||||
instance?: string
|
|
||||||
): ProblemDetails {
|
|
||||||
const status = statusFromError(error)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: `https://api.myapp.dev/problems/${error.type}`,
|
type: `https://api.myapp.dev/problems/${error.type}`,
|
||||||
title: titleFromStatus(status),
|
title: titleFromStatus(status),
|
||||||
status,
|
status,
|
||||||
detail: status === 500 ? 'Internal server error' : error.message,
|
detail: status === 500 ? 'Internal server error' : error.message,
|
||||||
instance
|
instance,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import type { MiddlewareHandler } from 'hono'
|
import type { MiddlewareHandler } from 'hono';
|
||||||
|
|
||||||
type Env = {
|
type Env = {
|
||||||
Variables: {
|
Variables: {
|
||||||
requestId: string
|
requestId: string;
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
export const requestIdMiddleware: MiddlewareHandler<Env> = async (c, next) => {
|
export const requestIdMiddleware: MiddlewareHandler<Env> = async (c, next) => {
|
||||||
const requestId = crypto.randomUUID()
|
const requestId = crypto.randomUUID();
|
||||||
|
|
||||||
c.set('requestId', requestId)
|
c.set('requestId', requestId);
|
||||||
c.header('X-Request-Id', requestId)
|
c.header('X-Request-Id', requestId);
|
||||||
|
|
||||||
await next()
|
await next();
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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'
|
type ValidationTarget = 'json' | 'query' | 'param' | 'header' | 'form';
|
||||||
import type { ZodSchema } from 'zod'
|
|
||||||
import { validationProblem } from './problem-builders'
|
|
||||||
import { zodIssuesToRecord } from './zod-issues'
|
|
||||||
|
|
||||||
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) => {
|
return zValidator(target, schema, (result, c) => {
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestId = c.get('requestId') as string | undefined
|
const requestId = c.get('requestId') as string | undefined;
|
||||||
const instance = requestId ? `/requests/${requestId}` : undefined
|
const instance = requestId ? `/requests/${requestId}` : undefined;
|
||||||
|
|
||||||
return c.json(
|
return c.json(
|
||||||
validationProblem({
|
validationProblem({
|
||||||
detail: `Invalid ${target} data`,
|
detail: `Invalid ${target} data`,
|
||||||
instance,
|
instance,
|
||||||
errors: zodIssuesToRecord(result.error.issues)
|
errors: zodIssuesToRecord(result.error.issues),
|
||||||
}),
|
}),
|
||||||
400
|
400
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const validate = {
|
export const validate = {
|
||||||
json<TSchema extends ZodSchema>(schema: TSchema) {
|
json<TSchema extends ZodSchema>(schema: TSchema) {
|
||||||
return makeValidator('json', schema)
|
return makeValidator('json', schema);
|
||||||
},
|
},
|
||||||
|
|
||||||
query<TSchema extends ZodSchema>(schema: TSchema) {
|
query<TSchema extends ZodSchema>(schema: TSchema) {
|
||||||
return makeValidator('query', schema)
|
return makeValidator('query', schema);
|
||||||
},
|
},
|
||||||
|
|
||||||
param<TSchema extends ZodSchema>(schema: TSchema) {
|
param<TSchema extends ZodSchema>(schema: TSchema) {
|
||||||
return makeValidator('param', schema)
|
return makeValidator('param', schema);
|
||||||
},
|
},
|
||||||
|
|
||||||
header<TSchema extends ZodSchema>(schema: TSchema) {
|
header<TSchema extends ZodSchema>(schema: TSchema) {
|
||||||
return makeValidator('header', schema)
|
return makeValidator('header', schema);
|
||||||
},
|
},
|
||||||
|
|
||||||
form<TSchema extends ZodSchema>(schema: TSchema) {
|
form<TSchema extends ZodSchema>(schema: TSchema) {
|
||||||
return makeValidator('form', schema)
|
return makeValidator('form', schema);
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
type IssueLike = {
|
type IssueLike = {
|
||||||
path: PropertyKey[]
|
path: PropertyKey[];
|
||||||
message: string
|
message: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export function zodIssuesToRecord(
|
export function zodIssuesToRecord(issues: IssueLike[]): Record<string, string[]> {
|
||||||
issues: IssueLike[]
|
const out: Record<string, string[]> = {};
|
||||||
): Record<string, string[]> {
|
|
||||||
const out: Record<string, string[]> = {}
|
|
||||||
|
|
||||||
for (const issue of issues) {
|
for (const issue of issues) {
|
||||||
const key = issue.path.length ? issue.path.join('.') : 'root'
|
const key = issue.path.length ? issue.path.join('.') : 'root';
|
||||||
out[key] ??= []
|
out[key] ??= [];
|
||||||
out[key].push(issue.message)
|
out[key].push(issue.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return out
|
return out;
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
// lib/result.ts
|
// lib/result.ts
|
||||||
import type { AppError } from '@/lib/errors';
|
import type { AppError } from '@/lib/errors';
|
||||||
|
|
||||||
export type Result<T, E = AppError> =
|
export type Result<T, E = AppError> = { ok: true; value: T } | { ok: false; error: E };
|
||||||
| { ok: true; value: T }
|
|
||||||
| { ok: false; error: E }
|
|
||||||
|
|
||||||
export function ok<T>(value: T): Result<T> {
|
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> {
|
export function err<E>(error: E): Result<never, E> {
|
||||||
return { ok: false, error }
|
return { ok: false, error };
|
||||||
}
|
}
|
||||||
@@ -494,7 +494,7 @@ export async function updateAdminBookingStatus(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if(input.status === 'NOSHOW' && booking.status !== 'CONFIRMED') {
|
if (input.status === 'NOSHOW' && booking.status !== 'CONFIRMED') {
|
||||||
throw new AdminBookingServiceError(
|
throw new AdminBookingServiceError(
|
||||||
'Solo se pueden marcar como no show las reservas confirmadas.',
|
'Solo se pueden marcar como no show las reservas confirmadas.',
|
||||||
409
|
409
|
||||||
@@ -517,7 +517,7 @@ export async function updateAdminBookingStatus(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if(input.status === 'COMPLETED' || input.status ==='NOSHOW') {
|
if (input.status === 'COMPLETED' || input.status === 'NOSHOW') {
|
||||||
await db.courtBooking.update({
|
await db.courtBooking.update({
|
||||||
where: { id: booking.id },
|
where: { id: booking.id },
|
||||||
data: {
|
data: {
|
||||||
@@ -528,8 +528,8 @@ export async function updateAdminBookingStatus(
|
|||||||
// if the booking is cancelled we delete it to free up the slot, but we keep a log of it with the cancelled status
|
// 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({
|
await db.courtBooking.delete({
|
||||||
where: { id: booking.id },
|
where: { id: booking.id },
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return mapBookingResponse({...booking, status: input.status });
|
return mapBookingResponse({ ...booking, status: input.status });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Prisma, Sport } from '@/generated/prisma/client';
|
import { Prisma, Sport } from '@/generated/prisma/client';
|
||||||
import { Errors } from '@/lib/errors';
|
import { Errors } from '@/lib/errors';
|
||||||
import { db } from '@/lib/prisma';
|
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';
|
import { v7 as uuidv7 } from 'uuid';
|
||||||
|
|
||||||
export type CreateSportInput = {
|
export type CreateSportInput = {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { validate } from '@/lib/http/validate';
|
||||||
import { requireAuth } from '@/middlewares/require-auth.middleware';
|
import { requireAuth } from '@/middlewares/require-auth.middleware';
|
||||||
import { requireSuperAdmin } from '@/middlewares/require-super-admin.middleware';
|
import { requireSuperAdmin } from '@/middlewares/require-super-admin.middleware';
|
||||||
import { createSportHandler } from '@/modules/sport/handlers/create-sport.handler';
|
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 { createSportSchema, updateSportSchema } from '@repo/api-contract';
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { validate } from '@/lib/http/validate'
|
|
||||||
export const sportRoutes = new Hono<AppEnv>();
|
export const sportRoutes = new Hono<AppEnv>();
|
||||||
const sportIdParamsSchema = z.object({ id: z.uuid() });
|
const sportIdParamsSchema = z.object({ id: z.uuid() });
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { beforeEach, expect, mock, test } from 'bun:test';
|
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 { createInviteComplexUserHandler } from '@/modules/complex/handlers/invite-complex-user.handler';
|
||||||
|
import type { InviteComplexUserResponse } from '@repo/api-contract';
|
||||||
|
|
||||||
const inviteComplexUserMock = mock(
|
const inviteComplexUserMock = mock(async () => undefined as unknown as InviteComplexUserResponse);
|
||||||
async () => undefined as unknown as InviteComplexUserResponse
|
|
||||||
);
|
|
||||||
|
|
||||||
class MockComplexMembersError extends Error {
|
class MockComplexMembersError extends Error {
|
||||||
status: 400 | 403 | 404 | 409;
|
status: 400 | 403 | 404 | 409;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { beforeEach, expect, mock, test } from 'bun:test';
|
import { beforeEach, expect, mock, test } from 'bun:test';
|
||||||
|
|
||||||
import { prismaMock, sendMailMock, transactionMock } from '../support/prisma.mock';
|
|
||||||
import {
|
import {
|
||||||
ComplexMembersError,
|
ComplexMembersError,
|
||||||
inviteComplexUser,
|
inviteComplexUser,
|
||||||
} from '@/modules/complex/services/complex-members.service';
|
} from '@/modules/complex/services/complex-members.service';
|
||||||
|
import { prismaMock, sendMailMock, transactionMock } from '../support/prisma.mock';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
prismaMock._reset();
|
prismaMock._reset();
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ mock.module('@/modules/sport/services/sport.service', () => ({
|
|||||||
createSport: createSportMock,
|
createSport: createSportMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { createSportHandler } = await import(
|
const { createSportHandler } = await import('@/modules/sport/handlers/create-sport.handler');
|
||||||
'@/modules/sport/handlers/create-sport.handler'
|
|
||||||
);
|
|
||||||
|
|
||||||
type HandlerContext = {
|
type HandlerContext = {
|
||||||
get: (key: 'requestId') => string | undefined;
|
get: (key: 'requestId') => string | undefined;
|
||||||
|
|||||||
@@ -5,14 +5,16 @@ import type { PrismaClient } from '@/generated/prisma/client';
|
|||||||
import type { PrismaClientMock } from 'bun-mock-prisma';
|
import type { PrismaClientMock } from 'bun-mock-prisma';
|
||||||
|
|
||||||
export const prismaMock = createPrismaMock<PrismaClient>() as PrismaClientMock<PrismaClient>;
|
export const prismaMock = createPrismaMock<PrismaClient>() as PrismaClientMock<PrismaClient>;
|
||||||
export const sendMailMock = mock(async (_input: {
|
export const sendMailMock = mock(
|
||||||
|
async (_input: {
|
||||||
to: string;
|
to: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
html: string;
|
html: string;
|
||||||
text: string;
|
text: string;
|
||||||
}) => undefined);
|
}) => undefined
|
||||||
export const transactionMock = mock(async <T>(fn: (tx: PrismaClientMock<PrismaClient>) => Promise<T>) =>
|
);
|
||||||
fn(prismaMock)
|
export const transactionMock = mock(
|
||||||
|
async <T>(fn: (tx: PrismaClientMock<PrismaClient>) => Promise<T>) => fn(prismaMock)
|
||||||
);
|
);
|
||||||
export const dbMock = {
|
export const dbMock = {
|
||||||
complexUser: prismaMock.complexUser,
|
complexUser: prismaMock.complexUser,
|
||||||
|
|||||||
@@ -255,7 +255,10 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
|
|||||||
[bookingsQuery.data?.bookings, selectedDate]
|
[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(() => {
|
const filteredCourts = useMemo(() => {
|
||||||
if (selectedSportId === 'all') return courts;
|
if (selectedSportId === 'all') return courts;
|
||||||
@@ -293,7 +296,6 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const currentTime = useMemo(() => {
|
const currentTime = useMemo(() => {
|
||||||
|
|
||||||
if (!isTodayIso(selectedDate)) return null;
|
if (!isTodayIso(selectedDate)) return null;
|
||||||
const nowMinutes = timeToMinutes(now);
|
const nowMinutes = timeToMinutes(now);
|
||||||
const start = timeToMinutes(visibleTimeRange.start);
|
const start = timeToMinutes(visibleTimeRange.start);
|
||||||
@@ -345,10 +347,12 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
|
|||||||
(segment?: BookingTimelineSegment) => {
|
(segment?: BookingTimelineSegment) => {
|
||||||
setSelectedSegment(segment ?? null);
|
setSelectedSegment(segment ?? null);
|
||||||
setBookingToolsOpen(true);
|
setBookingToolsOpen(true);
|
||||||
console.log(JSON.stringify(segment, null, 2))
|
console.log(JSON.stringify(segment, null, 2));
|
||||||
}, [selectedDate]);
|
},
|
||||||
|
[selectedDate]
|
||||||
|
);
|
||||||
|
|
||||||
const closeBookingTools = () => setBookingToolsOpen(false)
|
const closeBookingTools = () => setBookingToolsOpen(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleOpenCreateBooking = () => {
|
const handleOpenCreateBooking = () => {
|
||||||
@@ -449,7 +453,7 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
|
|||||||
bookingToolsOpen,
|
bookingToolsOpen,
|
||||||
openBookingTools,
|
openBookingTools,
|
||||||
closeBookingTools,
|
closeBookingTools,
|
||||||
selectedSegment
|
selectedSegment,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
bookings,
|
bookings,
|
||||||
@@ -480,7 +484,7 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
|
|||||||
updateBookingStatus,
|
updateBookingStatus,
|
||||||
viewMode,
|
viewMode,
|
||||||
visibleTimeRange,
|
visibleTimeRange,
|
||||||
bookingToolsOpen
|
bookingToolsOpen,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -227,8 +227,7 @@ function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: Booki
|
|||||||
segment.status === 'maintenance' &&
|
segment.status === 'maintenance' &&
|
||||||
'border-maintenance/80 bg-maintenance/75 text-maintenance-foreground',
|
'border-maintenance/80 bg-maintenance/75 text-maintenance-foreground',
|
||||||
segment.booking?.status === 'COMPLETED' &&
|
segment.booking?.status === 'COMPLETED' &&
|
||||||
'border-reserved/70 bg-reserved/25 dark:text-reserved-foreground text-gray-600 line-through',
|
'border-reserved/70 bg-reserved/25 dark:text-reserved-foreground text-gray-600 line-through'
|
||||||
|
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
left: `calc(${left}% + 4px)`,
|
left: `calc(${left}% + 4px)`,
|
||||||
@@ -242,7 +241,7 @@ function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: Booki
|
|||||||
sportId: schedule.court.sportId,
|
sportId: schedule.court.sportId,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if(event.shiftKey && segment.booking?.status === 'CONFIRMED') {
|
if (event.shiftKey && segment.booking?.status === 'CONFIRMED') {
|
||||||
updateBookingStatus(segment.booking.id, 'COMPLETED');
|
updateBookingStatus(segment.booking.id, 'COMPLETED');
|
||||||
} else {
|
} else {
|
||||||
openBookingTools(segment);
|
openBookingTools(segment);
|
||||||
@@ -269,7 +268,6 @@ function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: Booki
|
|||||||
<Users className="size-3" />
|
<Users className="size-3" />
|
||||||
{segment.booking?.customerName ?? 'Mantenimiento'}
|
{segment.booking?.customerName ?? 'Mantenimiento'}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import { ResponsiveDialog, ResponsiveDialogClose, ResponsiveDialogContent, ResponsiveDialogDescription, ResponsiveDialogFooter, ResponsiveDialogHeader, ResponsiveDialogTitle } from "@/components/ui/responsive-dialog";
|
import { Button } from '@/components/ui/button';
|
||||||
import { useBooking } from "../booking-provider";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
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,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
MapPin,
|
MapPin,
|
||||||
@@ -9,65 +20,55 @@ import {
|
|||||||
Phone,
|
Phone,
|
||||||
User,
|
User,
|
||||||
XCircle,
|
XCircle,
|
||||||
AlertTriangle,
|
} from 'lucide-react';
|
||||||
BadgeCheck,
|
import { useBooking } from '../booking-provider';
|
||||||
} from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
|
|
||||||
function formatDate(date: string | undefined) {
|
function formatDate(date: string | undefined) {
|
||||||
if (!date) return "";
|
if (!date) return '';
|
||||||
|
|
||||||
const formatted = new Intl.DateTimeFormat("es-AR", {
|
const formatted = new Intl.DateTimeFormat('es-AR', {
|
||||||
weekday: "long",
|
weekday: 'long',
|
||||||
day: "numeric",
|
day: 'numeric',
|
||||||
month: "long",
|
month: 'long',
|
||||||
}).format(new Date(`${date}T00:00:00`));
|
}).format(new Date(`${date}T00:00:00`));
|
||||||
|
|
||||||
return formatted
|
return formatted
|
||||||
.split(" ")
|
.split(' ')
|
||||||
.map((word, index) => {
|
.map((word, index) => {
|
||||||
if (index === 0 || index === 3) {
|
if (index === 0 || index === 3) {
|
||||||
return word.charAt(0).toUpperCase() + word.slice(1);
|
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||||
}
|
}
|
||||||
return word;
|
return word;
|
||||||
})
|
})
|
||||||
.join(" ");
|
.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusConfig: Record<string, { label: string; className: string }> = {
|
const statusConfig: Record<string, { label: string; className: string }> = {
|
||||||
|
|
||||||
CONFIRMED: {
|
CONFIRMED: {
|
||||||
label: "Reservada",
|
label: 'Reservada',
|
||||||
className: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
|
className: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30',
|
||||||
},
|
},
|
||||||
COMPLETED: {
|
COMPLETED: {
|
||||||
label: "Completada",
|
label: 'Completada',
|
||||||
className: "bg-sky-500/15 text-sky-400 border-sky-500/30",
|
className: 'bg-sky-500/15 text-sky-400 border-sky-500/30',
|
||||||
},
|
},
|
||||||
CANCELLED: {
|
CANCELLED: {
|
||||||
label: "Cancelada",
|
label: 'Cancelada',
|
||||||
className: "bg-red-500/15 text-red-400 border-red-500/30",
|
className: 'bg-red-500/15 text-red-400 border-red-500/30',
|
||||||
},
|
},
|
||||||
NOSHOW: {
|
NOSHOW: {
|
||||||
label: "No show",
|
label: 'No show',
|
||||||
className: "bg-amber-500/15 text-amber-400 border-amber-500/30",
|
className: 'bg-amber-500/15 text-amber-400 border-amber-500/30',
|
||||||
},
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BookingToolsDialog() {
|
export function BookingToolsDialog() {
|
||||||
const {
|
const { selectedSegment, bookingToolsOpen, closeBookingTools, updateBookingStatus } =
|
||||||
selectedSegment,
|
useBooking();
|
||||||
bookingToolsOpen,
|
|
||||||
closeBookingTools,
|
|
||||||
updateBookingStatus,
|
|
||||||
} = useBooking();
|
|
||||||
|
|
||||||
|
const status = statusConfig[selectedSegment?.booking?.status ?? 'CONFIRMED'] ?? {
|
||||||
const status = statusConfig[selectedSegment?.booking?.status ?? "CONFIRMED"] ?? {
|
|
||||||
label: selectedSegment?.booking?.status,
|
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 = () => {
|
const sendWhatsappReminder = () => {
|
||||||
@@ -77,13 +78,10 @@ export function BookingToolsDialog() {
|
|||||||
|
|
||||||
const url = `https://wa.me/${phone}?text=${encodeURIComponent(message)}`;
|
const url = `https://wa.me/${phone}?text=${encodeURIComponent(message)}`;
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveDialog
|
<ResponsiveDialog open={bookingToolsOpen} onOpenChange={(open) => !open && closeBookingTools()}>
|
||||||
open={bookingToolsOpen}
|
|
||||||
onOpenChange={(open) => !open && closeBookingTools()}
|
|
||||||
>
|
|
||||||
<ResponsiveDialogContent
|
<ResponsiveDialogContent
|
||||||
className="data-[variant=dialog]:max-w-5xl"
|
className="data-[variant=dialog]:max-w-5xl"
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
@@ -100,7 +98,7 @@ export function BookingToolsDialog() {
|
|||||||
<DetailItem
|
<DetailItem
|
||||||
icon={<MapPin className="h-5 w-5" />}
|
icon={<MapPin className="h-5 w-5" />}
|
||||||
label="Cancha"
|
label="Cancha"
|
||||||
value={selectedSegment?.booking?.courtName ?? "-"}
|
value={selectedSegment?.booking?.courtName ?? '-'}
|
||||||
helper={selectedSegment?.booking?.sport?.name}
|
helper={selectedSegment?.booking?.sport?.name}
|
||||||
withDivider
|
withDivider
|
||||||
/>
|
/>
|
||||||
@@ -116,7 +114,7 @@ export function BookingToolsDialog() {
|
|||||||
<DetailItem
|
<DetailItem
|
||||||
icon={<User className="h-5 w-5" />}
|
icon={<User className="h-5 w-5" />}
|
||||||
label="Cliente"
|
label="Cliente"
|
||||||
value={selectedSegment?.booking?.customerName ?? "-"}
|
value={selectedSegment?.booking?.customerName ?? '-'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DetailItem
|
<DetailItem
|
||||||
@@ -138,12 +136,10 @@ export function BookingToolsDialog() {
|
|||||||
<DetailItem
|
<DetailItem
|
||||||
icon={<Phone className="h-5 w-5" />}
|
icon={<Phone className="h-5 w-5" />}
|
||||||
label="Teléfono"
|
label="Teléfono"
|
||||||
value={selectedSegment?.booking?.customerPhone ?? "-"}
|
value={selectedSegment?.booking?.customerPhone ?? '-'}
|
||||||
className="pt-10"
|
className="pt-10"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Separator className="dark:bg-slate-800 bg-slate-300" />
|
<Separator className="dark:bg-slate-800 bg-slate-300" />
|
||||||
@@ -161,7 +157,10 @@ export function BookingToolsDialog() {
|
|||||||
title="Completar reserva"
|
title="Completar reserva"
|
||||||
description="El cliente se presentó para usar la cancha."
|
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"
|
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
|
<ActionButton
|
||||||
@@ -169,7 +168,10 @@ export function BookingToolsDialog() {
|
|||||||
title="Cancelar reserva"
|
title="Cancelar reserva"
|
||||||
description="El cliente avisó que no va a venir. La cancha vuelve a estar disponible."
|
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"
|
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
|
<ActionButton
|
||||||
@@ -185,7 +187,10 @@ export function BookingToolsDialog() {
|
|||||||
title="Marcar como No Show"
|
title="Marcar como No Show"
|
||||||
description="El cliente no vino y no canceló."
|
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"
|
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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -217,14 +222,8 @@ function DetailItem({
|
|||||||
withDivider?: boolean;
|
withDivider?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cn('relative flex items-start gap-4 px-5 py-1', className)}>
|
||||||
className={cn(
|
|
||||||
"relative flex items-start gap-4 px-5 py-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{withDivider && (
|
{withDivider && (
|
||||||
<div
|
<div
|
||||||
className="
|
className="
|
||||||
@@ -246,25 +245,18 @@ function DetailItem({
|
|||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm text-slate-400">
|
<p className="text-sm text-slate-400">{label}</p>
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
<p
|
<p
|
||||||
className={cn(
|
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
|
valueClassName
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
</p>
|
</p>
|
||||||
{helper && (
|
{helper && <p className="mt-1 text-sm text-slate-500">{helper}</p>}
|
||||||
<p className="mt-1 text-sm text-slate-500">
|
|
||||||
{helper}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +265,7 @@ function ActionButton({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
className,
|
className,
|
||||||
onClick
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -299,5 +291,4 @@ function ActionButton({
|
|||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user