Refactor to vertical slice

This commit is contained in:
Jose Selesan
2026-06-26 14:05:37 -03:00
parent 3949c9add1
commit c8477de5d2
149 changed files with 5011 additions and 5127 deletions

View File

@@ -0,0 +1,176 @@
import { randomInt } from 'node:crypto';
import type { DayOfWeek } from '@repo/api-contract';
export type Slot = {
startTime: string;
endTime: string;
};
const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const BOOKING_CODE_LENGTH = 6;
export const DAY_OF_WEEK_BY_INDEX: DayOfWeek[] = [
'SUNDAY',
'MONDAY',
'TUESDAY',
'WEDNESDAY',
'THURSDAY',
'FRIDAY',
'SATURDAY',
];
export function toMinutes(value: string): number {
const [hours, minutes] = value.split(':').map((part) => Number(part));
return hours * 60 + minutes;
}
export function minutesToTime(minutes: number): string {
const safeMinutes = Math.max(0, minutes);
const hours = Math.floor(safeMinutes / 60);
const mins = safeMinutes % 60;
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
}
export function parseIsoDate(date: string): { bookingDate: Date; dayOfWeek: DayOfWeek } {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
if (!match) {
throw new Error('La fecha debe tener formato YYYY-MM-DD.');
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const bookingDate = new Date(Date.UTC(year, month - 1, day));
if (
Number.isNaN(bookingDate.getTime()) ||
bookingDate.getUTCFullYear() !== year ||
bookingDate.getUTCMonth() + 1 !== month ||
bookingDate.getUTCDate() !== day
) {
throw new Error('La fecha enviada no es valida.');
}
const dayOfWeek = DAY_OF_WEEK_BY_INDEX[bookingDate.getUTCDay()];
if (!dayOfWeek) {
throw new Error('No se pudo resolver el dia de la semana.');
}
return { bookingDate, dayOfWeek };
}
export function formatIsoDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
export function getDayOfWeekValue(date: Date): DayOfWeek {
const dayOfWeek = DAY_OF_WEEK_BY_INDEX[date.getUTCDay()];
if (!dayOfWeek) {
throw new Error('No se pudo resolver el dia de la semana.');
}
return dayOfWeek;
}
export function generateBookingCode(): string {
let code = '';
for (let index = 0; index < BOOKING_CODE_LENGTH; index += 1) {
code += BOOKING_CODE_ALPHABET[randomInt(0, BOOKING_CODE_ALPHABET.length)];
}
return code;
}
export function buildSlots(
availability: Array<{ startTime: string; endTime: string }>,
slotDurationMinutes: number
): Slot[] {
const slots: Slot[] = [];
for (const range of availability) {
const start = toMinutes(range.startTime);
const end = toMinutes(range.endTime);
for (
let current = start;
current + slotDurationMinutes <= end;
current += slotDurationMinutes
) {
slots.push({
startTime: minutesToTime(current),
endTime: minutesToTime(current + slotDurationMinutes),
});
}
}
return slots;
}
export function hasOverlap(slot: Slot, existing: Slot): boolean {
return slot.startTime < existing.endTime && slot.endTime > existing.startTime;
}
type PriceableCourt = {
basePrice: unknown;
priceRules: Array<{
dayOfWeek: DayOfWeek | null;
startTime: string | null;
endTime: string | null;
price: unknown;
}>;
};
export function resolveSlotPrice(court: PriceableCourt, dayOfWeek: DayOfWeek, slot: Slot): number {
const slotStart = toMinutes(slot.startTime);
const slotEnd = toMinutes(slot.endTime);
const matchingRules = court.priceRules
.filter((rule) => {
if (rule.dayOfWeek && rule.dayOfWeek !== dayOfWeek) return false;
if (!rule.startTime || !rule.endTime) return true;
return slotStart >= toMinutes(rule.startTime) && slotEnd <= toMinutes(rule.endTime);
})
.sort((first, second) => {
const firstSpecificity =
(first.dayOfWeek ? 2 : 0) + (first.startTime && first.endTime ? 1 : 0);
const secondSpecificity =
(second.dayOfWeek ? 2 : 0) + (second.startTime && second.endTime ? 1 : 0);
return secondSpecificity - firstSpecificity;
});
return Number(matchingRules[0]?.price ?? court.basePrice);
}
export function assertAvailabilityRanges(
availability: Array<{
dayOfWeek: DayOfWeek;
startTime: string;
endTime: string;
}>
) {
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);
if (start >= end) {
throw new Error(`El rango ${range.startTime}-${range.endTime} es invalido.`);
}
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);
for (let index = 1; index < ranges.length; index += 1) {
const previous = ranges[index - 1];
const current = ranges[index];
if (previous.end > current.start) {
throw new Error('Hay rangos horarios superpuestos para el mismo dia.');
}
}
}
}

View File

@@ -0,0 +1,41 @@
import { db } from '@/lib/prisma';
export class ComplexAccessError extends Error {
status: 403 | 404;
constructor(message: string, status: 403 | 404 = 403) {
super(message);
this.name = 'ComplexAccessError';
this.status = status;
}
}
export async function ensureComplexAccess(complexId: string, userId: string) {
const complexUser = await db.complexUser.findUnique({
where: {
complexId_userId: {
complexId,
userId,
},
},
include: {
complex: {
select: {
id: true,
complexName: true,
plan: {
select: {
rules: true,
},
},
},
},
},
});
if (!complexUser) {
throw new ComplexAccessError('No tienes permisos para administrar este complejo.', 403);
}
return complexUser.complex;
}

View File

@@ -0,0 +1,31 @@
import { v7 as uuidv7 } from 'uuid';
export function slugify(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
}
export async function buildUniqueSlug(
source: string,
findExisting: (slug: string) => Promise<{ id: string } | null>
): Promise<string> {
const base = slugify(source);
const fallback = base.length > 0 ? base : `resource-${uuidv7().slice(0, 8)}`;
let candidate = fallback;
let index = 1;
while (true) {
const existing = await findExisting(candidate);
if (!existing) return candidate;
index += 1;
candidate = `${fallback}-${index}`;
}
}