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,36 +1,33 @@
|
||||
import { Hono } from 'hono'
|
||||
import { cors } from 'hono/cors'
|
||||
import { logger } from '@/lib/logger'
|
||||
import type { AppEnv } from '@/types/hono'
|
||||
import { requestId } from 'hono/request-id'
|
||||
import { logger } from '@/lib/logger';
|
||||
import type { AppEnv } from '@/types/hono';
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { requestId } from 'hono/request-id';
|
||||
|
||||
export function createApp() {
|
||||
const app = new Hono<AppEnv>()
|
||||
const app = new Hono<AppEnv>();
|
||||
|
||||
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(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.filter(Boolean);
|
||||
|
||||
app.use(
|
||||
'*',
|
||||
cors({
|
||||
origin: (origin) => {
|
||||
if (!origin) return allowedOrigins[0] ?? ''
|
||||
return allowedOrigins.includes(origin) ? origin : ''
|
||||
if (!origin) return allowedOrigins[0] ?? '';
|
||||
return allowedOrigins.includes(origin) ? origin : '';
|
||||
},
|
||||
allowHeaders: ['Authorization', 'Content-Type'],
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
}),
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
app.use('*', async (c, next) => {
|
||||
const start = performance.now()
|
||||
await next()
|
||||
const durationMs = Number((performance.now() - start).toFixed(1))
|
||||
const start = performance.now();
|
||||
await next();
|
||||
const durationMs = Number((performance.now() - start).toFixed(1));
|
||||
|
||||
logger.info(
|
||||
{
|
||||
@@ -38,11 +35,11 @@ export function createApp() {
|
||||
path: c.req.path,
|
||||
status: c.res.status,
|
||||
durationMs,
|
||||
requestId
|
||||
requestId,
|
||||
},
|
||||
'http_request',
|
||||
)
|
||||
})
|
||||
'http_request'
|
||||
);
|
||||
});
|
||||
|
||||
return app
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createApp } from '@/app'
|
||||
import { registerRoutes } from '@/register-routes'
|
||||
import { createApp } from '@/app';
|
||||
import { registerRoutes } from '@/register-routes';
|
||||
|
||||
const app = createApp()
|
||||
registerRoutes(app)
|
||||
const app = createApp();
|
||||
registerRoutes(app);
|
||||
|
||||
export type AppType = typeof app
|
||||
export default app
|
||||
export type AppType = typeof app;
|
||||
export default app;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import pino from 'pino'
|
||||
import pino from 'pino';
|
||||
|
||||
const isProd = Bun.env.NODE_ENV === 'production'
|
||||
const isProd = Bun.env.NODE_ENV === 'production';
|
||||
|
||||
export const logger = pino({
|
||||
level: Bun.env.LOG_LEVEL ?? (isProd ? 'info' : 'debug'),
|
||||
@@ -15,4 +15,4 @@ export const logger = pino({
|
||||
ignore: 'pid,hostname',
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
let cachedTransporter: ReturnType<typeof nodemailer.createTransport> | null = null
|
||||
let cachedTransporter: ReturnType<typeof nodemailer.createTransport> | null = null;
|
||||
|
||||
function getTransporter() {
|
||||
if (cachedTransporter) return cachedTransporter
|
||||
if (cachedTransporter) return cachedTransporter;
|
||||
|
||||
const smtpHost = Bun.env.SMTP_HOST
|
||||
const smtpPort = Number(Bun.env.SMTP_PORT ?? 587)
|
||||
const smtpUser = Bun.env.SMTP_USER
|
||||
const smtpPass = Bun.env.SMTP_PASS
|
||||
const smtpHost = Bun.env.SMTP_HOST;
|
||||
const smtpPort = Number(Bun.env.SMTP_PORT ?? 587);
|
||||
const smtpUser = Bun.env.SMTP_USER;
|
||||
const smtpPass = Bun.env.SMTP_PASS;
|
||||
|
||||
if (!smtpHost || !smtpUser || !smtpPass) {
|
||||
throw new Error(
|
||||
'Missing SMTP env vars. Set SMTP_HOST, SMTP_PORT, SMTP_USER and SMTP_PASS.',
|
||||
)
|
||||
throw new Error('Missing SMTP env vars. Set SMTP_HOST, SMTP_PORT, SMTP_USER and SMTP_PASS.');
|
||||
}
|
||||
|
||||
cachedTransporter = nodemailer.createTransport({
|
||||
@@ -24,21 +22,21 @@ function getTransporter() {
|
||||
user: smtpUser,
|
||||
pass: smtpPass,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
return cachedTransporter
|
||||
return cachedTransporter;
|
||||
}
|
||||
|
||||
export async function sendMail(input: {
|
||||
to: string
|
||||
subject: string
|
||||
html: string
|
||||
text: string
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text: string;
|
||||
}) {
|
||||
const smtpFrom = Bun.env.SMTP_FROM
|
||||
const smtpFrom = Bun.env.SMTP_FROM;
|
||||
|
||||
if (!smtpFrom) {
|
||||
throw new Error('Missing SMTP_FROM env var.')
|
||||
throw new Error('Missing SMTP_FROM env var.');
|
||||
}
|
||||
|
||||
await getTransporter().sendMail({
|
||||
@@ -47,5 +45,5 @@ export async function sendMail(input: {
|
||||
subject: input.subject,
|
||||
html: input.html,
|
||||
text: input.text,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,50 +1,50 @@
|
||||
import { PrismaPg } from '@prisma/adapter-pg'
|
||||
import { PrismaClient } from '@/generated/prisma/client'
|
||||
import { PrismaClient } from '@/generated/prisma/client';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }
|
||||
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
|
||||
|
||||
function hasExpectedDelegates(client: PrismaClient) {
|
||||
const prismaClient = client as unknown as {
|
||||
court?: { findMany?: unknown }
|
||||
sport?: { findMany?: unknown }
|
||||
}
|
||||
court?: { findMany?: unknown };
|
||||
sport?: { findMany?: unknown };
|
||||
};
|
||||
|
||||
return (
|
||||
typeof prismaClient.court?.findMany === 'function' &&
|
||||
typeof prismaClient.sport?.findMany === 'function'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function buildPrismaClient() {
|
||||
const databaseUrl = Bun.env.DATABASE_URL
|
||||
const databaseUrl = Bun.env.DATABASE_URL;
|
||||
|
||||
if (!databaseUrl) {
|
||||
throw new Error('Missing DATABASE_URL in backend environment.')
|
||||
throw new Error('Missing DATABASE_URL in backend environment.');
|
||||
}
|
||||
|
||||
const adapter = new PrismaPg({
|
||||
connectionString: databaseUrl,
|
||||
})
|
||||
});
|
||||
|
||||
return new PrismaClient({ adapter })
|
||||
return new PrismaClient({ adapter });
|
||||
}
|
||||
|
||||
export function getPrismaClient() {
|
||||
if (globalForPrisma.prisma && hasExpectedDelegates(globalForPrisma.prisma)) {
|
||||
return globalForPrisma.prisma
|
||||
return globalForPrisma.prisma;
|
||||
}
|
||||
|
||||
if (globalForPrisma.prisma && !hasExpectedDelegates(globalForPrisma.prisma)) {
|
||||
void globalForPrisma.prisma.$disconnect()
|
||||
void globalForPrisma.prisma.$disconnect();
|
||||
}
|
||||
|
||||
const prisma = buildPrismaClient()
|
||||
const prisma = buildPrismaClient();
|
||||
|
||||
if (Bun.env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.prisma = prisma
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
|
||||
return prisma
|
||||
return prisma;
|
||||
}
|
||||
|
||||
export const db = getPrismaClient()
|
||||
export const db = getPrismaClient();
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
let cachedClient: ReturnType<typeof createClient> | null = null
|
||||
let cachedClient: ReturnType<typeof createClient> | null = null;
|
||||
|
||||
export function getSupabaseAdminClient() {
|
||||
if (cachedClient) return cachedClient
|
||||
if (cachedClient) return cachedClient;
|
||||
|
||||
const supabaseUrl = Bun.env.SUPABASE_URL ?? Bun.env.VITE_SUPABASE_URL
|
||||
const supabaseServiceRoleKey = Bun.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
const supabaseUrl = Bun.env.SUPABASE_URL ?? Bun.env.VITE_SUPABASE_URL;
|
||||
const supabaseServiceRoleKey = Bun.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
if (!supabaseUrl || !supabaseServiceRoleKey) {
|
||||
throw new Error(
|
||||
'Missing Supabase admin env vars. Set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in backend environment.',
|
||||
)
|
||||
'Missing Supabase admin env vars. Set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in backend environment.'
|
||||
);
|
||||
}
|
||||
|
||||
cachedClient = createClient(supabaseUrl, supabaseServiceRoleKey, {
|
||||
@@ -20,7 +20,7 @@ export function getSupabaseAdminClient() {
|
||||
persistSession: false,
|
||||
detectSessionInUrl: false,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
return cachedClient
|
||||
return cachedClient;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl = Bun.env.SUPABASE_URL ?? Bun.env.VITE_SUPABASE_URL
|
||||
const supabaseAnonKey =
|
||||
Bun.env.SUPABASE_ANON_KEY ?? Bun.env.VITE_SUPABASE_ANON_KEY
|
||||
const supabaseUrl = Bun.env.SUPABASE_URL ?? Bun.env.VITE_SUPABASE_URL;
|
||||
const supabaseAnonKey = Bun.env.SUPABASE_ANON_KEY ?? Bun.env.VITE_SUPABASE_ANON_KEY;
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
throw new Error(
|
||||
'Missing Supabase env vars. Set SUPABASE_URL/SUPABASE_ANON_KEY (or VITE_SUPABASE_URL/VITE_SUPABASE_ANON_KEY) in backend environment.',
|
||||
)
|
||||
'Missing Supabase env vars. Set SUPABASE_URL/SUPABASE_ANON_KEY (or VITE_SUPABASE_URL/VITE_SUPABASE_ANON_KEY) in backend environment.'
|
||||
);
|
||||
}
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
@@ -16,4 +15,4 @@ export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
persistSession: false,
|
||||
detectSessionInUrl: false,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,35 +1,32 @@
|
||||
import type { MiddlewareHandler } from 'hono'
|
||||
import { db } from '@/lib/prisma'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import type { AppEnv } from '@/types/hono'
|
||||
import { db } from '@/lib/prisma';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import type { AppEnv } from '@/types/hono';
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
|
||||
function getBearerToken(value: string | undefined): string | null {
|
||||
if (!value) return null
|
||||
const [scheme, token] = value.split(' ')
|
||||
if (scheme?.toLowerCase() !== 'bearer' || !token) return null
|
||||
return token
|
||||
if (!value) return null;
|
||||
const [scheme, token] = value.split(' ');
|
||||
if (scheme?.toLowerCase() !== 'bearer' || !token) return null;
|
||||
return token;
|
||||
}
|
||||
|
||||
export const requireAuth: MiddlewareHandler<AppEnv> = async (
|
||||
c,
|
||||
next,
|
||||
) => {
|
||||
const token = getBearerToken(c.req.header('authorization'))
|
||||
export const requireAuth: MiddlewareHandler<AppEnv> = async (c, next) => {
|
||||
const token = getBearerToken(c.req.header('authorization'));
|
||||
|
||||
if (!token) {
|
||||
return c.json({ message: 'Missing bearer token.' }, 403)
|
||||
return c.json({ message: 'Missing bearer token.' }, 403);
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.auth.getUser(token)
|
||||
const { data, error } = await supabase.auth.getUser(token);
|
||||
|
||||
if (error || !data.user) {
|
||||
return c.json({ message: 'Invalid or expired token.' }, 403)
|
||||
return c.json({ message: 'Invalid or expired token.' }, 403);
|
||||
}
|
||||
|
||||
const email = data.user.email?.toLowerCase()
|
||||
const email = data.user.email?.toLowerCase();
|
||||
|
||||
if (!email) {
|
||||
return c.json({ message: 'Auth user does not contain email.' }, 403)
|
||||
return c.json({ message: 'Auth user does not contain email.' }, 403);
|
||||
}
|
||||
|
||||
const appUser = await db.user.findFirst({
|
||||
@@ -38,13 +35,13 @@ export const requireAuth: MiddlewareHandler<AppEnv> = async (
|
||||
email,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
});
|
||||
|
||||
if (!appUser) {
|
||||
return c.json({ message: 'User not provisioned in backend.' }, 403)
|
||||
return c.json({ message: 'User not provisioned in backend.' }, 403);
|
||||
}
|
||||
|
||||
c.set('authUser', data.user)
|
||||
c.set('appUserId', appUser.id)
|
||||
await next()
|
||||
}
|
||||
c.set('authUser', data.user);
|
||||
c.set('appUserId', appUser.id);
|
||||
await next();
|
||||
};
|
||||
|
||||
@@ -1,35 +1,29 @@
|
||||
import type { MiddlewareHandler } from 'hono'
|
||||
import type { AppEnv } from '@/types/hono'
|
||||
import type { AppEnv } from '@/types/hono';
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
|
||||
function getSuperAdminEmails(): Set<string> {
|
||||
const raw = Bun.env.SUPER_ADMIN_EMAILS ?? ''
|
||||
const raw = Bun.env.SUPER_ADMIN_EMAILS ?? '';
|
||||
const emails = raw
|
||||
.split(',')
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
return new Set(emails)
|
||||
.filter(Boolean);
|
||||
return new Set(emails);
|
||||
}
|
||||
|
||||
export const requireSuperAdmin: MiddlewareHandler<AppEnv> = async (
|
||||
c,
|
||||
next,
|
||||
) => {
|
||||
const authUser = c.get('authUser')
|
||||
const role =
|
||||
typeof authUser.app_metadata?.role === 'string'
|
||||
? authUser.app_metadata.role
|
||||
: null
|
||||
const email = authUser.email?.toLowerCase()
|
||||
export const requireSuperAdmin: MiddlewareHandler<AppEnv> = async (c, next) => {
|
||||
const authUser = c.get('authUser');
|
||||
const role = typeof authUser.app_metadata?.role === 'string' ? authUser.app_metadata.role : null;
|
||||
const email = authUser.email?.toLowerCase();
|
||||
|
||||
if (role === 'super_admin') {
|
||||
await next()
|
||||
return
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (email && getSuperAdminEmails().has(email)) {
|
||||
await next()
|
||||
return
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
return c.json({ message: 'Este recurso requiere permisos de super admin.' }, 403)
|
||||
}
|
||||
return c.json({ message: 'Este recurso requiere permisos de super admin.' }, 403);
|
||||
};
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
import { requireAuth } from '@/middlewares/require-auth.middleware';
|
||||
import { createAdminBookingHandler } from '@/modules/admin-booking/handlers/create-admin-booking.handler';
|
||||
import { listAdminBookingsHandler } from '@/modules/admin-booking/handlers/list-admin-bookings.handler';
|
||||
import { updateAdminBookingStatusHandler } from '@/modules/admin-booking/handlers/update-admin-booking-status.handler';
|
||||
import type { AppEnv } from '@/types/hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import {
|
||||
createAdminBookingSchema,
|
||||
listAdminBookingsQuerySchema,
|
||||
updateAdminBookingStatusSchema,
|
||||
} from '@repo/api-contract'
|
||||
import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import { requireAuth } from '@/middlewares/require-auth.middleware'
|
||||
import { createAdminBookingHandler } from '@/modules/admin-booking/handlers/create-admin-booking.handler'
|
||||
import { listAdminBookingsHandler } from '@/modules/admin-booking/handlers/list-admin-bookings.handler'
|
||||
import { updateAdminBookingStatusHandler } from '@/modules/admin-booking/handlers/update-admin-booking-status.handler'
|
||||
import type { AppEnv } from '@/types/hono'
|
||||
} from '@repo/api-contract';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const adminBookingRoutes = new Hono<AppEnv>()
|
||||
const complexIdParamsSchema = z.object({ complexId: z.uuid() })
|
||||
const bookingIdParamsSchema = z.object({ id: z.uuid() })
|
||||
export const adminBookingRoutes = new Hono<AppEnv>();
|
||||
const complexIdParamsSchema = z.object({ complexId: z.uuid() });
|
||||
const bookingIdParamsSchema = z.object({ id: z.uuid() });
|
||||
|
||||
adminBookingRoutes.use('*', requireAuth)
|
||||
adminBookingRoutes.use('*', requireAuth);
|
||||
|
||||
adminBookingRoutes.get(
|
||||
'/complex/:complexId',
|
||||
zValidator('param', complexIdParamsSchema),
|
||||
zValidator('query', listAdminBookingsQuerySchema),
|
||||
listAdminBookingsHandler,
|
||||
)
|
||||
listAdminBookingsHandler
|
||||
);
|
||||
|
||||
adminBookingRoutes.post(
|
||||
'/complex/:complexId',
|
||||
zValidator('param', complexIdParamsSchema),
|
||||
zValidator('json', createAdminBookingSchema),
|
||||
createAdminBookingHandler,
|
||||
)
|
||||
createAdminBookingHandler
|
||||
);
|
||||
|
||||
adminBookingRoutes.patch(
|
||||
'/:id/status',
|
||||
zValidator('param', bookingIdParamsSchema),
|
||||
zValidator('json', updateAdminBookingStatusSchema),
|
||||
updateAdminBookingStatusHandler,
|
||||
)
|
||||
updateAdminBookingStatusHandler
|
||||
);
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import type { CreateAdminBookingInput } from '@repo/api-contract'
|
||||
import {
|
||||
AdminBookingServiceError,
|
||||
createAdminBooking,
|
||||
} from '@/modules/admin-booking/services/admin-booking.service'
|
||||
import type { AppContext } from '@/types/hono'
|
||||
} from '@/modules/admin-booking/services/admin-booking.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
import type { CreateAdminBookingInput } from '@repo/api-contract';
|
||||
|
||||
type ComplexIdParams = { complexId: string }
|
||||
type ComplexIdParams = { complexId: string };
|
||||
|
||||
export async function createAdminBookingHandler(c: AppContext) {
|
||||
const { complexId } = c.req.valid('param' as never) as ComplexIdParams
|
||||
const payload = c.req.valid('json' as never) as CreateAdminBookingInput
|
||||
const appUserId = c.get('appUserId')
|
||||
const { complexId } = c.req.valid('param' as never) as ComplexIdParams;
|
||||
const payload = c.req.valid('json' as never) as CreateAdminBookingInput;
|
||||
const appUserId = c.get('appUserId');
|
||||
|
||||
try {
|
||||
const booking = await createAdminBooking(appUserId, complexId, payload)
|
||||
return c.json(booking, 201)
|
||||
const booking = await createAdminBooking(appUserId, complexId, payload);
|
||||
return c.json(booking, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof AdminBookingServiceError) {
|
||||
return c.json({ message: error.message }, error.status)
|
||||
return c.json({ message: error.message }, error.status);
|
||||
}
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import type { ListAdminBookingsQuery } from '@repo/api-contract'
|
||||
import {
|
||||
AdminBookingServiceError,
|
||||
listAdminBookings,
|
||||
} from '@/modules/admin-booking/services/admin-booking.service'
|
||||
import type { AppContext } from '@/types/hono'
|
||||
} from '@/modules/admin-booking/services/admin-booking.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
import type { ListAdminBookingsQuery } from '@repo/api-contract';
|
||||
|
||||
type ComplexIdParams = { complexId: string }
|
||||
type ComplexIdParams = { complexId: string };
|
||||
|
||||
export async function listAdminBookingsHandler(c: AppContext) {
|
||||
const { complexId } = c.req.valid('param' as never) as ComplexIdParams
|
||||
const query = c.req.valid('query' as never) as ListAdminBookingsQuery
|
||||
const appUserId = c.get('appUserId')
|
||||
const { complexId } = c.req.valid('param' as never) as ComplexIdParams;
|
||||
const query = c.req.valid('query' as never) as ListAdminBookingsQuery;
|
||||
const appUserId = c.get('appUserId');
|
||||
|
||||
try {
|
||||
const response = await listAdminBookings(appUserId, complexId, query)
|
||||
return c.json(response)
|
||||
const response = await listAdminBookings(appUserId, complexId, query);
|
||||
return c.json(response);
|
||||
} catch (error) {
|
||||
if (error instanceof AdminBookingServiceError) {
|
||||
return c.json({ message: error.message }, error.status)
|
||||
return c.json({ message: error.message }, error.status);
|
||||
}
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import type { UpdateAdminBookingStatusInput } from '@repo/api-contract'
|
||||
import {
|
||||
AdminBookingServiceError,
|
||||
updateAdminBookingStatus,
|
||||
} from '@/modules/admin-booking/services/admin-booking.service'
|
||||
import type { AppContext } from '@/types/hono'
|
||||
} from '@/modules/admin-booking/services/admin-booking.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
import type { UpdateAdminBookingStatusInput } from '@repo/api-contract';
|
||||
|
||||
type BookingIdParams = { id: string }
|
||||
type BookingIdParams = { id: string };
|
||||
|
||||
export async function updateAdminBookingStatusHandler(c: AppContext) {
|
||||
const { id } = c.req.valid('param' as never) as BookingIdParams
|
||||
const payload = c.req.valid('json' as never) as UpdateAdminBookingStatusInput
|
||||
const appUserId = c.get('appUserId')
|
||||
const { id } = c.req.valid('param' as never) as BookingIdParams;
|
||||
const payload = c.req.valid('json' as never) as UpdateAdminBookingStatusInput;
|
||||
const appUserId = c.get('appUserId');
|
||||
|
||||
try {
|
||||
const booking = await updateAdminBookingStatus(appUserId, id, payload)
|
||||
return c.json(booking)
|
||||
const booking = await updateAdminBookingStatus(appUserId, id, payload);
|
||||
return c.json(booking);
|
||||
} catch (error) {
|
||||
if (error instanceof AdminBookingServiceError) {
|
||||
return c.json({ message: error.message }, error.status)
|
||||
return c.json({ message: error.message }, error.status);
|
||||
}
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { randomInt } from 'node:crypto';
|
||||
import { CourtBookingStatus } from '@/generated/prisma/enums';
|
||||
import { db } from '@/lib/prisma';
|
||||
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
|
||||
import type {
|
||||
AdminBooking,
|
||||
CreateAdminBookingInput,
|
||||
ListAdminBookingsQuery,
|
||||
UpdateAdminBookingStatusInput,
|
||||
} from '@repo/api-contract'
|
||||
import { randomInt } from 'node:crypto'
|
||||
import { v7 as uuidv7 } from 'uuid'
|
||||
import { CourtBookingStatus } from '@/generated/prisma/enums'
|
||||
import { db } from '@/lib/prisma'
|
||||
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service'
|
||||
} from '@repo/api-contract';
|
||||
import { v7 as uuidv7 } from 'uuid';
|
||||
|
||||
type Slot = {
|
||||
startTime: string
|
||||
endTime: string
|
||||
}
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
};
|
||||
|
||||
const DAY_OF_WEEK_BY_INDEX = [
|
||||
'SUNDAY',
|
||||
@@ -23,44 +23,44 @@ const DAY_OF_WEEK_BY_INDEX = [
|
||||
'THURSDAY',
|
||||
'FRIDAY',
|
||||
'SATURDAY',
|
||||
] as const
|
||||
const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
const BOOKING_CODE_LENGTH = 6
|
||||
] as const;
|
||||
const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
const BOOKING_CODE_LENGTH = 6;
|
||||
|
||||
export class AdminBookingServiceError extends Error {
|
||||
status: 400 | 403 | 404 | 409
|
||||
status: 400 | 403 | 404 | 409;
|
||||
|
||||
constructor(message: string, status: 400 | 403 | 404 | 409) {
|
||||
super(message)
|
||||
this.name = 'AdminBookingServiceError'
|
||||
this.status = status
|
||||
super(message);
|
||||
this.name = 'AdminBookingServiceError';
|
||||
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 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')}`
|
||||
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')}`;
|
||||
}
|
||||
|
||||
function parseIsoDate(date: string): Date {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date)
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
|
||||
|
||||
if (!match) {
|
||||
throw new AdminBookingServiceError('La fecha debe tener formato YYYY-MM-DD.', 400)
|
||||
throw new AdminBookingServiceError('La fecha debe tener formato YYYY-MM-DD.', 400);
|
||||
}
|
||||
|
||||
const year = Number(match[1])
|
||||
const month = Number(match[2])
|
||||
const day = Number(match[3])
|
||||
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))
|
||||
const bookingDate = new Date(Date.UTC(year, month - 1, day));
|
||||
|
||||
if (
|
||||
Number.isNaN(bookingDate.getTime()) ||
|
||||
@@ -68,48 +68,48 @@ function parseIsoDate(date: string): Date {
|
||||
bookingDate.getUTCMonth() + 1 !== month ||
|
||||
bookingDate.getUTCDate() !== day
|
||||
) {
|
||||
throw new AdminBookingServiceError('La fecha enviada no es valida.', 400)
|
||||
throw new AdminBookingServiceError('La fecha enviada no es valida.', 400);
|
||||
}
|
||||
|
||||
return bookingDate
|
||||
return bookingDate;
|
||||
}
|
||||
|
||||
function getDayOfWeek(date: Date) {
|
||||
const dayOfWeek = DAY_OF_WEEK_BY_INDEX[date.getUTCDay()]
|
||||
const dayOfWeek = DAY_OF_WEEK_BY_INDEX[date.getUTCDay()];
|
||||
|
||||
if (!dayOfWeek) {
|
||||
throw new AdminBookingServiceError('No se pudo resolver el dia de la semana.', 400)
|
||||
throw new AdminBookingServiceError('No se pudo resolver el dia de la semana.', 400);
|
||||
}
|
||||
|
||||
return dayOfWeek
|
||||
return dayOfWeek;
|
||||
}
|
||||
|
||||
function formatIsoDate(date: Date): string {
|
||||
return date.toISOString().slice(0, 10)
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function generateBookingCode(): string {
|
||||
let code = ''
|
||||
let code = '';
|
||||
|
||||
for (let index = 0; index < BOOKING_CODE_LENGTH; index += 1) {
|
||||
code += BOOKING_CODE_ALPHABET[randomInt(0, BOOKING_CODE_ALPHABET.length)]
|
||||
code += BOOKING_CODE_ALPHABET[randomInt(0, BOOKING_CODE_ALPHABET.length)];
|
||||
}
|
||||
|
||||
return code
|
||||
return code;
|
||||
}
|
||||
|
||||
function buildSlots(
|
||||
availability: Array<{
|
||||
startTime: string
|
||||
endTime: string
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}>,
|
||||
slotDurationMinutes: number,
|
||||
slotDurationMinutes: number
|
||||
): Slot[] {
|
||||
const slots: Slot[] = []
|
||||
const slots: Slot[] = [];
|
||||
|
||||
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);
|
||||
|
||||
for (
|
||||
let current = start;
|
||||
@@ -119,11 +119,11 @@ function buildSlots(
|
||||
slots.push({
|
||||
startTime: minutesToTime(current),
|
||||
endTime: minutesToTime(current + slotDurationMinutes),
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return slots
|
||||
return slots;
|
||||
}
|
||||
|
||||
async function ensureComplexAccess(complexId: string, appUserId: string) {
|
||||
@@ -147,39 +147,39 @@ async function ensureComplexAccess(complexId: string, appUserId: string) {
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
if (!complexUser) {
|
||||
throw new AdminBookingServiceError('No tienes permisos para administrar este complejo.', 403)
|
||||
throw new AdminBookingServiceError('No tienes permisos para administrar este complejo.', 403);
|
||||
}
|
||||
|
||||
return complexUser.complex
|
||||
return complexUser.complex;
|
||||
}
|
||||
|
||||
function mapBookingResponse(booking: {
|
||||
id: string
|
||||
bookingCode: string
|
||||
bookingDate: Date
|
||||
startTime: string
|
||||
endTime: string
|
||||
customerName: string
|
||||
customerPhone: string
|
||||
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED'
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
id: string;
|
||||
bookingCode: string;
|
||||
bookingDate: Date;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
customerName: string;
|
||||
customerPhone: string;
|
||||
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED';
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
court: {
|
||||
id: string
|
||||
name: string
|
||||
id: string;
|
||||
name: string;
|
||||
complex: {
|
||||
id: string
|
||||
complexName: string
|
||||
}
|
||||
id: string;
|
||||
complexName: string;
|
||||
};
|
||||
sport: {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
}
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
};
|
||||
}): AdminBooking {
|
||||
return {
|
||||
id: booking.id,
|
||||
@@ -201,16 +201,16 @@ function mapBookingResponse(booking: {
|
||||
status: booking.status,
|
||||
createdAt: booking.createdAt.toISOString(),
|
||||
updatedAt: booking.updatedAt.toISOString(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function listAdminBookings(
|
||||
appUserId: string,
|
||||
complexId: string,
|
||||
query: ListAdminBookingsQuery,
|
||||
query: ListAdminBookingsQuery
|
||||
) {
|
||||
await ensureComplexAccess(complexId, appUserId)
|
||||
const fromDate = parseIsoDate(query.fromDate)
|
||||
await ensureComplexAccess(complexId, appUserId);
|
||||
const fromDate = parseIsoDate(query.fromDate);
|
||||
|
||||
const bookings = await db.courtBooking.findMany({
|
||||
where: {
|
||||
@@ -243,21 +243,21 @@ export async function listAdminBookings(
|
||||
},
|
||||
},
|
||||
orderBy: [{ bookingDate: 'asc' }, { startTime: 'asc' }, { createdAt: 'asc' }],
|
||||
})
|
||||
});
|
||||
|
||||
return {
|
||||
bookings: bookings.map((booking) => mapBookingResponse(booking)),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function createAdminBooking(
|
||||
appUserId: string,
|
||||
complexId: string,
|
||||
input: CreateAdminBookingInput,
|
||||
input: CreateAdminBookingInput
|
||||
) {
|
||||
const complex = await ensureComplexAccess(complexId, appUserId)
|
||||
const bookingDate = parseIsoDate(input.date)
|
||||
const dayOfWeek = getDayOfWeek(bookingDate)
|
||||
const complex = await ensureComplexAccess(complexId, appUserId);
|
||||
const bookingDate = parseIsoDate(input.date);
|
||||
const dayOfWeek = getDayOfWeek(bookingDate);
|
||||
|
||||
const court = await db.court.findFirst({
|
||||
where: {
|
||||
@@ -281,36 +281,36 @@ export async function createAdminBooking(
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
if (!court) {
|
||||
throw new AdminBookingServiceError('La cancha seleccionada no existe en el complejo.', 404)
|
||||
throw new AdminBookingServiceError('La cancha seleccionada no existe en el complejo.', 404);
|
||||
}
|
||||
|
||||
const validSlots = buildSlots(court.availabilities, court.slotDurationMinutes)
|
||||
const selectedStartMinutes = toMinutes(input.startTime)
|
||||
const selectedEndMinutes = selectedStartMinutes + court.slotDurationMinutes
|
||||
const validSlots = buildSlots(court.availabilities, court.slotDurationMinutes);
|
||||
const selectedStartMinutes = toMinutes(input.startTime);
|
||||
const selectedEndMinutes = selectedStartMinutes + court.slotDurationMinutes;
|
||||
const selectedSlot = {
|
||||
startTime: input.startTime,
|
||||
endTime: minutesToTime(selectedEndMinutes),
|
||||
}
|
||||
};
|
||||
|
||||
const slotExists = validSlots.some(
|
||||
(slot) => slot.startTime === selectedSlot.startTime && slot.endTime === selectedSlot.endTime,
|
||||
)
|
||||
(slot) => slot.startTime === selectedSlot.startTime && slot.endTime === selectedSlot.endTime
|
||||
);
|
||||
|
||||
if (!slotExists) {
|
||||
throw new AdminBookingServiceError(
|
||||
'El horario seleccionado no esta disponible para esa cancha.',
|
||||
409,
|
||||
)
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
try {
|
||||
const booking = await db.$transaction(async (tx) => {
|
||||
if (complex.plan) {
|
||||
const rules = parsePlanRules(complex.plan.rules)
|
||||
const rules = parsePlanRules(complex.plan.rules);
|
||||
const bookingsForDate = await tx.courtBooking.count({
|
||||
where: {
|
||||
bookingDate,
|
||||
@@ -319,25 +319,25 @@ export async function createAdminBooking(
|
||||
complexId,
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const courtsCount = await tx.court.count({
|
||||
where: {
|
||||
complexId,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const violations = evaluatePlanUsage(rules, {
|
||||
courtsCount,
|
||||
bookingsToday: bookingsForDate,
|
||||
})
|
||||
});
|
||||
|
||||
const maxBookingsViolation = violations.find(
|
||||
(violation) => violation.code === 'MAX_BOOKINGS_PER_DAY_REACHED',
|
||||
)
|
||||
(violation) => violation.code === 'MAX_BOOKINGS_PER_DAY_REACHED'
|
||||
);
|
||||
|
||||
if (maxBookingsViolation) {
|
||||
throw new AdminBookingServiceError(maxBookingsViolation.message, 409)
|
||||
throw new AdminBookingServiceError(maxBookingsViolation.message, 409);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,10 +353,10 @@ export async function createAdminBooking(
|
||||
gt: selectedSlot.startTime,
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
if (overlappingBooking) {
|
||||
throw new AdminBookingServiceError('El horario seleccionado ya fue reservado.', 409)
|
||||
throw new AdminBookingServiceError('El horario seleccionado ya fue reservado.', 409);
|
||||
}
|
||||
|
||||
return tx.courtBooking.create({
|
||||
@@ -392,51 +392,51 @@ export async function createAdminBooking(
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
return mapBookingResponse(booking)
|
||||
return mapBookingResponse(booking);
|
||||
} catch (error) {
|
||||
if (error instanceof AdminBookingServiceError) {
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
|
||||
const prismaError = error as {
|
||||
code?: string
|
||||
code?: string;
|
||||
meta?: {
|
||||
target?: string[] | string
|
||||
}
|
||||
}
|
||||
target?: string[] | string;
|
||||
};
|
||||
};
|
||||
|
||||
if (prismaError.code === 'P2002') {
|
||||
const targets = Array.isArray(prismaError.meta?.target)
|
||||
? prismaError.meta?.target
|
||||
: [prismaError.meta?.target]
|
||||
: [prismaError.meta?.target];
|
||||
const isBookingCodeCollision = targets.some((target) =>
|
||||
String(target).includes('booking_code'),
|
||||
)
|
||||
String(target).includes('booking_code')
|
||||
);
|
||||
|
||||
if (isBookingCodeCollision) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new AdminBookingServiceError('El horario seleccionado ya fue reservado.', 409)
|
||||
throw new AdminBookingServiceError('El horario seleccionado ya fue reservado.', 409);
|
||||
}
|
||||
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw new AdminBookingServiceError(
|
||||
'No se pudo generar un codigo de reserva unico. Intenta nuevamente.',
|
||||
409,
|
||||
)
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateAdminBookingStatus(
|
||||
appUserId: string,
|
||||
bookingId: string,
|
||||
input: UpdateAdminBookingStatusInput,
|
||||
input: UpdateAdminBookingStatusInput
|
||||
) {
|
||||
const booking = await db.courtBooking.findFirst({
|
||||
where: {
|
||||
@@ -472,33 +472,31 @@ export async function updateAdminBookingStatus(
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
throw new AdminBookingServiceError('Reserva no encontrada.', 404)
|
||||
throw new AdminBookingServiceError('Reserva no encontrada.', 404);
|
||||
}
|
||||
|
||||
if (input.status === 'CANCELLED' && booking.status !== 'CONFIRMED') {
|
||||
throw new AdminBookingServiceError(
|
||||
'Solo se pueden cancelar reservas en estado confirmada.',
|
||||
409,
|
||||
)
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
if (input.status === 'COMPLETED' && booking.status !== 'CONFIRMED') {
|
||||
throw new AdminBookingServiceError(
|
||||
'Solo se pueden marcar como cumplidas las reservas confirmadas.',
|
||||
409,
|
||||
)
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await db.courtBooking.update({
|
||||
where: { id: booking.id },
|
||||
data: {
|
||||
status:
|
||||
input.status === 'COMPLETED'
|
||||
? CourtBookingStatus.COMPLETED
|
||||
: CourtBookingStatus.CANCELLED,
|
||||
input.status === 'COMPLETED' ? CourtBookingStatus.COMPLETED : CourtBookingStatus.CANCELLED,
|
||||
},
|
||||
include: {
|
||||
court: {
|
||||
@@ -521,7 +519,7 @@ export async function updateAdminBookingStatus(
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
return mapBookingResponse(updated)
|
||||
return mapBookingResponse(updated);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,33 @@
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
import { createComplexSchema, updateComplexSchema } from '@repo/api-contract'
|
||||
import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import { requireAuth } from '@/middlewares/require-auth.middleware'
|
||||
import { createComplexHandler } from '@/modules/complex/handlers/create-complex.handler'
|
||||
import { getComplexByIdHandler } from '@/modules/complex/handlers/get-complex-by-id.handler'
|
||||
import { getComplexBySlugHandler } from '@/modules/complex/handlers/get-complex-by-slug.handler'
|
||||
import { listMyComplexesHandler } from '@/modules/complex/handlers/list-my-complexes.handler'
|
||||
import { updateComplexHandler } from '@/modules/complex/handlers/update-complex.handler'
|
||||
import type { AppEnv } from '@/types/hono'
|
||||
import { requireAuth } from '@/middlewares/require-auth.middleware';
|
||||
import { createComplexHandler } from '@/modules/complex/handlers/create-complex.handler';
|
||||
import { getComplexByIdHandler } from '@/modules/complex/handlers/get-complex-by-id.handler';
|
||||
import { getComplexBySlugHandler } from '@/modules/complex/handlers/get-complex-by-slug.handler';
|
||||
import { listMyComplexesHandler } from '@/modules/complex/handlers/list-my-complexes.handler';
|
||||
import { updateComplexHandler } from '@/modules/complex/handlers/update-complex.handler';
|
||||
import type { AppEnv } from '@/types/hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { createComplexSchema, updateComplexSchema } from '@repo/api-contract';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const complexRoutes = new Hono<AppEnv>()
|
||||
const complexIdParamsSchema = z.object({ id: z.uuid() })
|
||||
const complexSlugParamsSchema = z.object({ slug: z.string().trim().min(1) })
|
||||
export const complexRoutes = new Hono<AppEnv>();
|
||||
const complexIdParamsSchema = z.object({ id: z.uuid() });
|
||||
const complexSlugParamsSchema = z.object({ slug: z.string().trim().min(1) });
|
||||
|
||||
complexRoutes.use('*', requireAuth)
|
||||
complexRoutes.use('*', requireAuth);
|
||||
|
||||
complexRoutes.post('/', zValidator('json', createComplexSchema), createComplexHandler)
|
||||
complexRoutes.get('/mine', listMyComplexesHandler)
|
||||
complexRoutes.post('/', zValidator('json', createComplexSchema), createComplexHandler);
|
||||
complexRoutes.get('/mine', listMyComplexesHandler);
|
||||
complexRoutes.get(
|
||||
'/slug/:slug',
|
||||
zValidator('param', complexSlugParamsSchema),
|
||||
getComplexBySlugHandler,
|
||||
)
|
||||
getComplexBySlugHandler
|
||||
);
|
||||
|
||||
complexRoutes.get(
|
||||
'/:id',
|
||||
zValidator('param', complexIdParamsSchema),
|
||||
getComplexByIdHandler,
|
||||
)
|
||||
complexRoutes.get('/:id', zValidator('param', complexIdParamsSchema), getComplexByIdHandler);
|
||||
complexRoutes.patch(
|
||||
'/:id',
|
||||
zValidator('param', complexIdParamsSchema),
|
||||
zValidator('json', updateComplexSchema),
|
||||
updateComplexHandler,
|
||||
)
|
||||
updateComplexHandler
|
||||
);
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { CreateComplexInput } from '@repo/api-contract'
|
||||
import type { AppContext } from '@/types/hono'
|
||||
import { createComplex } from '@/modules/complex/services/complex.service'
|
||||
import { createComplex } from '@/modules/complex/services/complex.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
import type { CreateComplexInput } from '@repo/api-contract';
|
||||
|
||||
export async function createComplexHandler(c: AppContext) {
|
||||
const payload = c.req.valid('json' as never) as CreateComplexInput
|
||||
const payload = c.req.valid('json' as never) as CreateComplexInput;
|
||||
|
||||
const authUser = c.get('authUser')
|
||||
const adminEmail = authUser.email
|
||||
const authUser = c.get('authUser');
|
||||
const adminEmail = authUser.email;
|
||||
|
||||
if (!adminEmail) {
|
||||
return c.json({ message: 'El usuario autenticado no tiene email.' }, 400)
|
||||
return c.json({ message: 'El usuario autenticado no tiene email.' }, 400);
|
||||
}
|
||||
|
||||
const complex = await createComplex({
|
||||
@@ -20,7 +20,7 @@ export async function createComplexHandler(c: AppContext) {
|
||||
city: payload.city,
|
||||
state: payload.state,
|
||||
country: payload.country,
|
||||
})
|
||||
});
|
||||
|
||||
return c.json(complex, 201)
|
||||
return c.json(complex, 201);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { AppContext } from '@/types/hono'
|
||||
import { getComplexById } from '@/modules/complex/services/complex.service'
|
||||
import { getComplexById } from '@/modules/complex/services/complex.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
|
||||
type ComplexIdParams = { id: string }
|
||||
type ComplexIdParams = { id: string };
|
||||
|
||||
export async function getComplexByIdHandler(c: AppContext) {
|
||||
const { id } = c.req.valid('param' as never) as ComplexIdParams
|
||||
const { id } = c.req.valid('param' as never) as ComplexIdParams;
|
||||
|
||||
const complex = await getComplexById(id)
|
||||
const complex = await getComplexById(id);
|
||||
|
||||
if (!complex) {
|
||||
return c.json({ message: 'Complejo no encontrado.' }, 404)
|
||||
return c.json({ message: 'Complejo no encontrado.' }, 404);
|
||||
}
|
||||
|
||||
return c.json(complex)
|
||||
return c.json(complex);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { AppContext } from '@/types/hono'
|
||||
import { getComplexBySlug } from '@/modules/complex/services/complex.service'
|
||||
import { getComplexBySlug } from '@/modules/complex/services/complex.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
|
||||
type ComplexSlugParams = { slug: string }
|
||||
type ComplexSlugParams = { slug: string };
|
||||
|
||||
export async function getComplexBySlugHandler(c: AppContext) {
|
||||
const { slug } = c.req.valid('param' as never) as ComplexSlugParams
|
||||
const { slug } = c.req.valid('param' as never) as ComplexSlugParams;
|
||||
|
||||
const complex = await getComplexBySlug(slug)
|
||||
const complex = await getComplexBySlug(slug);
|
||||
|
||||
if (!complex) {
|
||||
return c.json({ message: 'Complejo no encontrado.' }, 404)
|
||||
return c.json({ message: 'Complejo no encontrado.' }, 404);
|
||||
}
|
||||
|
||||
return c.json(complex)
|
||||
return c.json(complex);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { AppContext } from '@/types/hono'
|
||||
import { listMyComplexes } from '@/modules/complex/services/complex.service'
|
||||
import { listMyComplexes } from '@/modules/complex/services/complex.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
|
||||
export async function listMyComplexesHandler(c: AppContext) {
|
||||
const appUserId = c.get('appUserId')
|
||||
const complexes = await listMyComplexes(appUserId)
|
||||
return c.json(complexes)
|
||||
const appUserId = c.get('appUserId');
|
||||
const complexes = await listMyComplexes(appUserId);
|
||||
return c.json(complexes);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import type { UpdateComplexInput } from '@repo/api-contract'
|
||||
import { Prisma } from '@/generated/prisma/client'
|
||||
import type { AppContext } from '@/types/hono'
|
||||
import { getComplexById, updateComplex } from '@/modules/complex/services/complex.service'
|
||||
import { Prisma } from '@/generated/prisma/client';
|
||||
import { getComplexById, updateComplex } from '@/modules/complex/services/complex.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
import type { UpdateComplexInput } from '@repo/api-contract';
|
||||
|
||||
type ComplexIdParams = { id: string }
|
||||
type ComplexIdParams = { id: string };
|
||||
|
||||
export async function updateComplexHandler(c: AppContext) {
|
||||
const { id } = c.req.valid('param' as never) as ComplexIdParams
|
||||
const payload = c.req.valid('json' as never) as UpdateComplexInput
|
||||
const { id } = c.req.valid('param' as never) as ComplexIdParams;
|
||||
const payload = c.req.valid('json' as never) as UpdateComplexInput;
|
||||
|
||||
const existing = await getComplexById(id)
|
||||
const existing = await getComplexById(id);
|
||||
|
||||
if (!existing) {
|
||||
return c.json({ message: 'Complejo no encontrado.' }, 404)
|
||||
return c.json({ message: 'Complejo no encontrado.' }, 404);
|
||||
}
|
||||
|
||||
try {
|
||||
const complex = await updateComplex(id, payload)
|
||||
return c.json(complex)
|
||||
const complex = await updateComplex(id, payload);
|
||||
return c.json(complex);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
return c.json({ message: 'No se pudo actualizar el complejo.' }, 409)
|
||||
return c.json({ message: 'No se pudo actualizar el complejo.' }, 409);
|
||||
}
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { v7 as uuidv7 } from 'uuid'
|
||||
import { Prisma } from '@/generated/prisma/client'
|
||||
import { db } from '@/lib/prisma'
|
||||
import { Prisma } from '@/generated/prisma/client';
|
||||
import { db } from '@/lib/prisma';
|
||||
import { v7 as uuidv7 } from 'uuid';
|
||||
|
||||
export type CreateComplexInput = {
|
||||
complexName: string
|
||||
physicalAddress: string
|
||||
adminEmail: string
|
||||
planCode?: string
|
||||
city?: string
|
||||
state?: string
|
||||
country?: string
|
||||
}
|
||||
complexName: string;
|
||||
physicalAddress: string;
|
||||
adminEmail: string;
|
||||
planCode?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
};
|
||||
|
||||
export type UpdateComplexInput = {
|
||||
complexName?: string
|
||||
physicalAddress?: string | null
|
||||
complexSlug?: string
|
||||
adminEmail?: string
|
||||
planCode?: string | null
|
||||
city?: string | null
|
||||
state?: string | null
|
||||
country?: string | null
|
||||
}
|
||||
complexName?: string;
|
||||
physicalAddress?: string | null;
|
||||
complexSlug?: string;
|
||||
adminEmail?: string;
|
||||
planCode?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
country?: string | null;
|
||||
};
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
@@ -31,18 +31,15 @@ function slugify(value: string): string {
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/-+/g, '-');
|
||||
}
|
||||
|
||||
async function buildUniqueSlug(
|
||||
source: string,
|
||||
excludeComplexId?: string,
|
||||
): Promise<string> {
|
||||
const base = slugify(source)
|
||||
const fallback = base.length > 0 ? base : `complex-${uuidv7().slice(0, 8)}`
|
||||
async function buildUniqueSlug(source: string, excludeComplexId?: string): Promise<string> {
|
||||
const base = slugify(source);
|
||||
const fallback = base.length > 0 ? base : `complex-${uuidv7().slice(0, 8)}`;
|
||||
|
||||
let candidate = fallback
|
||||
let index = 1
|
||||
let candidate = fallback;
|
||||
let index = 1;
|
||||
|
||||
while (true) {
|
||||
const existing = await db.complex.findFirst({
|
||||
@@ -57,17 +54,17 @@ async function buildUniqueSlug(
|
||||
: {}),
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
});
|
||||
|
||||
if (!existing) return candidate
|
||||
if (!existing) return candidate;
|
||||
|
||||
index += 1
|
||||
candidate = `${fallback}-${index}`
|
||||
index += 1;
|
||||
candidate = `${fallback}-${index}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createComplex(input: CreateComplexInput) {
|
||||
const complexSlug = await buildUniqueSlug(input.complexName)
|
||||
const complexSlug = await buildUniqueSlug(input.complexName);
|
||||
|
||||
return db.complex.create({
|
||||
data: {
|
||||
@@ -81,19 +78,19 @@ export async function createComplex(input: CreateComplexInput) {
|
||||
adminEmail: input.adminEmail,
|
||||
planCode: input.planCode,
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export async function getComplexById(id: string) {
|
||||
return db.complex.findUnique({
|
||||
where: { id },
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export async function getComplexBySlug(slug: string) {
|
||||
return db.complex.findUnique({
|
||||
where: { complexSlug: slug },
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export async function listMyComplexes(appUserId: string) {
|
||||
@@ -105,49 +102,49 @@ export async function listMyComplexes(appUserId: string) {
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
return complexUsers.map((complexUser) => complexUser.complex)
|
||||
return complexUsers.map((complexUser) => complexUser.complex);
|
||||
}
|
||||
|
||||
export async function updateComplex(id: string, input: UpdateComplexInput) {
|
||||
const data: Record<string, unknown> = {}
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
if (input.complexName) {
|
||||
data.complexName = input.complexName
|
||||
data.complexSlug = await buildUniqueSlug(input.complexName, id)
|
||||
data.complexName = input.complexName;
|
||||
data.complexSlug = await buildUniqueSlug(input.complexName, id);
|
||||
}
|
||||
|
||||
if (input.complexSlug) {
|
||||
data.complexSlug = await buildUniqueSlug(input.complexSlug, id)
|
||||
data.complexSlug = await buildUniqueSlug(input.complexSlug, id);
|
||||
}
|
||||
|
||||
if (input.adminEmail) {
|
||||
data.adminEmail = input.adminEmail
|
||||
data.adminEmail = input.adminEmail;
|
||||
}
|
||||
|
||||
if (input.planCode !== undefined) {
|
||||
data.planCode = input.planCode
|
||||
data.planCode = input.planCode;
|
||||
}
|
||||
|
||||
if (input.physicalAddress !== undefined) {
|
||||
data.physicalAddress = input.physicalAddress?.trim() ?? null
|
||||
data.physicalAddress = input.physicalAddress?.trim() ?? null;
|
||||
}
|
||||
|
||||
if (input.city !== undefined) {
|
||||
data.city = input.city?.trim() ?? null
|
||||
data.city = input.city?.trim() ?? null;
|
||||
}
|
||||
|
||||
if (input.state !== undefined) {
|
||||
data.state = input.state?.trim() ?? null
|
||||
data.state = input.state?.trim() ?? null;
|
||||
}
|
||||
|
||||
if (input.country !== undefined) {
|
||||
data.country = input.country?.trim() ?? null
|
||||
data.country = input.country?.trim() ?? null;
|
||||
}
|
||||
|
||||
return db.complex.update({
|
||||
where: { id },
|
||||
data: data as Prisma.ComplexUncheckedUpdateInput,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
import { createCourtSchema, updateCourtSchema } from '@repo/api-contract'
|
||||
import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import { requireAuth } from '@/middlewares/require-auth.middleware'
|
||||
import { createCourtHandler } from '@/modules/court/handlers/create-court.handler'
|
||||
import { listCourtsByComplexHandler } from '@/modules/court/handlers/list-courts-by-complex.handler'
|
||||
import { updateCourtHandler } from '@/modules/court/handlers/update-court.handler'
|
||||
import type { AppEnv } from '@/types/hono'
|
||||
import { requireAuth } from '@/middlewares/require-auth.middleware';
|
||||
import { createCourtHandler } from '@/modules/court/handlers/create-court.handler';
|
||||
import { listCourtsByComplexHandler } from '@/modules/court/handlers/list-courts-by-complex.handler';
|
||||
import { updateCourtHandler } from '@/modules/court/handlers/update-court.handler';
|
||||
import type { AppEnv } from '@/types/hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { createCourtSchema, updateCourtSchema } from '@repo/api-contract';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const courtRoutes = new Hono<AppEnv>()
|
||||
const complexIdParamsSchema = z.object({ complexId: z.uuid() })
|
||||
const courtIdParamsSchema = z.object({ id: z.uuid() })
|
||||
export const courtRoutes = new Hono<AppEnv>();
|
||||
const complexIdParamsSchema = z.object({ complexId: z.uuid() });
|
||||
const courtIdParamsSchema = z.object({ id: z.uuid() });
|
||||
|
||||
courtRoutes.use('*', requireAuth)
|
||||
courtRoutes.use('*', requireAuth);
|
||||
|
||||
courtRoutes.get(
|
||||
'/complex/:complexId',
|
||||
zValidator('param', complexIdParamsSchema),
|
||||
listCourtsByComplexHandler,
|
||||
)
|
||||
listCourtsByComplexHandler
|
||||
);
|
||||
courtRoutes.post(
|
||||
'/complex/:complexId',
|
||||
zValidator('param', complexIdParamsSchema),
|
||||
zValidator('json', createCourtSchema),
|
||||
createCourtHandler,
|
||||
)
|
||||
createCourtHandler
|
||||
);
|
||||
courtRoutes.patch(
|
||||
'/:id',
|
||||
zValidator('param', courtIdParamsSchema),
|
||||
zValidator('json', updateCourtSchema),
|
||||
updateCourtHandler,
|
||||
)
|
||||
updateCourtHandler
|
||||
);
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import type { CreateCourtInput } from '@repo/api-contract'
|
||||
import { CourtServiceError, createCourt } from '@/modules/court/services/court.service'
|
||||
import type { AppContext } from '@/types/hono'
|
||||
import { CourtServiceError, createCourt } from '@/modules/court/services/court.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
import type { CreateCourtInput } from '@repo/api-contract';
|
||||
|
||||
type ComplexIdParams = { complexId: string }
|
||||
type ComplexIdParams = { complexId: string };
|
||||
|
||||
export async function createCourtHandler(c: AppContext) {
|
||||
const { complexId } = c.req.valid('param' as never) as ComplexIdParams
|
||||
const payload = c.req.valid('json' as never) as CreateCourtInput
|
||||
const appUserId = c.get('appUserId')
|
||||
const { complexId } = c.req.valid('param' as never) as ComplexIdParams;
|
||||
const payload = c.req.valid('json' as never) as CreateCourtInput;
|
||||
const appUserId = c.get('appUserId');
|
||||
|
||||
try {
|
||||
const court = await createCourt(appUserId, complexId, payload)
|
||||
return c.json(court, 201)
|
||||
const court = await createCourt(appUserId, complexId, payload);
|
||||
return c.json(court, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof CourtServiceError) {
|
||||
return c.json({ message: error.message }, error.status)
|
||||
return c.json({ message: error.message }, error.status);
|
||||
}
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { CourtServiceError, listCourtsByComplex } from '@/modules/court/services/court.service'
|
||||
import type { AppContext } from '@/types/hono'
|
||||
import { CourtServiceError, listCourtsByComplex } from '@/modules/court/services/court.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
|
||||
type ComplexIdParams = { complexId: string }
|
||||
type ComplexIdParams = { complexId: string };
|
||||
|
||||
export async function listCourtsByComplexHandler(c: AppContext) {
|
||||
const { complexId } = c.req.valid('param' as never) as ComplexIdParams
|
||||
const appUserId = c.get('appUserId')
|
||||
const { complexId } = c.req.valid('param' as never) as ComplexIdParams;
|
||||
const appUserId = c.get('appUserId');
|
||||
|
||||
try {
|
||||
const courts = await listCourtsByComplex(complexId, appUserId)
|
||||
return c.json(courts)
|
||||
const courts = await listCourtsByComplex(complexId, appUserId);
|
||||
return c.json(courts);
|
||||
} catch (error) {
|
||||
if (error instanceof CourtServiceError) {
|
||||
return c.json({ message: error.message }, error.status)
|
||||
return c.json({ message: error.message }, error.status);
|
||||
}
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import type { UpdateCourtInput } from '@repo/api-contract'
|
||||
import { CourtServiceError, updateCourt } from '@/modules/court/services/court.service'
|
||||
import type { AppContext } from '@/types/hono'
|
||||
import { CourtServiceError, updateCourt } from '@/modules/court/services/court.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
import type { UpdateCourtInput } from '@repo/api-contract';
|
||||
|
||||
type CourtIdParams = { id: string }
|
||||
type CourtIdParams = { id: string };
|
||||
|
||||
export async function updateCourtHandler(c: AppContext) {
|
||||
const { id } = c.req.valid('param' as never) as CourtIdParams
|
||||
const payload = c.req.valid('json' as never) as UpdateCourtInput
|
||||
const appUserId = c.get('appUserId')
|
||||
const { id } = c.req.valid('param' as never) as CourtIdParams;
|
||||
const payload = c.req.valid('json' as never) as UpdateCourtInput;
|
||||
const appUserId = c.get('appUserId');
|
||||
|
||||
try {
|
||||
const court = await updateCourt(appUserId, id, payload)
|
||||
return c.json(court)
|
||||
const court = await updateCourt(appUserId, id, payload);
|
||||
return c.json(court);
|
||||
} catch (error) {
|
||||
if (error instanceof CourtServiceError) {
|
||||
return c.json({ message: error.message }, error.status)
|
||||
return c.json({ message: error.message }, error.status);
|
||||
}
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { AppEnv } from "@/types/hono";
|
||||
import { Hono } from "hono";
|
||||
|
||||
export const healthCheckRoutes = new Hono<AppEnv>()
|
||||
import { AppEnv } from '@/types/hono';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
export const healthCheckRoutes = new Hono<AppEnv>();
|
||||
|
||||
healthCheckRoutes.get('/', (c) => {
|
||||
return c.json({
|
||||
status: 'healthy',
|
||||
timeStamp: new Date()
|
||||
}, 200)
|
||||
})
|
||||
return c.json(
|
||||
{
|
||||
status: 'healthy',
|
||||
timeStamp: new Date(),
|
||||
},
|
||||
200
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import type { OnboardingCompleteInput } from '@repo/api-contract'
|
||||
import type { AppContext } from '@/types/hono'
|
||||
import {
|
||||
OnboardingError,
|
||||
completeOnboarding,
|
||||
} from '@/modules/onboarding/services/onboarding.service'
|
||||
} from '@/modules/onboarding/services/onboarding.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
import type { OnboardingCompleteInput } from '@repo/api-contract';
|
||||
|
||||
export async function completeOnboardingHandler(c: AppContext) {
|
||||
const payload = c.req.valid('json' as never) as OnboardingCompleteInput
|
||||
const payload = c.req.valid('json' as never) as OnboardingCompleteInput;
|
||||
|
||||
try {
|
||||
const result = await completeOnboarding(payload)
|
||||
const result = await completeOnboarding(payload);
|
||||
return c.json({
|
||||
message: 'Onboarding completado correctamente.',
|
||||
...result,
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof OnboardingError) {
|
||||
return c.json({ message: error.message }, error.status)
|
||||
return c.json({ message: error.message }, error.status);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return c.json({ message: error.message }, 400)
|
||||
return c.json({ message: error.message }, 400);
|
||||
}
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import type { OnboardingResendOtpInput } from '@repo/api-contract'
|
||||
import type { AppContext } from '@/types/hono'
|
||||
import {
|
||||
OnboardingError,
|
||||
resendOnboardingOtp,
|
||||
} from '@/modules/onboarding/services/onboarding.service'
|
||||
} from '@/modules/onboarding/services/onboarding.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
import type { OnboardingResendOtpInput } from '@repo/api-contract';
|
||||
|
||||
export async function resendOtpHandler(c: AppContext) {
|
||||
const payload = c.req.valid('json' as never) as OnboardingResendOtpInput
|
||||
const payload = c.req.valid('json' as never) as OnboardingResendOtpInput;
|
||||
|
||||
try {
|
||||
const result = await resendOnboardingOtp(payload)
|
||||
return c.json(result)
|
||||
const result = await resendOnboardingOtp(payload);
|
||||
return c.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof OnboardingError) {
|
||||
return c.json({ message: error.message }, error.status)
|
||||
return c.json({ message: error.message }, error.status);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return c.json({ message: error.message }, 400)
|
||||
return c.json({ message: error.message }, 400);
|
||||
}
|
||||
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { OnboardingStartInput } from '@repo/api-contract'
|
||||
import type { AppContext } from '@/types/hono'
|
||||
import { startOnboarding } from '@/modules/onboarding/services/onboarding.service'
|
||||
import { startOnboarding } from '@/modules/onboarding/services/onboarding.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
import type { OnboardingStartInput } from '@repo/api-contract';
|
||||
|
||||
export async function startOnboardingHandler(c: AppContext) {
|
||||
const payload = c.req.valid('json' as never) as OnboardingStartInput
|
||||
const payload = c.req.valid('json' as never) as OnboardingStartInput;
|
||||
|
||||
const result = await startOnboarding(payload)
|
||||
return c.json(result, 202)
|
||||
const result = await startOnboarding(payload);
|
||||
return c.json(result, 202);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { OnboardingVerifyOtpInput } from '@repo/api-contract'
|
||||
import type { AppContext } from '@/types/hono'
|
||||
import { verifyOnboardingOtp } from '@/modules/onboarding/services/onboarding.service'
|
||||
import { verifyOnboardingOtp } from '@/modules/onboarding/services/onboarding.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
import type { OnboardingVerifyOtpInput } from '@repo/api-contract';
|
||||
|
||||
export async function verifyOtpHandler(c: AppContext) {
|
||||
const payload = c.req.valid('json' as never) as OnboardingVerifyOtpInput
|
||||
const result = await verifyOnboardingOtp(payload)
|
||||
const payload = c.req.valid('json' as never) as OnboardingVerifyOtpInput;
|
||||
const result = await verifyOnboardingOtp(payload);
|
||||
|
||||
return c.json(result)
|
||||
return c.json(result);
|
||||
}
|
||||
|
||||
@@ -1,39 +1,35 @@
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
import { completeOnboardingHandler } from '@/modules/onboarding/handlers/complete-onboarding.handler';
|
||||
import { resendOtpHandler } from '@/modules/onboarding/handlers/resend-otp.handler';
|
||||
import { startOnboardingHandler } from '@/modules/onboarding/handlers/start-onboarding.handler';
|
||||
import { verifyOtpHandler } from '@/modules/onboarding/handlers/verify-otp.handler';
|
||||
import type { AppEnv } from '@/types/hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import {
|
||||
onboardingCompleteSchema,
|
||||
onboardingResendOtpSchema,
|
||||
onboardingStartSchema,
|
||||
onboardingVerifyOtpSchema,
|
||||
} from '@repo/api-contract'
|
||||
import { Hono } from 'hono'
|
||||
import { completeOnboardingHandler } from '@/modules/onboarding/handlers/complete-onboarding.handler'
|
||||
import { resendOtpHandler } from '@/modules/onboarding/handlers/resend-otp.handler'
|
||||
import { startOnboardingHandler } from '@/modules/onboarding/handlers/start-onboarding.handler'
|
||||
import { verifyOtpHandler } from '@/modules/onboarding/handlers/verify-otp.handler'
|
||||
import type { AppEnv } from '@/types/hono'
|
||||
} from '@repo/api-contract';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
export const onboardingRoutes = new Hono<AppEnv>()
|
||||
export const onboardingRoutes = new Hono<AppEnv>();
|
||||
|
||||
onboardingRoutes.post(
|
||||
'/start',
|
||||
zValidator('json', onboardingStartSchema),
|
||||
startOnboardingHandler,
|
||||
)
|
||||
onboardingRoutes.post('/start', zValidator('json', onboardingStartSchema), startOnboardingHandler);
|
||||
|
||||
onboardingRoutes.post(
|
||||
'/verify-otp',
|
||||
zValidator('json', onboardingVerifyOtpSchema),
|
||||
verifyOtpHandler,
|
||||
)
|
||||
verifyOtpHandler
|
||||
);
|
||||
|
||||
onboardingRoutes.post(
|
||||
'/resend-otp',
|
||||
zValidator('json', onboardingResendOtpSchema),
|
||||
resendOtpHandler,
|
||||
)
|
||||
resendOtpHandler
|
||||
);
|
||||
|
||||
onboardingRoutes.post(
|
||||
'/complete',
|
||||
zValidator('json', onboardingCompleteSchema),
|
||||
completeOnboardingHandler,
|
||||
)
|
||||
completeOnboardingHandler
|
||||
);
|
||||
|
||||
@@ -1,86 +1,86 @@
|
||||
import { createHash, randomInt } from 'node:crypto'
|
||||
import { v7 as uuidv7 } from 'uuid'
|
||||
import type { User as SupabaseAuthUser } from '@supabase/supabase-js'
|
||||
import { createHash, randomInt } from 'node:crypto';
|
||||
import { sendMail } from '@/lib/mailer';
|
||||
import { db } from '@/lib/prisma';
|
||||
import { getSupabaseAdminClient } from '@/lib/supabase-admin';
|
||||
import type {
|
||||
OnboardingCompleteInput,
|
||||
OnboardingResendOtpInput,
|
||||
OnboardingStartInput,
|
||||
OnboardingVerifyOtpInput,
|
||||
} from '@repo/api-contract'
|
||||
import { db } from '@/lib/prisma'
|
||||
import { sendMail } from '@/lib/mailer'
|
||||
import { getSupabaseAdminClient } from '@/lib/supabase-admin'
|
||||
} from '@repo/api-contract';
|
||||
import type { User as SupabaseAuthUser } from '@supabase/supabase-js';
|
||||
import { v7 as uuidv7 } from 'uuid';
|
||||
|
||||
const OTP_LENGTH = 6
|
||||
const OTP_TTL_MINUTES = Number(Bun.env.ONBOARDING_OTP_TTL_MINUTES ?? 10)
|
||||
const OTP_MAX_ATTEMPTS = Number(Bun.env.ONBOARDING_OTP_MAX_ATTEMPTS ?? 5)
|
||||
const OTP_RESEND_COOLDOWN_SECONDS = Number(
|
||||
Bun.env.ONBOARDING_OTP_RESEND_COOLDOWN_SECONDS ?? 30,
|
||||
)
|
||||
const OTP_LENGTH = 6;
|
||||
const OTP_TTL_MINUTES = Number(Bun.env.ONBOARDING_OTP_TTL_MINUTES ?? 10);
|
||||
const OTP_MAX_ATTEMPTS = Number(Bun.env.ONBOARDING_OTP_MAX_ATTEMPTS ?? 5);
|
||||
const OTP_RESEND_COOLDOWN_SECONDS = Number(Bun.env.ONBOARDING_OTP_RESEND_COOLDOWN_SECONDS ?? 30);
|
||||
|
||||
type VerifyOtpResult = {
|
||||
message: string
|
||||
verified: boolean
|
||||
requestId: string
|
||||
email: string | null
|
||||
expiresAt: string | null
|
||||
remainingAttempts: number
|
||||
cooldownSeconds: number
|
||||
}
|
||||
message: string;
|
||||
verified: boolean;
|
||||
requestId: string;
|
||||
email: string | null;
|
||||
expiresAt: string | null;
|
||||
remainingAttempts: number;
|
||||
cooldownSeconds: number;
|
||||
};
|
||||
|
||||
type ResendOtpResult = {
|
||||
message: string
|
||||
requestId: string
|
||||
email: string
|
||||
expiresAt: string
|
||||
cooldownSeconds: number
|
||||
remainingAttempts: number
|
||||
}
|
||||
message: string;
|
||||
requestId: string;
|
||||
email: string;
|
||||
expiresAt: string;
|
||||
cooldownSeconds: number;
|
||||
remainingAttempts: number;
|
||||
};
|
||||
|
||||
type StartOnboardingResult = {
|
||||
message: string
|
||||
requestId: string
|
||||
email: string
|
||||
expiresAt: string
|
||||
cooldownSeconds: number
|
||||
}
|
||||
message: string;
|
||||
requestId: string;
|
||||
email: string;
|
||||
expiresAt: string;
|
||||
cooldownSeconds: number;
|
||||
};
|
||||
|
||||
export class OnboardingError extends Error {
|
||||
status: 400 | 404 | 409 | 429
|
||||
status: 400 | 404 | 409 | 429;
|
||||
|
||||
constructor(message: string, status: 400 | 404 | 409 | 429 = 400) {
|
||||
super(message)
|
||||
this.name = 'OnboardingError'
|
||||
this.status = status
|
||||
super(message);
|
||||
this.name = 'OnboardingError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function nowPlusMinutes(minutes: number): Date {
|
||||
const date = new Date()
|
||||
date.setMinutes(date.getMinutes() + minutes)
|
||||
return date
|
||||
const date = new Date();
|
||||
date.setMinutes(date.getMinutes() + minutes);
|
||||
return date;
|
||||
}
|
||||
|
||||
function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase()
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function createOtpCode(): string {
|
||||
return randomInt(0, 10 ** OTP_LENGTH).toString().padStart(OTP_LENGTH, '0')
|
||||
return randomInt(0, 10 ** OTP_LENGTH)
|
||||
.toString()
|
||||
.padStart(OTP_LENGTH, '0');
|
||||
}
|
||||
|
||||
function hashValue(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex')
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function getRemainingAttempts(otpAttempts: number): number {
|
||||
return Math.max(0, OTP_MAX_ATTEMPTS - otpAttempts)
|
||||
return Math.max(0, OTP_MAX_ATTEMPTS - otpAttempts);
|
||||
}
|
||||
|
||||
function getCooldownSeconds(otpLastSentAt: Date): number {
|
||||
const elapsedMs = Date.now() - otpLastSentAt.getTime()
|
||||
const remainingMs = OTP_RESEND_COOLDOWN_SECONDS * 1000 - elapsedMs
|
||||
return Math.max(0, Math.ceil(remainingMs / 1000))
|
||||
const elapsedMs = Date.now() - otpLastSentAt.getTime();
|
||||
const remainingMs = OTP_RESEND_COOLDOWN_SECONDS * 1000 - elapsedMs;
|
||||
return Math.max(0, Math.ceil(remainingMs / 1000));
|
||||
}
|
||||
|
||||
function slugify(value: string): string {
|
||||
@@ -91,28 +91,28 @@ function slugify(value: string): string {
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/-+/g, '-');
|
||||
}
|
||||
|
||||
async function buildUniqueComplexSlug(complexName: string): Promise<string> {
|
||||
const base = slugify(complexName)
|
||||
const fallback = base.length > 0 ? base : `complex-${uuidv7().slice(0, 8)}`
|
||||
const base = slugify(complexName);
|
||||
const fallback = base.length > 0 ? base : `complex-${uuidv7().slice(0, 8)}`;
|
||||
|
||||
let candidate = fallback
|
||||
let index = 1
|
||||
let candidate = fallback;
|
||||
let index = 1;
|
||||
|
||||
while (true) {
|
||||
const existing = await db.complex.findFirst({
|
||||
where: { complexSlug: candidate },
|
||||
select: { id: true },
|
||||
})
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return candidate
|
||||
return candidate;
|
||||
}
|
||||
|
||||
index += 1
|
||||
candidate = `${fallback}-${index}`
|
||||
index += 1;
|
||||
candidate = `${fallback}-${index}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,70 +122,61 @@ async function sendOtpEmail(email: string, otpCode: string) {
|
||||
subject: 'Codigo OTP para validar tu email',
|
||||
text: `Tu codigo OTP es ${otpCode}. Vence en ${OTP_TTL_MINUTES} minutos.`,
|
||||
html: `<p>Tu codigo OTP es <strong>${otpCode}</strong>.</p><p>Vence en ${OTP_TTL_MINUTES} minutos.</p>`,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
async function findSupabaseUserByEmail(
|
||||
email: string,
|
||||
): Promise<SupabaseAuthUser | null> {
|
||||
let page = 1
|
||||
const perPage = 100
|
||||
async function findSupabaseUserByEmail(email: string): Promise<SupabaseAuthUser | null> {
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
|
||||
while (page <= 10) {
|
||||
const { data, error } = await getSupabaseAdminClient().auth.admin.listUsers({
|
||||
page,
|
||||
perPage,
|
||||
})
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(`No se pudo consultar usuarios en Supabase: ${error.message}`)
|
||||
throw new Error(`No se pudo consultar usuarios en Supabase: ${error.message}`);
|
||||
}
|
||||
|
||||
const found = data.users.find(
|
||||
(user) => user.email?.toLowerCase() === email.toLowerCase(),
|
||||
)
|
||||
const found = data.users.find((user) => user.email?.toLowerCase() === email.toLowerCase());
|
||||
|
||||
if (found) {
|
||||
return found
|
||||
return found;
|
||||
}
|
||||
|
||||
if (data.users.length < perPage) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
page += 1
|
||||
page += 1;
|
||||
}
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensureSupabaseUser(params: {
|
||||
email: string
|
||||
fullName: string
|
||||
password: string
|
||||
email: string;
|
||||
fullName: string;
|
||||
password: string;
|
||||
}): Promise<string> {
|
||||
const existingUser = await findSupabaseUserByEmail(params.email)
|
||||
const existingUser = await findSupabaseUserByEmail(params.email);
|
||||
|
||||
if (existingUser) {
|
||||
const { error } = await getSupabaseAdminClient().auth.admin.updateUserById(
|
||||
existingUser.id,
|
||||
{
|
||||
password: params.password,
|
||||
email_confirm: true,
|
||||
user_metadata: {
|
||||
full_name: params.fullName,
|
||||
name: params.fullName,
|
||||
},
|
||||
const { error } = await getSupabaseAdminClient().auth.admin.updateUserById(existingUser.id, {
|
||||
password: params.password,
|
||||
email_confirm: true,
|
||||
user_metadata: {
|
||||
full_name: params.fullName,
|
||||
name: params.fullName,
|
||||
},
|
||||
)
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(
|
||||
`No se pudo actualizar usuario existente en Supabase: ${error.message}`,
|
||||
)
|
||||
throw new Error(`No se pudo actualizar usuario existente en Supabase: ${error.message}`);
|
||||
}
|
||||
|
||||
return existingUser.id
|
||||
return existingUser.id;
|
||||
}
|
||||
|
||||
const { data, error } = await getSupabaseAdminClient().auth.admin.createUser({
|
||||
@@ -196,24 +187,20 @@ async function ensureSupabaseUser(params: {
|
||||
full_name: params.fullName,
|
||||
name: params.fullName,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
if (error || !data.user) {
|
||||
throw new Error(
|
||||
`No se pudo crear usuario en Supabase: ${error?.message ?? 'unknown error'}`,
|
||||
)
|
||||
throw new Error(`No se pudo crear usuario en Supabase: ${error?.message ?? 'unknown error'}`);
|
||||
}
|
||||
|
||||
return data.user.id
|
||||
return data.user.id;
|
||||
}
|
||||
|
||||
export async function startOnboarding(
|
||||
input: OnboardingStartInput,
|
||||
): Promise<StartOnboardingResult> {
|
||||
const email = normalizeEmail(input.email)
|
||||
const otpCode = createOtpCode()
|
||||
const expiresAt = nowPlusMinutes(OTP_TTL_MINUTES)
|
||||
const now = new Date()
|
||||
export async function startOnboarding(input: OnboardingStartInput): Promise<StartOnboardingResult> {
|
||||
const email = normalizeEmail(input.email);
|
||||
const otpCode = createOtpCode();
|
||||
const expiresAt = nowPlusMinutes(OTP_TTL_MINUTES);
|
||||
const now = new Date();
|
||||
|
||||
const request = await db.onboardingRequest.create({
|
||||
data: {
|
||||
@@ -231,9 +218,9 @@ export async function startOnboarding(
|
||||
email: true,
|
||||
otpExpiresAt: true,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
await sendOtpEmail(email, otpCode)
|
||||
await sendOtpEmail(email, otpCode);
|
||||
|
||||
return {
|
||||
message: 'Te enviamos un codigo OTP para validar tu direccion.',
|
||||
@@ -241,15 +228,15 @@ export async function startOnboarding(
|
||||
email: request.email,
|
||||
expiresAt: request.otpExpiresAt.toISOString(),
|
||||
cooldownSeconds: OTP_RESEND_COOLDOWN_SECONDS,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyOnboardingOtp(
|
||||
input: OnboardingVerifyOtpInput,
|
||||
input: OnboardingVerifyOtpInput
|
||||
): Promise<VerifyOtpResult> {
|
||||
const request = await db.onboardingRequest.findUnique({
|
||||
where: { id: input.requestId },
|
||||
})
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
return {
|
||||
@@ -260,7 +247,7 @@ export async function verifyOnboardingOtp(
|
||||
expiresAt: null,
|
||||
remainingAttempts: 0,
|
||||
cooldownSeconds: 0,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (request.completedAt) {
|
||||
@@ -272,7 +259,7 @@ export async function verifyOnboardingOtp(
|
||||
expiresAt: request.otpExpiresAt.toISOString(),
|
||||
remainingAttempts: getRemainingAttempts(request.otpAttempts),
|
||||
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (request.otpExpiresAt < new Date()) {
|
||||
@@ -284,7 +271,7 @@ export async function verifyOnboardingOtp(
|
||||
expiresAt: request.otpExpiresAt.toISOString(),
|
||||
remainingAttempts: getRemainingAttempts(request.otpAttempts),
|
||||
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (request.otpAttempts >= OTP_MAX_ATTEMPTS) {
|
||||
@@ -296,10 +283,10 @@ export async function verifyOnboardingOtp(
|
||||
expiresAt: request.otpExpiresAt.toISOString(),
|
||||
remainingAttempts: 0,
|
||||
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const otpMatches = hashValue(input.otp) === request.otpHash
|
||||
const otpMatches = hashValue(input.otp) === request.otpHash;
|
||||
|
||||
if (!otpMatches) {
|
||||
const updated = await db.onboardingRequest.update({
|
||||
@@ -316,9 +303,9 @@ export async function verifyOnboardingOtp(
|
||||
otpExpiresAt: true,
|
||||
otpLastSentAt: true,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const remainingAttempts = getRemainingAttempts(updated.otpAttempts)
|
||||
const remainingAttempts = getRemainingAttempts(updated.otpAttempts);
|
||||
|
||||
return {
|
||||
message:
|
||||
@@ -331,14 +318,14 @@ export async function verifyOnboardingOtp(
|
||||
expiresAt: updated.otpExpiresAt.toISOString(),
|
||||
remainingAttempts,
|
||||
cooldownSeconds: getCooldownSeconds(updated.otpLastSentAt),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!request.emailVerifiedAt) {
|
||||
await db.onboardingRequest.update({
|
||||
where: { id: request.id },
|
||||
data: { emailVerifiedAt: new Date() },
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -349,39 +336,36 @@ export async function verifyOnboardingOtp(
|
||||
expiresAt: request.otpExpiresAt.toISOString(),
|
||||
remainingAttempts: getRemainingAttempts(request.otpAttempts),
|
||||
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function resendOnboardingOtp(
|
||||
input: OnboardingResendOtpInput,
|
||||
input: OnboardingResendOtpInput
|
||||
): Promise<ResendOtpResult> {
|
||||
const request = await db.onboardingRequest.findUnique({
|
||||
where: { id: input.requestId },
|
||||
})
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
throw new OnboardingError('Solicitud de onboarding invalida o expirada.', 404)
|
||||
throw new OnboardingError('Solicitud de onboarding invalida o expirada.', 404);
|
||||
}
|
||||
|
||||
if (request.completedAt) {
|
||||
throw new OnboardingError('Este onboarding ya fue completado.', 400)
|
||||
throw new OnboardingError('Este onboarding ya fue completado.', 400);
|
||||
}
|
||||
|
||||
if (request.emailVerifiedAt) {
|
||||
throw new OnboardingError('El email ya fue verificado.', 400)
|
||||
throw new OnboardingError('El email ya fue verificado.', 400);
|
||||
}
|
||||
|
||||
const cooldownSeconds = getCooldownSeconds(request.otpLastSentAt)
|
||||
const cooldownSeconds = getCooldownSeconds(request.otpLastSentAt);
|
||||
if (cooldownSeconds > 0) {
|
||||
throw new OnboardingError(
|
||||
`Debes esperar ${cooldownSeconds}s antes de reenviar el OTP.`,
|
||||
429,
|
||||
)
|
||||
throw new OnboardingError(`Debes esperar ${cooldownSeconds}s antes de reenviar el OTP.`, 429);
|
||||
}
|
||||
|
||||
const otpCode = createOtpCode()
|
||||
const expiresAt = nowPlusMinutes(OTP_TTL_MINUTES)
|
||||
const now = new Date()
|
||||
const otpCode = createOtpCode();
|
||||
const expiresAt = nowPlusMinutes(OTP_TTL_MINUTES);
|
||||
const now = new Date();
|
||||
|
||||
const updated = await db.onboardingRequest.update({
|
||||
where: { id: request.id },
|
||||
@@ -400,9 +384,9 @@ export async function resendOnboardingOtp(
|
||||
otpExpiresAt: true,
|
||||
otpAttempts: true,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
await sendOtpEmail(updated.email, otpCode)
|
||||
await sendOtpEmail(updated.email, otpCode);
|
||||
|
||||
return {
|
||||
message: 'Te enviamos un nuevo codigo OTP.',
|
||||
@@ -411,49 +395,46 @@ export async function resendOnboardingOtp(
|
||||
expiresAt: updated.otpExpiresAt.toISOString(),
|
||||
cooldownSeconds: OTP_RESEND_COOLDOWN_SECONDS,
|
||||
remainingAttempts: getRemainingAttempts(updated.otpAttempts),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function completeOnboarding(input: OnboardingCompleteInput) {
|
||||
const onboardingRequest = await db.onboardingRequest.findUnique({
|
||||
where: { id: input.onboardingRequestId },
|
||||
})
|
||||
});
|
||||
|
||||
if (!onboardingRequest) {
|
||||
throw new OnboardingError('Solicitud de onboarding invalida o expirada.', 400)
|
||||
throw new OnboardingError('Solicitud de onboarding invalida o expirada.', 400);
|
||||
}
|
||||
|
||||
if (onboardingRequest.otpExpiresAt < new Date()) {
|
||||
throw new OnboardingError(
|
||||
'La sesion de onboarding expiro. Solicita un nuevo OTP.',
|
||||
400,
|
||||
)
|
||||
throw new OnboardingError('La sesion de onboarding expiro. Solicita un nuevo OTP.', 400);
|
||||
}
|
||||
|
||||
if (!onboardingRequest.emailVerifiedAt) {
|
||||
throw new OnboardingError('Debes verificar el email antes de continuar.', 400)
|
||||
throw new OnboardingError('Debes verificar el email antes de continuar.', 400);
|
||||
}
|
||||
|
||||
if (onboardingRequest.completedAt) {
|
||||
throw new OnboardingError('Este onboarding ya fue completado.', 400)
|
||||
throw new OnboardingError('Este onboarding ya fue completado.', 400);
|
||||
}
|
||||
|
||||
const plan = await db.plan.findUnique({
|
||||
where: { code: input.planCode },
|
||||
select: { code: true },
|
||||
})
|
||||
});
|
||||
|
||||
if (!plan) {
|
||||
throw new OnboardingError('El plan seleccionado no existe.', 400)
|
||||
throw new OnboardingError('El plan seleccionado no existe.', 400);
|
||||
}
|
||||
|
||||
const supabaseUserId = await ensureSupabaseUser({
|
||||
email: onboardingRequest.email,
|
||||
fullName: onboardingRequest.fullName,
|
||||
password: input.password,
|
||||
})
|
||||
});
|
||||
|
||||
const complexSlug = await buildUniqueComplexSlug(input.complexName)
|
||||
const complexSlug = await buildUniqueComplexSlug(input.complexName);
|
||||
|
||||
const result = await db.$transaction(async (tx) => {
|
||||
const user = await tx.user.upsert({
|
||||
@@ -468,7 +449,7 @@ export async function completeOnboarding(input: OnboardingCompleteInput) {
|
||||
email: onboardingRequest.email,
|
||||
supabaseUserId,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const complex = await tx.complex.create({
|
||||
data: {
|
||||
@@ -482,7 +463,7 @@ export async function completeOnboarding(input: OnboardingCompleteInput) {
|
||||
adminEmail: onboardingRequest.email,
|
||||
planCode: input.planCode,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
await tx.complexUser.create({
|
||||
data: {
|
||||
@@ -490,24 +471,24 @@ export async function completeOnboarding(input: OnboardingCompleteInput) {
|
||||
userId: user.id,
|
||||
role: 'ADMIN',
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
await tx.onboardingRequest.update({
|
||||
where: { id: onboardingRequest.id },
|
||||
data: {
|
||||
completedAt: new Date(),
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
complexId: complex.id,
|
||||
complexSlug: complex.complexSlug,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
supabaseUserId,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AppContext } from '@/types/hono'
|
||||
import { db } from '@/lib/prisma'
|
||||
import { db } from '@/lib/prisma';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
|
||||
export async function listPlansHandler(c: AppContext) {
|
||||
const plans = await db.plan.findMany({
|
||||
@@ -11,13 +11,13 @@ export async function listPlansHandler(c: AppContext) {
|
||||
orderBy: {
|
||||
price: 'asc',
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
return c.json(
|
||||
plans.map((plan) => ({
|
||||
code: plan.code,
|
||||
name: plan.name,
|
||||
price: Number(plan.price),
|
||||
})),
|
||||
)
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono'
|
||||
import { listPlansHandler } from '@/modules/plan/handlers/list-plans.handler'
|
||||
import type { AppEnv } from '@/types/hono'
|
||||
import { listPlansHandler } from '@/modules/plan/handlers/list-plans.handler';
|
||||
import type { AppEnv } from '@/types/hono';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
export const planRoutes = new Hono<AppEnv>()
|
||||
export const planRoutes = new Hono<AppEnv>();
|
||||
|
||||
planRoutes.get('/', listPlansHandler)
|
||||
planRoutes.get('/', listPlansHandler);
|
||||
|
||||
@@ -1,44 +1,41 @@
|
||||
import type { PlanRules } from '@repo/api-contract'
|
||||
import { planRulesSchema } from '@repo/api-contract'
|
||||
import type { PlanRules } from '@repo/api-contract';
|
||||
import { planRulesSchema } from '@repo/api-contract';
|
||||
|
||||
export type PlanUsageSnapshot = {
|
||||
courtsCount: number
|
||||
bookingsToday: number
|
||||
activeUsersCount?: number
|
||||
}
|
||||
courtsCount: number;
|
||||
bookingsToday: number;
|
||||
activeUsersCount?: number;
|
||||
};
|
||||
|
||||
export type PlanViolationCode =
|
||||
| 'MAX_COURTS_REACHED'
|
||||
| 'MAX_BOOKINGS_PER_DAY_REACHED'
|
||||
| 'MAX_ACTIVE_USERS_REACHED'
|
||||
| 'MAX_ACTIVE_USERS_REACHED';
|
||||
|
||||
export type PlanViolation = {
|
||||
code: PlanViolationCode
|
||||
message: string
|
||||
}
|
||||
code: PlanViolationCode;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function parsePlanRules(input: unknown): PlanRules {
|
||||
return planRulesSchema.parse(input)
|
||||
return planRulesSchema.parse(input);
|
||||
}
|
||||
|
||||
export function evaluatePlanUsage(
|
||||
rules: PlanRules,
|
||||
usage: PlanUsageSnapshot,
|
||||
): PlanViolation[] {
|
||||
const violations: PlanViolation[] = []
|
||||
export function evaluatePlanUsage(rules: PlanRules, usage: PlanUsageSnapshot): PlanViolation[] {
|
||||
const violations: PlanViolation[] = [];
|
||||
|
||||
if (usage.courtsCount >= rules.limits.maxCourts) {
|
||||
violations.push({
|
||||
code: 'MAX_COURTS_REACHED',
|
||||
message: `El plan permite hasta ${rules.limits.maxCourts} canchas.`,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if (usage.bookingsToday >= rules.limits.maxBookingsPerDay) {
|
||||
violations.push({
|
||||
code: 'MAX_BOOKINGS_PER_DAY_REACHED',
|
||||
message: `El plan permite hasta ${rules.limits.maxBookingsPerDay} turnos por dia.`,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -49,15 +46,12 @@ export function evaluatePlanUsage(
|
||||
violations.push({
|
||||
code: 'MAX_ACTIVE_USERS_REACHED',
|
||||
message: `El plan permite hasta ${rules.limits.maxActiveUsers} usuarios activos.`,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
return violations
|
||||
return violations;
|
||||
}
|
||||
|
||||
export function isFeatureEnabled(
|
||||
rules: PlanRules,
|
||||
feature: keyof PlanRules['features'],
|
||||
): boolean {
|
||||
return rules.features[feature]
|
||||
export function isFeatureEnabled(rules: PlanRules, feature: keyof PlanRules['features']): boolean {
|
||||
return rules.features[feature];
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import type { CreatePublicBookingInput } from '@repo/api-contract'
|
||||
import {
|
||||
PublicBookingServiceError,
|
||||
createPublicBooking,
|
||||
} from '@/modules/public-booking/services/public-booking.service'
|
||||
import type { AppContext } from '@/types/hono'
|
||||
} from '@/modules/public-booking/services/public-booking.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
import type { CreatePublicBookingInput } from '@repo/api-contract';
|
||||
|
||||
type ComplexSlugParams = { complexSlug: string }
|
||||
type ComplexSlugParams = { complexSlug: string };
|
||||
|
||||
export async function createPublicBookingHandler(c: AppContext) {
|
||||
const { complexSlug } = c.req.valid('param' as never) as ComplexSlugParams
|
||||
const payload = c.req.valid('json' as never) as CreatePublicBookingInput
|
||||
const { complexSlug } = c.req.valid('param' as never) as ComplexSlugParams;
|
||||
const payload = c.req.valid('json' as never) as CreatePublicBookingInput;
|
||||
|
||||
try {
|
||||
const booking = await createPublicBooking(complexSlug, payload)
|
||||
return c.json(booking, 201)
|
||||
const booking = await createPublicBooking(complexSlug, payload);
|
||||
return c.json(booking, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof PublicBookingServiceError) {
|
||||
return c.json({ message: error.message }, error.status)
|
||||
return c.json({ message: error.message }, error.status);
|
||||
}
|
||||
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import {
|
||||
PublicBookingServiceError,
|
||||
getPublicBookingConfirmation,
|
||||
} from '@/modules/public-booking/services/public-booking.service'
|
||||
import type { AppContext } from '@/types/hono'
|
||||
} from '@/modules/public-booking/services/public-booking.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
|
||||
type BookingCodeParams = {
|
||||
complexSlug: string
|
||||
bookingCode: string
|
||||
}
|
||||
complexSlug: string;
|
||||
bookingCode: string;
|
||||
};
|
||||
|
||||
export async function getPublicBookingConfirmationHandler(c: AppContext) {
|
||||
const { complexSlug, bookingCode } = c.req.valid('param' as never) as BookingCodeParams
|
||||
const { complexSlug, bookingCode } = c.req.valid('param' as never) as BookingCodeParams;
|
||||
|
||||
try {
|
||||
const booking = await getPublicBookingConfirmation(complexSlug, bookingCode)
|
||||
return c.json(booking)
|
||||
const booking = await getPublicBookingConfirmation(complexSlug, bookingCode);
|
||||
return c.json(booking);
|
||||
} catch (error) {
|
||||
if (error instanceof PublicBookingServiceError) {
|
||||
return c.json({ message: error.message }, error.status)
|
||||
return c.json({ message: error.message }, error.status);
|
||||
}
|
||||
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import type { PublicAvailabilityQuery } from '@repo/api-contract'
|
||||
import {
|
||||
PublicBookingServiceError,
|
||||
listPublicAvailability,
|
||||
} from '@/modules/public-booking/services/public-booking.service'
|
||||
import type { AppContext } from '@/types/hono'
|
||||
} from '@/modules/public-booking/services/public-booking.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
import type { PublicAvailabilityQuery } from '@repo/api-contract';
|
||||
|
||||
type ComplexSlugParams = { complexSlug: string }
|
||||
type ComplexSlugParams = { complexSlug: string };
|
||||
|
||||
export async function listPublicAvailabilityHandler(c: AppContext) {
|
||||
const { complexSlug } = c.req.valid('param' as never) as ComplexSlugParams
|
||||
const query = c.req.valid('query' as never) as PublicAvailabilityQuery
|
||||
const { complexSlug } = c.req.valid('param' as never) as ComplexSlugParams;
|
||||
const query = c.req.valid('query' as never) as PublicAvailabilityQuery;
|
||||
|
||||
try {
|
||||
const availability = await listPublicAvailability(complexSlug, query)
|
||||
return c.json(availability)
|
||||
const availability = await listPublicAvailability(complexSlug, query);
|
||||
return c.json(availability);
|
||||
} catch (error) {
|
||||
if (error instanceof PublicBookingServiceError) {
|
||||
return c.json({ message: error.message }, error.status)
|
||||
return c.json({ message: error.message }, error.status);
|
||||
}
|
||||
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,35 @@
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
import {
|
||||
createPublicBookingSchema,
|
||||
publicAvailabilityQuerySchema,
|
||||
} from '@repo/api-contract'
|
||||
import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import { createPublicBookingHandler } from '@/modules/public-booking/handlers/create-public-booking.handler'
|
||||
import { getPublicBookingConfirmationHandler } from '@/modules/public-booking/handlers/get-public-booking-confirmation.handler'
|
||||
import { listPublicAvailabilityHandler } from '@/modules/public-booking/handlers/list-public-availability.handler'
|
||||
import type { AppEnv } from '@/types/hono'
|
||||
import { createPublicBookingHandler } from '@/modules/public-booking/handlers/create-public-booking.handler';
|
||||
import { getPublicBookingConfirmationHandler } from '@/modules/public-booking/handlers/get-public-booking-confirmation.handler';
|
||||
import { listPublicAvailabilityHandler } from '@/modules/public-booking/handlers/list-public-availability.handler';
|
||||
import type { AppEnv } from '@/types/hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { createPublicBookingSchema, publicAvailabilityQuerySchema } from '@repo/api-contract';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const publicBookingRoutes = new Hono<AppEnv>()
|
||||
const complexSlugParamsSchema = z.object({ complexSlug: z.string().trim().min(1) })
|
||||
export const publicBookingRoutes = new Hono<AppEnv>();
|
||||
const complexSlugParamsSchema = z.object({ complexSlug: z.string().trim().min(1) });
|
||||
const confirmationParamsSchema = z.object({
|
||||
complexSlug: z.string().trim().min(1),
|
||||
bookingCode: z.string().trim().min(6).max(8),
|
||||
})
|
||||
});
|
||||
|
||||
publicBookingRoutes.get(
|
||||
'/complex/:complexSlug/availability',
|
||||
zValidator('param', complexSlugParamsSchema),
|
||||
zValidator('query', publicAvailabilityQuerySchema),
|
||||
listPublicAvailabilityHandler,
|
||||
)
|
||||
listPublicAvailabilityHandler
|
||||
);
|
||||
|
||||
publicBookingRoutes.post(
|
||||
'/complex/:complexSlug',
|
||||
zValidator('param', complexSlugParamsSchema),
|
||||
zValidator('json', createPublicBookingSchema),
|
||||
createPublicBookingHandler,
|
||||
)
|
||||
createPublicBookingHandler
|
||||
);
|
||||
|
||||
publicBookingRoutes.get(
|
||||
'/complex/:complexSlug/confirmation/:bookingCode',
|
||||
zValidator('param', confirmationParamsSchema),
|
||||
getPublicBookingConfirmationHandler,
|
||||
)
|
||||
getPublicBookingConfirmationHandler
|
||||
);
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { randomInt } from 'node:crypto';
|
||||
import { db } from '@/lib/prisma';
|
||||
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
|
||||
import type {
|
||||
CreatePublicBookingInput,
|
||||
DayOfWeek,
|
||||
PublicAvailabilityQuery,
|
||||
} from '@repo/api-contract'
|
||||
import { randomInt } from 'node:crypto'
|
||||
import { v7 as uuidv7 } from 'uuid'
|
||||
import { db } from '@/lib/prisma'
|
||||
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service'
|
||||
} from '@repo/api-contract';
|
||||
import { v7 as uuidv7 } from 'uuid';
|
||||
|
||||
type Slot = {
|
||||
startTime: string
|
||||
endTime: string
|
||||
}
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
};
|
||||
|
||||
type ComplexWithPublicBookingData = Awaited<ReturnType<typeof getComplexWithBookingData>>
|
||||
type ComplexWithPublicBookingData = Awaited<ReturnType<typeof getComplexWithBookingData>>;
|
||||
|
||||
const DAY_OF_WEEK_BY_INDEX: DayOfWeek[] = [
|
||||
'SUNDAY',
|
||||
@@ -23,44 +23,44 @@ const DAY_OF_WEEK_BY_INDEX: DayOfWeek[] = [
|
||||
'THURSDAY',
|
||||
'FRIDAY',
|
||||
'SATURDAY',
|
||||
]
|
||||
const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
const BOOKING_CODE_LENGTH = 6
|
||||
];
|
||||
const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
const BOOKING_CODE_LENGTH = 6;
|
||||
|
||||
export class PublicBookingServiceError extends Error {
|
||||
status: 400 | 403 | 404 | 409
|
||||
status: 400 | 403 | 404 | 409;
|
||||
|
||||
constructor(message: string, status: 400 | 403 | 404 | 409) {
|
||||
super(message)
|
||||
this.name = 'PublicBookingServiceError'
|
||||
this.status = status
|
||||
super(message);
|
||||
this.name = 'PublicBookingServiceError';
|
||||
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 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')}`
|
||||
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')}`;
|
||||
}
|
||||
|
||||
function parseIsoDate(date: string): { bookingDate: Date; dayOfWeek: DayOfWeek } {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date)
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
|
||||
|
||||
if (!match) {
|
||||
throw new PublicBookingServiceError('La fecha debe tener formato YYYY-MM-DD.', 400)
|
||||
throw new PublicBookingServiceError('La fecha debe tener formato YYYY-MM-DD.', 400);
|
||||
}
|
||||
|
||||
const year = Number(match[1])
|
||||
const month = Number(match[2])
|
||||
const day = Number(match[3])
|
||||
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))
|
||||
const bookingDate = new Date(Date.UTC(year, month - 1, day));
|
||||
|
||||
if (
|
||||
Number.isNaN(bookingDate.getTime()) ||
|
||||
@@ -68,48 +68,48 @@ function parseIsoDate(date: string): { bookingDate: Date; dayOfWeek: DayOfWeek }
|
||||
bookingDate.getUTCMonth() + 1 !== month ||
|
||||
bookingDate.getUTCDate() !== day
|
||||
) {
|
||||
throw new PublicBookingServiceError('La fecha enviada no es valida.', 400)
|
||||
throw new PublicBookingServiceError('La fecha enviada no es valida.', 400);
|
||||
}
|
||||
|
||||
const dayOfWeek = DAY_OF_WEEK_BY_INDEX[bookingDate.getUTCDay()]
|
||||
const dayOfWeek = DAY_OF_WEEK_BY_INDEX[bookingDate.getUTCDay()];
|
||||
|
||||
if (!dayOfWeek) {
|
||||
throw new PublicBookingServiceError('No se pudo resolver el dia de la semana.', 400)
|
||||
throw new PublicBookingServiceError('No se pudo resolver el dia de la semana.', 400);
|
||||
}
|
||||
|
||||
return { bookingDate, dayOfWeek }
|
||||
return { bookingDate, dayOfWeek };
|
||||
}
|
||||
|
||||
function formatIsoDate(date: Date): string {
|
||||
return date.toISOString().slice(0, 10)
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function generateBookingCode(): string {
|
||||
let code = ''
|
||||
let code = '';
|
||||
|
||||
for (let index = 0; index < BOOKING_CODE_LENGTH; index += 1) {
|
||||
code += BOOKING_CODE_ALPHABET[randomInt(0, BOOKING_CODE_ALPHABET.length)]
|
||||
code += BOOKING_CODE_ALPHABET[randomInt(0, BOOKING_CODE_ALPHABET.length)];
|
||||
}
|
||||
|
||||
return code
|
||||
return code;
|
||||
}
|
||||
|
||||
function hasOverlap(slot: Slot, existing: Slot): boolean {
|
||||
return slot.startTime < existing.endTime && slot.endTime > existing.startTime
|
||||
return slot.startTime < existing.endTime && slot.endTime > existing.startTime;
|
||||
}
|
||||
|
||||
function buildSlots(
|
||||
availability: Array<{
|
||||
startTime: string
|
||||
endTime: string
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}>,
|
||||
slotDurationMinutes: number,
|
||||
slotDurationMinutes: number
|
||||
): Slot[] {
|
||||
const slots: Slot[] = []
|
||||
const slots: Slot[] = [];
|
||||
|
||||
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);
|
||||
|
||||
for (
|
||||
let current = start;
|
||||
@@ -119,19 +119,19 @@ function buildSlots(
|
||||
slots.push({
|
||||
startTime: minutesToTime(current),
|
||||
endTime: minutesToTime(current + slotDurationMinutes),
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return slots
|
||||
return slots;
|
||||
}
|
||||
|
||||
function resolveSports(data: ComplexWithPublicBookingData) {
|
||||
const map = new Map<string, { id: string; name: string; slug: string }>()
|
||||
const map = new Map<string, { id: string; name: string; slug: string }>();
|
||||
|
||||
for (const court of data.courts) {
|
||||
if (!court.sport.isActive) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!map.has(court.sport.id)) {
|
||||
@@ -139,41 +139,41 @@ function resolveSports(data: ComplexWithPublicBookingData) {
|
||||
id: court.sport.id,
|
||||
name: court.sport.name,
|
||||
slug: court.sport.slug,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name, 'es'))
|
||||
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name, 'es'));
|
||||
}
|
||||
|
||||
function validateSportSelection(
|
||||
availableSports: Array<{ id: string }>,
|
||||
sportId: string | undefined,
|
||||
sportId: string | undefined
|
||||
): { sportSelectionRequired: boolean; selectedSportId: string | undefined } {
|
||||
const sportSelectionRequired = availableSports.length > 1
|
||||
const sportSelectionRequired = availableSports.length > 1;
|
||||
|
||||
if (sportSelectionRequired && !sportId) {
|
||||
throw new PublicBookingServiceError(
|
||||
'Debes seleccionar un deporte para consultar disponibilidad.',
|
||||
400,
|
||||
)
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
if (sportId && !availableSports.some((sport) => sport.id === sportId)) {
|
||||
throw new PublicBookingServiceError('El deporte seleccionado no pertenece al complejo.', 400)
|
||||
throw new PublicBookingServiceError('El deporte seleccionado no pertenece al complejo.', 400);
|
||||
}
|
||||
|
||||
if (sportSelectionRequired) {
|
||||
return {
|
||||
sportSelectionRequired,
|
||||
selectedSportId: sportId,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
sportSelectionRequired,
|
||||
selectedSportId: sportId ?? availableSports[0]?.id,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function getComplexWithBookingData(complexSlug: string) {
|
||||
@@ -205,48 +205,48 @@ async function getComplexWithBookingData(complexSlug: string) {
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
if (!complex) {
|
||||
throw new PublicBookingServiceError('Complejo no encontrado.', 404)
|
||||
throw new PublicBookingServiceError('Complejo no encontrado.', 404);
|
||||
}
|
||||
|
||||
if (complex.plan) {
|
||||
const rules = parsePlanRules(complex.plan.rules)
|
||||
const rules = parsePlanRules(complex.plan.rules);
|
||||
|
||||
if (!rules.features.publicBookingPage) {
|
||||
throw new PublicBookingServiceError(
|
||||
'El complejo no tiene habilitada la reserva publica.',
|
||||
403,
|
||||
)
|
||||
403
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return complex
|
||||
return complex;
|
||||
}
|
||||
|
||||
function mapBookingResponse(input: {
|
||||
bookingId: string
|
||||
bookingCode: string
|
||||
bookingDate: Date
|
||||
createdAt: Date
|
||||
startTime: string
|
||||
endTime: string
|
||||
customerName: string
|
||||
customerPhone: string
|
||||
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED'
|
||||
bookingId: string;
|
||||
bookingCode: string;
|
||||
bookingDate: Date;
|
||||
createdAt: Date;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
customerName: string;
|
||||
customerPhone: string;
|
||||
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED';
|
||||
court: {
|
||||
id: string
|
||||
name: string
|
||||
complexId: string
|
||||
complexName: string
|
||||
complexSlug: string
|
||||
id: string;
|
||||
name: string;
|
||||
complexId: string;
|
||||
complexName: string;
|
||||
complexSlug: string;
|
||||
sport: {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
}
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
};
|
||||
}) {
|
||||
return {
|
||||
id: input.bookingId,
|
||||
@@ -264,11 +264,11 @@ function mapBookingResponse(input: {
|
||||
customerPhone: input.customerPhone,
|
||||
status: input.status,
|
||||
createdAt: input.createdAt.toISOString(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPublicBookingConfirmation(complexSlug: string, bookingCode: string) {
|
||||
const normalizedBookingCode = bookingCode.toUpperCase()
|
||||
const normalizedBookingCode = bookingCode.toUpperCase();
|
||||
const booking = await db.courtBooking.findFirst({
|
||||
where: {
|
||||
bookingCode: normalizedBookingCode,
|
||||
@@ -304,10 +304,10 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
throw new PublicBookingServiceError('Reserva no encontrada.', 404)
|
||||
throw new PublicBookingServiceError('Reserva no encontrada.', 404);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -325,39 +325,34 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
|
||||
},
|
||||
status: booking.status,
|
||||
createdAt: booking.createdAt.toISOString(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function listPublicAvailability(
|
||||
complexSlug: string,
|
||||
query: PublicAvailabilityQuery,
|
||||
) {
|
||||
const { bookingDate, dayOfWeek } = parseIsoDate(query.date)
|
||||
const complex = await getComplexWithBookingData(complexSlug)
|
||||
const sports = resolveSports(complex)
|
||||
const sportSelectionRequired = sports.length > 1
|
||||
export async function listPublicAvailability(complexSlug: string, query: PublicAvailabilityQuery) {
|
||||
const { bookingDate, dayOfWeek } = parseIsoDate(query.date);
|
||||
const complex = await getComplexWithBookingData(complexSlug);
|
||||
const sports = resolveSports(complex);
|
||||
const sportSelectionRequired = sports.length > 1;
|
||||
|
||||
if (query.sportId && !sports.some((sport) => sport.id === query.sportId)) {
|
||||
throw new PublicBookingServiceError('El deporte seleccionado no pertenece al complejo.', 400)
|
||||
throw new PublicBookingServiceError('El deporte seleccionado no pertenece al complejo.', 400);
|
||||
}
|
||||
|
||||
const selectedSportId = sportSelectionRequired
|
||||
? query.sportId
|
||||
: (query.sportId ?? sports[0]?.id)
|
||||
const selectedSportId = sportSelectionRequired ? query.sportId : (query.sportId ?? sports[0]?.id);
|
||||
|
||||
const candidateCourts = complex.courts.filter((court) => {
|
||||
if (!court.sport.isActive) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
if (selectedSportId) {
|
||||
return court.sportId === selectedSportId
|
||||
return court.sportId === selectedSportId;
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
return true;
|
||||
});
|
||||
|
||||
const courtIds = candidateCourts.map((court) => court.id)
|
||||
const courtIds = candidateCourts.map((court) => court.id);
|
||||
|
||||
const bookings =
|
||||
courtIds.length > 0
|
||||
@@ -375,31 +370,31 @@ export async function listPublicAvailability(
|
||||
endTime: true,
|
||||
},
|
||||
})
|
||||
: []
|
||||
: [];
|
||||
|
||||
const bookingsByCourt = new Map<string, Slot[]>()
|
||||
const bookingsByCourt = new Map<string, Slot[]>();
|
||||
|
||||
for (const booking of bookings) {
|
||||
const current = bookingsByCourt.get(booking.courtId) ?? []
|
||||
const current = bookingsByCourt.get(booking.courtId) ?? [];
|
||||
current.push({
|
||||
startTime: booking.startTime,
|
||||
endTime: booking.endTime,
|
||||
})
|
||||
bookingsByCourt.set(booking.courtId, current)
|
||||
});
|
||||
bookingsByCourt.set(booking.courtId, current);
|
||||
}
|
||||
|
||||
const courts = candidateCourts
|
||||
.map((court) => {
|
||||
const dayAvailability = court.availabilities.filter(
|
||||
(availability) => availability.dayOfWeek === dayOfWeek,
|
||||
)
|
||||
(availability) => availability.dayOfWeek === dayOfWeek
|
||||
);
|
||||
|
||||
const allSlots = buildSlots(dayAvailability, court.slotDurationMinutes)
|
||||
const occupiedSlots = bookingsByCourt.get(court.id) ?? []
|
||||
const allSlots = buildSlots(dayAvailability, court.slotDurationMinutes);
|
||||
const occupiedSlots = bookingsByCourt.get(court.id) ?? [];
|
||||
|
||||
const availableSlots = allSlots.filter(
|
||||
(slot) => !occupiedSlots.some((occupied) => hasOverlap(slot, occupied)),
|
||||
)
|
||||
(slot) => !occupiedSlots.some((occupied) => hasOverlap(slot, occupied))
|
||||
);
|
||||
|
||||
return {
|
||||
courtId: court.id,
|
||||
@@ -412,9 +407,9 @@ export async function listPublicAvailability(
|
||||
slotDurationMinutes: court.slotDurationMinutes,
|
||||
availabilityDay: dayOfWeek,
|
||||
availableSlots,
|
||||
}
|
||||
};
|
||||
})
|
||||
.filter((court) => court.availableSlots.length > 0)
|
||||
.filter((court) => court.availableSlots.length > 0);
|
||||
|
||||
return {
|
||||
complexId: complex.id,
|
||||
@@ -424,69 +419,66 @@ export async function listPublicAvailability(
|
||||
sportSelectionRequired,
|
||||
sports,
|
||||
courts,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function createPublicBooking(
|
||||
complexSlug: string,
|
||||
input: CreatePublicBookingInput,
|
||||
) {
|
||||
const { bookingDate, dayOfWeek } = parseIsoDate(input.date)
|
||||
const complex = await getComplexWithBookingData(complexSlug)
|
||||
const sports = resolveSports(complex)
|
||||
const { sportSelectionRequired } = validateSportSelection(sports, input.sportId)
|
||||
export async function createPublicBooking(complexSlug: string, input: CreatePublicBookingInput) {
|
||||
const { bookingDate, dayOfWeek } = parseIsoDate(input.date);
|
||||
const complex = await getComplexWithBookingData(complexSlug);
|
||||
const sports = resolveSports(complex);
|
||||
const { sportSelectionRequired } = validateSportSelection(sports, input.sportId);
|
||||
|
||||
const selectedCourt = complex.courts.find(
|
||||
(court) => court.id === input.courtId && court.sport.isActive,
|
||||
)
|
||||
(court) => court.id === input.courtId && court.sport.isActive
|
||||
);
|
||||
|
||||
if (!selectedCourt) {
|
||||
throw new PublicBookingServiceError('La cancha seleccionada no existe en el complejo.', 404)
|
||||
throw new PublicBookingServiceError('La cancha seleccionada no existe en el complejo.', 404);
|
||||
}
|
||||
|
||||
if (sportSelectionRequired && !input.sportId) {
|
||||
throw new PublicBookingServiceError(
|
||||
'Debes seleccionar un deporte para reservar en este complejo.',
|
||||
400,
|
||||
)
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
if (input.sportId && input.sportId !== selectedCourt.sportId) {
|
||||
throw new PublicBookingServiceError(
|
||||
'La cancha seleccionada no corresponde al deporte indicado.',
|
||||
400,
|
||||
)
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const dayAvailability = selectedCourt.availabilities.filter(
|
||||
(availability) => availability.dayOfWeek === dayOfWeek,
|
||||
)
|
||||
(availability) => availability.dayOfWeek === dayOfWeek
|
||||
);
|
||||
|
||||
const validSlots = buildSlots(dayAvailability, selectedCourt.slotDurationMinutes)
|
||||
const selectedStartMinutes = toMinutes(input.startTime)
|
||||
const selectedEndMinutes = selectedStartMinutes + selectedCourt.slotDurationMinutes
|
||||
const validSlots = buildSlots(dayAvailability, selectedCourt.slotDurationMinutes);
|
||||
const selectedStartMinutes = toMinutes(input.startTime);
|
||||
const selectedEndMinutes = selectedStartMinutes + selectedCourt.slotDurationMinutes;
|
||||
|
||||
const selectedSlot = {
|
||||
startTime: input.startTime,
|
||||
endTime: minutesToTime(selectedEndMinutes),
|
||||
}
|
||||
};
|
||||
|
||||
const slotExists = validSlots.some(
|
||||
(slot) => slot.startTime === selectedSlot.startTime && slot.endTime === selectedSlot.endTime,
|
||||
)
|
||||
(slot) => slot.startTime === selectedSlot.startTime && slot.endTime === selectedSlot.endTime
|
||||
);
|
||||
|
||||
if (!slotExists) {
|
||||
throw new PublicBookingServiceError(
|
||||
'El horario seleccionado no esta disponible para esa cancha.',
|
||||
409,
|
||||
)
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
try {
|
||||
const booking = await db.$transaction(async (tx) => {
|
||||
if (complex.plan) {
|
||||
const rules = parsePlanRules(complex.plan.rules)
|
||||
const rules = parsePlanRules(complex.plan.rules);
|
||||
const bookingsForDate = await tx.courtBooking.count({
|
||||
where: {
|
||||
bookingDate,
|
||||
@@ -495,19 +487,19 @@ export async function createPublicBooking(
|
||||
complexId: complex.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const violations = evaluatePlanUsage(rules, {
|
||||
courtsCount: complex.courts.length,
|
||||
bookingsToday: bookingsForDate,
|
||||
})
|
||||
});
|
||||
|
||||
const maxBookingsViolation = violations.find(
|
||||
(violation) => violation.code === 'MAX_BOOKINGS_PER_DAY_REACHED',
|
||||
)
|
||||
(violation) => violation.code === 'MAX_BOOKINGS_PER_DAY_REACHED'
|
||||
);
|
||||
|
||||
if (maxBookingsViolation) {
|
||||
throw new PublicBookingServiceError(maxBookingsViolation.message, 409)
|
||||
throw new PublicBookingServiceError(maxBookingsViolation.message, 409);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,10 +515,10 @@ export async function createPublicBooking(
|
||||
gt: selectedSlot.startTime,
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
if (overlappingBooking) {
|
||||
throw new PublicBookingServiceError('El horario seleccionado ya fue reservado.', 409)
|
||||
throw new PublicBookingServiceError('El horario seleccionado ya fue reservado.', 409);
|
||||
}
|
||||
|
||||
return tx.courtBooking.create({
|
||||
@@ -552,8 +544,8 @@ export async function createPublicBooking(
|
||||
customerPhone: true,
|
||||
status: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
return mapBookingResponse({
|
||||
bookingId: booking.id,
|
||||
@@ -577,40 +569,40 @@ export async function createPublicBooking(
|
||||
slug: selectedCourt.sport.slug,
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof PublicBookingServiceError) {
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
|
||||
const prismaError = error as {
|
||||
code?: string
|
||||
code?: string;
|
||||
meta?: {
|
||||
target?: string[] | string
|
||||
}
|
||||
}
|
||||
target?: string[] | string;
|
||||
};
|
||||
};
|
||||
|
||||
if (prismaError.code === 'P2002') {
|
||||
const targets = Array.isArray(prismaError.meta?.target)
|
||||
? prismaError.meta?.target
|
||||
: [prismaError.meta?.target]
|
||||
: [prismaError.meta?.target];
|
||||
const isBookingCodeCollision = targets.some((target) =>
|
||||
String(target).includes('booking_code'),
|
||||
)
|
||||
String(target).includes('booking_code')
|
||||
);
|
||||
|
||||
if (isBookingCodeCollision) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new PublicBookingServiceError('El horario seleccionado ya fue reservado.', 409)
|
||||
throw new PublicBookingServiceError('El horario seleccionado ya fue reservado.', 409);
|
||||
}
|
||||
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw new PublicBookingServiceError(
|
||||
'No se pudo generar un codigo de reserva unico. Intenta nuevamente.',
|
||||
409,
|
||||
)
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import type { CreateSportInput } from '@repo/api-contract'
|
||||
import { Prisma } from '@/generated/prisma/client'
|
||||
import type { AppContext } from '@/types/hono'
|
||||
import { createSport } from '@/modules/sport/services/sport.service'
|
||||
import { Prisma } from '@/generated/prisma/client';
|
||||
import { createSport } from '@/modules/sport/services/sport.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
import type { CreateSportInput } from '@repo/api-contract';
|
||||
|
||||
export async function createSportHandler(c: AppContext) {
|
||||
const payload = c.req.valid('json' as never) as CreateSportInput
|
||||
const payload = c.req.valid('json' as never) as CreateSportInput;
|
||||
|
||||
try {
|
||||
const sport = await createSport(payload)
|
||||
return c.json(sport, 201)
|
||||
const sport = await createSport(payload);
|
||||
return c.json(sport, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
return c.json({ message: 'No se pudo crear el deporte.' }, 409)
|
||||
return c.json({ message: 'No se pudo crear el deporte.' }, 409);
|
||||
}
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AppContext } from '@/types/hono'
|
||||
import { listSports } from '@/modules/sport/services/sport.service'
|
||||
import { listSports } from '@/modules/sport/services/sport.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
|
||||
export async function listSportsHandler(c: AppContext) {
|
||||
const sports = await listSports()
|
||||
return c.json(sports)
|
||||
const sports = await listSports();
|
||||
return c.json(sports);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import type { UpdateSportInput } from '@repo/api-contract'
|
||||
import { Prisma } from '@/generated/prisma/client'
|
||||
import type { AppContext } from '@/types/hono'
|
||||
import { getSportById, updateSport } from '@/modules/sport/services/sport.service'
|
||||
import { Prisma } from '@/generated/prisma/client';
|
||||
import { getSportById, updateSport } from '@/modules/sport/services/sport.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
import type { UpdateSportInput } from '@repo/api-contract';
|
||||
|
||||
type SportIdParams = { id: string }
|
||||
type SportIdParams = { id: string };
|
||||
|
||||
export async function updateSportHandler(c: AppContext) {
|
||||
const { id } = c.req.valid('param' as never) as SportIdParams
|
||||
const payload = c.req.valid('json' as never) as UpdateSportInput
|
||||
const { id } = c.req.valid('param' as never) as SportIdParams;
|
||||
const payload = c.req.valid('json' as never) as UpdateSportInput;
|
||||
|
||||
const existing = await getSportById(id)
|
||||
const existing = await getSportById(id);
|
||||
|
||||
if (!existing) {
|
||||
return c.json({ message: 'Deporte no encontrado.' }, 404)
|
||||
return c.json({ message: 'Deporte no encontrado.' }, 404);
|
||||
}
|
||||
|
||||
try {
|
||||
const sport = await updateSport(id, payload)
|
||||
return c.json(sport)
|
||||
const sport = await updateSport(id, payload);
|
||||
return c.json(sport);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
return c.json({ message: 'No se pudo actualizar el deporte.' }, 409)
|
||||
return c.json({ message: 'No se pudo actualizar el deporte.' }, 409);
|
||||
}
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { v7 as uuidv7 } from 'uuid'
|
||||
import { Prisma } from '@/generated/prisma/client'
|
||||
import { db } from '@/lib/prisma'
|
||||
import { Prisma } from '@/generated/prisma/client';
|
||||
import { db } from '@/lib/prisma';
|
||||
import { v7 as uuidv7 } from 'uuid';
|
||||
|
||||
export type CreateSportInput = {
|
||||
name: string
|
||||
}
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type UpdateSportInput = {
|
||||
name?: string
|
||||
isActive?: boolean
|
||||
}
|
||||
name?: string;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
@@ -19,18 +19,15 @@ function slugify(value: string): string {
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/-+/g, '-');
|
||||
}
|
||||
|
||||
async function buildUniqueSlug(
|
||||
source: string,
|
||||
excludeSportId?: string,
|
||||
): Promise<string> {
|
||||
const base = slugify(source)
|
||||
const fallback = base.length > 0 ? base : `sport-${uuidv7().slice(0, 8)}`
|
||||
async function buildUniqueSlug(source: string, excludeSportId?: string): Promise<string> {
|
||||
const base = slugify(source);
|
||||
const fallback = base.length > 0 ? base : `sport-${uuidv7().slice(0, 8)}`;
|
||||
|
||||
let candidate = fallback
|
||||
let index = 1
|
||||
let candidate = fallback;
|
||||
let index = 1;
|
||||
|
||||
while (true) {
|
||||
const existing = await db.sport.findFirst({
|
||||
@@ -45,23 +42,23 @@ async function buildUniqueSlug(
|
||||
: {}),
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
});
|
||||
|
||||
if (!existing) return candidate
|
||||
if (!existing) return candidate;
|
||||
|
||||
index += 1
|
||||
candidate = `${fallback}-${index}`
|
||||
index += 1;
|
||||
candidate = `${fallback}-${index}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSports() {
|
||||
return db.sport.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export async function createSport(input: CreateSportInput) {
|
||||
const slug = await buildUniqueSlug(input.name)
|
||||
const slug = await buildUniqueSlug(input.name);
|
||||
|
||||
return db.sport.create({
|
||||
data: {
|
||||
@@ -70,27 +67,27 @@ export async function createSport(input: CreateSportInput) {
|
||||
slug,
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSportById(id: string) {
|
||||
return db.sport.findUnique({ where: { id } })
|
||||
return db.sport.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
export async function updateSport(id: string, input: UpdateSportInput) {
|
||||
const data: Prisma.SportUncheckedUpdateInput = {}
|
||||
const data: Prisma.SportUncheckedUpdateInput = {};
|
||||
|
||||
if (input.name) {
|
||||
data.name = input.name
|
||||
data.slug = await buildUniqueSlug(input.name, id)
|
||||
data.name = input.name;
|
||||
data.slug = await buildUniqueSlug(input.name, id);
|
||||
}
|
||||
|
||||
if (input.isActive !== undefined) {
|
||||
data.isActive = input.isActive
|
||||
data.isActive = input.isActive;
|
||||
}
|
||||
|
||||
return db.sport.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,30 +1,25 @@
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
import { createSportSchema, updateSportSchema } from '@repo/api-contract'
|
||||
import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import { requireAuth } from '@/middlewares/require-auth.middleware'
|
||||
import { requireSuperAdmin } from '@/middlewares/require-super-admin.middleware'
|
||||
import { createSportHandler } from '@/modules/sport/handlers/create-sport.handler'
|
||||
import { listSportsHandler } from '@/modules/sport/handlers/list-sports.handler'
|
||||
import { updateSportHandler } from '@/modules/sport/handlers/update-sport.handler'
|
||||
import type { AppEnv } from '@/types/hono'
|
||||
import { requireAuth } from '@/middlewares/require-auth.middleware';
|
||||
import { requireSuperAdmin } from '@/middlewares/require-super-admin.middleware';
|
||||
import { createSportHandler } from '@/modules/sport/handlers/create-sport.handler';
|
||||
import { listSportsHandler } from '@/modules/sport/handlers/list-sports.handler';
|
||||
import { updateSportHandler } from '@/modules/sport/handlers/update-sport.handler';
|
||||
import type { AppEnv } from '@/types/hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { createSportSchema, updateSportSchema } from '@repo/api-contract';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const sportRoutes = new Hono<AppEnv>()
|
||||
const sportIdParamsSchema = z.object({ id: z.uuid() })
|
||||
export const sportRoutes = new Hono<AppEnv>();
|
||||
const sportIdParamsSchema = z.object({ id: z.uuid() });
|
||||
|
||||
sportRoutes.use('*', requireAuth)
|
||||
sportRoutes.use('*', requireAuth);
|
||||
|
||||
sportRoutes.get('/', listSportsHandler)
|
||||
sportRoutes.post(
|
||||
'/',
|
||||
requireSuperAdmin,
|
||||
zValidator('json', createSportSchema),
|
||||
createSportHandler,
|
||||
)
|
||||
sportRoutes.get('/', listSportsHandler);
|
||||
sportRoutes.post('/', requireSuperAdmin, zValidator('json', createSportSchema), createSportHandler);
|
||||
sportRoutes.patch(
|
||||
'/:id',
|
||||
requireSuperAdmin,
|
||||
zValidator('param', sportIdParamsSchema),
|
||||
zValidator('json', updateSportSchema),
|
||||
updateSportHandler,
|
||||
)
|
||||
updateSportHandler
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { AppContext } from '@/types/hono'
|
||||
import { getUserProfile } from '@/modules/user/services/user.service'
|
||||
import { getUserProfile } from '@/modules/user/services/user.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
|
||||
export function getUserProfileHandler(c: AppContext) {
|
||||
const authUser = c.get('authUser')
|
||||
const profile = getUserProfile(authUser)
|
||||
return c.json(profile)
|
||||
const authUser = c.get('authUser');
|
||||
const profile = getUserProfile(authUser);
|
||||
return c.json(profile);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import type { AuthUser } from '@/types/auth-user'
|
||||
import type { UserProfile } from '@repo/api-contract'
|
||||
import type { AuthUser } from '@/types/auth-user';
|
||||
import type { UserProfile } from '@repo/api-contract';
|
||||
|
||||
export function getUserProfile(user: AuthUser): UserProfile {
|
||||
const fullName =
|
||||
(typeof user.user_metadata?.full_name === 'string' &&
|
||||
user.user_metadata.full_name) ||
|
||||
(typeof user.user_metadata?.full_name === 'string' && user.user_metadata.full_name) ||
|
||||
(typeof user.user_metadata?.name === 'string' && user.user_metadata.name) ||
|
||||
(user.email ?? 'Usuario')
|
||||
(user.email ?? 'Usuario');
|
||||
|
||||
const role =
|
||||
(typeof user.app_metadata?.role === 'string' && user.app_metadata.role) ||
|
||||
'member'
|
||||
const role = (typeof user.app_metadata?.role === 'string' && user.app_metadata.role) || 'member';
|
||||
|
||||
const avatarUrl =
|
||||
(typeof user.user_metadata?.avatar_url === 'string' &&
|
||||
user.user_metadata.avatar_url) ||
|
||||
null
|
||||
(typeof user.user_metadata?.avatar_url === 'string' && user.user_metadata.avatar_url) || null;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
@@ -24,5 +19,5 @@ export function getUserProfile(user: AuthUser): UserProfile {
|
||||
role,
|
||||
avatarUrl,
|
||||
createdAt: user.created_at,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Hono } from 'hono'
|
||||
import { requireAuth } from '@/middlewares/require-auth.middleware'
|
||||
import type { AppEnv } from '@/types/hono'
|
||||
import { getUserProfileHandler } from '@/modules/user/handlers/get-user-profile.handler'
|
||||
import { requireAuth } from '@/middlewares/require-auth.middleware';
|
||||
import { getUserProfileHandler } from '@/modules/user/handlers/get-user-profile.handler';
|
||||
import type { AppEnv } from '@/types/hono';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
export const userRoutes = new Hono<AppEnv>()
|
||||
export const userRoutes = new Hono<AppEnv>();
|
||||
|
||||
userRoutes.use('*', requireAuth)
|
||||
userRoutes.get('/profile', getUserProfileHandler)
|
||||
userRoutes.use('*', requireAuth);
|
||||
userRoutes.get('/profile', getUserProfileHandler);
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { Hono } from 'hono'
|
||||
import { serveStatic } from 'hono/bun'
|
||||
import path from 'node:path'
|
||||
import { adminBookingRoutes } from '@/modules/admin-booking/admin-booking.routes'
|
||||
import { complexRoutes } from '@/modules/complex/complex.routes'
|
||||
import { courtRoutes } from '@/modules/court/court.routes'
|
||||
import { onboardingRoutes } from '@/modules/onboarding/onboarding.routes'
|
||||
import { planRoutes } from '@/modules/plan/plan.routes'
|
||||
import { publicBookingRoutes } from '@/modules/public-booking/public-booking.routes'
|
||||
import { sportRoutes } from '@/modules/sport/sport.routes'
|
||||
import { userRoutes } from '@/modules/user/user.routes'
|
||||
import type { AppEnv } from '@/types/hono'
|
||||
import { healthCheckRoutes } from './modules/health-check/health-check.routes'
|
||||
import path from 'node:path';
|
||||
import { adminBookingRoutes } from '@/modules/admin-booking/admin-booking.routes';
|
||||
import { complexRoutes } from '@/modules/complex/complex.routes';
|
||||
import { courtRoutes } from '@/modules/court/court.routes';
|
||||
import { onboardingRoutes } from '@/modules/onboarding/onboarding.routes';
|
||||
import { planRoutes } from '@/modules/plan/plan.routes';
|
||||
import { publicBookingRoutes } from '@/modules/public-booking/public-booking.routes';
|
||||
import { sportRoutes } from '@/modules/sport/sport.routes';
|
||||
import { userRoutes } from '@/modules/user/user.routes';
|
||||
import type { AppEnv } from '@/types/hono';
|
||||
import type { Hono } from 'hono';
|
||||
import { serveStatic } from 'hono/bun';
|
||||
import { healthCheckRoutes } from './modules/health-check/health-check.routes';
|
||||
|
||||
export function registerRoutes(app: Hono<AppEnv>) {
|
||||
app
|
||||
@@ -22,24 +22,24 @@ export function registerRoutes(app: Hono<AppEnv>) {
|
||||
.route('/api/courts', courtRoutes)
|
||||
.route('/api/public-bookings', publicBookingRoutes)
|
||||
.route('/api/admin-bookings', adminBookingRoutes)
|
||||
.route('/api/health', healthCheckRoutes)
|
||||
.route('/api/health', healthCheckRoutes);
|
||||
|
||||
app.use('*', serveStatic({ root: './public' }))
|
||||
app.use('*', serveStatic({ root: './public' }));
|
||||
app.get('*', async (c) => {
|
||||
if (c.req.path === '/api' || c.req.path.startsWith('/api/')) {
|
||||
return c.notFound()
|
||||
return c.notFound();
|
||||
}
|
||||
|
||||
// Keep real asset URLs as 404s if the file does not exist.
|
||||
if (path.extname(c.req.path)) {
|
||||
return c.notFound()
|
||||
return c.notFound();
|
||||
}
|
||||
|
||||
const indexFile = Bun.file('./public/index.html')
|
||||
const indexFile = Bun.file('./public/index.html');
|
||||
if (!(await indexFile.exists())) {
|
||||
return c.notFound()
|
||||
return c.notFound();
|
||||
}
|
||||
|
||||
return c.html(await indexFile.text())
|
||||
})
|
||||
return c.html(await indexFile.text());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import app from '@/index'
|
||||
import { logger } from '@/lib/logger'
|
||||
import app from '@/index';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
const port = Number(Bun.env.PORT ?? 3000)
|
||||
const hostname = Bun.env.HOST ?? '0.0.0.0'
|
||||
const port = Number(Bun.env.PORT ?? 3000);
|
||||
const hostname = Bun.env.HOST ?? '0.0.0.0';
|
||||
|
||||
const server = Bun.serve({
|
||||
hostname,
|
||||
port,
|
||||
fetch: app.fetch,
|
||||
})
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{ url: `http://${server.hostname}:${server.port}` },
|
||||
'backend_listening',
|
||||
)
|
||||
logger.info({ url: `http://${server.hostname}:${server.port}` }, 'backend_listening');
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import type { User } from '@supabase/supabase-js'
|
||||
import type { User } from '@supabase/supabase-js';
|
||||
|
||||
export type AuthUser = User
|
||||
export type AuthUser = User;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { Context } from 'hono'
|
||||
import type { AuthUser } from '@/types/auth-user'
|
||||
import type { AuthUser } from '@/types/auth-user';
|
||||
import type { Context } from 'hono';
|
||||
|
||||
export type AppVariables = {
|
||||
authUser: AuthUser
|
||||
appUserId: string
|
||||
}
|
||||
authUser: AuthUser;
|
||||
appUserId: string;
|
||||
};
|
||||
|
||||
export type AppEnv = {
|
||||
Variables: AppVariables
|
||||
}
|
||||
Variables: AppVariables;
|
||||
};
|
||||
|
||||
export type AppContext = Context<AppEnv>
|
||||
export type AppContext = Context<AppEnv>;
|
||||
|
||||
Reference in New Issue
Block a user