feat: add city/state/country to complex, new settings page with sidebar, and Biome linting
- Added city, state, and country optional fields to Complex model - Updated onboarding to include optional location fields - Created new settings page with sidebar navigation (Datos del Complejo, Canchas) - Replaced ESLint with Biome for frontend and backend linting - Added parallel dev script with concurrently - Migrated register-routes to use direct app.route() pattern
This commit is contained in:
@@ -1,72 +1,61 @@
|
||||
import type { CreateCourtInput, DayOfWeek, UpdateCourtInput } from '@repo/api-contract'
|
||||
import { v7 as uuidv7 } from 'uuid'
|
||||
import type {
|
||||
Court,
|
||||
CourtAvailability,
|
||||
CourtPriceRule,
|
||||
Sport,
|
||||
} from '@/generated/prisma/client'
|
||||
import { db } from '@/lib/prisma'
|
||||
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service'
|
||||
import type { Court, CourtAvailability, CourtPriceRule, Sport } from '@/generated/prisma/client';
|
||||
import { db } from '@/lib/prisma';
|
||||
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
|
||||
import type { CreateCourtInput, DayOfWeek, UpdateCourtInput } from '@repo/api-contract';
|
||||
import { v7 as uuidv7 } from 'uuid';
|
||||
|
||||
type CourtWithRelations = Court & {
|
||||
sport: Sport
|
||||
availabilities: CourtAvailability[]
|
||||
priceRules: CourtPriceRule[]
|
||||
}
|
||||
sport: Sport;
|
||||
availabilities: CourtAvailability[];
|
||||
priceRules: CourtPriceRule[];
|
||||
};
|
||||
|
||||
export class CourtServiceError extends Error {
|
||||
status: 400 | 403 | 404 | 409
|
||||
status: 400 | 403 | 404 | 409;
|
||||
|
||||
constructor(message: string, status: 400 | 403 | 404 | 409) {
|
||||
super(message)
|
||||
this.name = 'CourtServiceError'
|
||||
this.status = status
|
||||
super(message);
|
||||
this.name = 'CourtServiceError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function toMinutes(value: string): number {
|
||||
const [hours, minutes] = value.split(':').map((part) => Number(part))
|
||||
return hours * 60 + minutes
|
||||
const [hours, minutes] = value.split(':').map((part) => Number(part));
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
function assertAvailabilityRanges(
|
||||
availability: Array<{
|
||||
dayOfWeek: DayOfWeek
|
||||
startTime: string
|
||||
endTime: string
|
||||
}>,
|
||||
dayOfWeek: DayOfWeek;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}>
|
||||
) {
|
||||
const grouped = new Map<DayOfWeek, Array<{ start: number; end: number }>>()
|
||||
const grouped = new Map<DayOfWeek, Array<{ start: number; end: number }>>();
|
||||
|
||||
for (const range of availability) {
|
||||
const start = toMinutes(range.startTime)
|
||||
const end = toMinutes(range.endTime)
|
||||
const start = toMinutes(range.startTime);
|
||||
const end = toMinutes(range.endTime);
|
||||
|
||||
if (start >= end) {
|
||||
throw new CourtServiceError(
|
||||
`El rango ${range.startTime}-${range.endTime} es invalido.`,
|
||||
400,
|
||||
)
|
||||
throw new CourtServiceError(`El rango ${range.startTime}-${range.endTime} es invalido.`, 400);
|
||||
}
|
||||
|
||||
const current = grouped.get(range.dayOfWeek) ?? []
|
||||
current.push({ start, end })
|
||||
grouped.set(range.dayOfWeek, current)
|
||||
const current = grouped.get(range.dayOfWeek) ?? [];
|
||||
current.push({ start, end });
|
||||
grouped.set(range.dayOfWeek, current);
|
||||
}
|
||||
|
||||
for (const ranges of grouped.values()) {
|
||||
ranges.sort((a, b) => a.start - b.start)
|
||||
ranges.sort((a, b) => a.start - b.start);
|
||||
|
||||
for (let index = 1; index < ranges.length; index += 1) {
|
||||
const previous = ranges[index - 1]
|
||||
const current = ranges[index]
|
||||
const previous = ranges[index - 1];
|
||||
const current = ranges[index];
|
||||
|
||||
if (previous.end > current.start) {
|
||||
throw new CourtServiceError(
|
||||
'Hay rangos horarios superpuestos para el mismo dia.',
|
||||
400,
|
||||
)
|
||||
throw new CourtServiceError('Hay rangos horarios superpuestos para el mismo dia.', 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,16 +81,13 @@ async function ensureComplexAccess(complexId: string, appUserId: string) {
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
if (!complexUser) {
|
||||
throw new CourtServiceError(
|
||||
'No tienes permisos para administrar este complejo.',
|
||||
403,
|
||||
)
|
||||
throw new CourtServiceError('No tienes permisos para administrar este complejo.', 403);
|
||||
}
|
||||
|
||||
return complexUser.complex
|
||||
return complexUser.complex;
|
||||
}
|
||||
|
||||
async function ensureActiveSport(sportId: string) {
|
||||
@@ -111,10 +97,10 @@ async function ensureActiveSport(sportId: string) {
|
||||
isActive: true,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
});
|
||||
|
||||
if (!sport) {
|
||||
throw new CourtServiceError('El deporte seleccionado no existe o esta inactivo.', 400)
|
||||
throw new CourtServiceError('El deporte seleccionado no existe o esta inactivo.', 400);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,28 +114,26 @@ async function enforcePlanCourtLimit(complexId: string) {
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
if (!complex?.plan) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const rules = parsePlanRules(complex.plan.rules)
|
||||
const rules = parsePlanRules(complex.plan.rules);
|
||||
const courtsCount = await db.court.count({
|
||||
where: { complexId },
|
||||
})
|
||||
});
|
||||
|
||||
const violations = evaluatePlanUsage(rules, {
|
||||
courtsCount,
|
||||
bookingsToday: 0,
|
||||
})
|
||||
});
|
||||
|
||||
const maxCourtViolation = violations.find(
|
||||
(violation) => violation.code === 'MAX_COURTS_REACHED',
|
||||
)
|
||||
const maxCourtViolation = violations.find((violation) => violation.code === 'MAX_COURTS_REACHED');
|
||||
|
||||
if (maxCourtViolation) {
|
||||
throw new CourtServiceError(maxCourtViolation.message, 409)
|
||||
throw new CourtServiceError(maxCourtViolation.message, 409);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +159,7 @@ async function getCourtByIdForUser(courtId: string, appUserId: string) {
|
||||
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
function mapCourtResponse(court: CourtWithRelations) {
|
||||
@@ -208,11 +192,11 @@ function mapCourtResponse(court: CourtWithRelations) {
|
||||
hasCustomPricing: court.priceRules.length > 0,
|
||||
createdAt: court.createdAt.toISOString(),
|
||||
updatedAt: court.updatedAt.toISOString(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function listCourtsByComplex(complexId: string, appUserId: string) {
|
||||
await ensureComplexAccess(complexId, appUserId)
|
||||
await ensureComplexAccess(complexId, appUserId);
|
||||
|
||||
const courts = await db.court.findMany({
|
||||
where: { complexId },
|
||||
@@ -229,20 +213,16 @@ export async function listCourtsByComplex(complexId: string, appUserId: string)
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
return courts.map((court) => mapCourtResponse(court))
|
||||
return courts.map((court) => mapCourtResponse(court));
|
||||
}
|
||||
|
||||
export async function createCourt(
|
||||
appUserId: string,
|
||||
complexId: string,
|
||||
input: CreateCourtInput,
|
||||
) {
|
||||
await ensureComplexAccess(complexId, appUserId)
|
||||
await ensureActiveSport(input.sportId)
|
||||
await enforcePlanCourtLimit(complexId)
|
||||
assertAvailabilityRanges(input.availability)
|
||||
export async function createCourt(appUserId: string, complexId: string, input: CreateCourtInput) {
|
||||
await ensureComplexAccess(complexId, appUserId);
|
||||
await ensureActiveSport(input.sportId);
|
||||
await enforcePlanCourtLimit(complexId);
|
||||
assertAvailabilityRanges(input.availability);
|
||||
|
||||
const createdCourt = await db.$transaction(async (tx) => {
|
||||
const court = await tx.court.create({
|
||||
@@ -254,7 +234,7 @@ export async function createCourt(
|
||||
slotDurationMinutes: input.slotDurationMinutes,
|
||||
basePrice: input.basePrice,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
await tx.courtAvailability.createMany({
|
||||
data: input.availability.map((availability) => ({
|
||||
@@ -264,37 +244,33 @@ export async function createCourt(
|
||||
startTime: availability.startTime,
|
||||
endTime: availability.endTime,
|
||||
})),
|
||||
})
|
||||
});
|
||||
|
||||
return court
|
||||
})
|
||||
return court;
|
||||
});
|
||||
|
||||
const court = await getCourtByIdForUser(createdCourt.id, appUserId)
|
||||
const court = await getCourtByIdForUser(createdCourt.id, appUserId);
|
||||
|
||||
if (!court) {
|
||||
throw new CourtServiceError('No se pudo obtener la cancha creada.', 404)
|
||||
throw new CourtServiceError('No se pudo obtener la cancha creada.', 404);
|
||||
}
|
||||
|
||||
return mapCourtResponse(court)
|
||||
return mapCourtResponse(court);
|
||||
}
|
||||
|
||||
export async function updateCourt(
|
||||
appUserId: string,
|
||||
courtId: string,
|
||||
input: UpdateCourtInput,
|
||||
) {
|
||||
const existingCourt = await getCourtByIdForUser(courtId, appUserId)
|
||||
export async function updateCourt(appUserId: string, courtId: string, input: UpdateCourtInput) {
|
||||
const existingCourt = await getCourtByIdForUser(courtId, appUserId);
|
||||
|
||||
if (!existingCourt) {
|
||||
throw new CourtServiceError('Cancha no encontrada.', 404)
|
||||
throw new CourtServiceError('Cancha no encontrada.', 404);
|
||||
}
|
||||
|
||||
if (input.sportId) {
|
||||
await ensureActiveSport(input.sportId)
|
||||
await ensureActiveSport(input.sportId);
|
||||
}
|
||||
|
||||
if (input.availability) {
|
||||
assertAvailabilityRanges(input.availability)
|
||||
assertAvailabilityRanges(input.availability);
|
||||
}
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
@@ -310,12 +286,12 @@ export async function updateCourt(
|
||||
: {}),
|
||||
...(input.basePrice !== undefined ? { basePrice: input.basePrice } : {}),
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
if (input.availability) {
|
||||
await tx.courtAvailability.deleteMany({
|
||||
where: { courtId },
|
||||
})
|
||||
});
|
||||
|
||||
await tx.courtAvailability.createMany({
|
||||
data: input.availability.map((availability) => ({
|
||||
@@ -325,15 +301,15 @@ export async function updateCourt(
|
||||
startTime: availability.startTime,
|
||||
endTime: availability.endTime,
|
||||
})),
|
||||
})
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const updatedCourt = await getCourtByIdForUser(courtId, appUserId)
|
||||
const updatedCourt = await getCourtByIdForUser(courtId, appUserId);
|
||||
|
||||
if (!updatedCourt) {
|
||||
throw new CourtServiceError('Cancha no encontrada.', 404)
|
||||
throw new CourtServiceError('Cancha no encontrada.', 404);
|
||||
}
|
||||
|
||||
return mapCourtResponse(updatedCourt)
|
||||
return mapCourtResponse(updatedCourt);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user