Close dialog on status change

This commit is contained in:
Jose Selesan
2026-05-06 17:03:42 -03:00
parent 41a217e8a9
commit 7be776f189
22 changed files with 408 additions and 428 deletions

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

@@ -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

@@ -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(
'Solo se pueden marcar como no show las reservas confirmadas.',
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({
where: { id: booking.id },
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
await db.courtBooking.delete({
where: { id: booking.id },
})
});
}
return mapBookingResponse({...booking, status: input.status });
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: {
to: string;
subject: string;
html: string;
text: string;
}) => undefined);
export const transactionMock = mock(async <T>(fn: (tx: PrismaClientMock<PrismaClient>) => Promise<T>) =>
fn(prismaMock)
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)
);
export const dbMock = {
complexUser: prismaMock.complexUser,