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:
Jose Selesan
2026-04-10 15:36:15 -03:00
parent 77004b14b0
commit 9ee98a4cb4
129 changed files with 2929 additions and 3186 deletions

View File

@@ -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);
}