Initial commit
This commit is contained in:
11
.codex/environments/environment.toml
Normal file
11
.codex/environments/environment.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||||
|
version = 1
|
||||||
|
name = "monorepo"
|
||||||
|
|
||||||
|
[setup]
|
||||||
|
script = ""
|
||||||
|
|
||||||
|
[[actions]]
|
||||||
|
name = "Run"
|
||||||
|
icon = "run"
|
||||||
|
command = "bun dev"
|
||||||
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.git
|
||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/.turbo
|
||||||
|
**/.cache
|
||||||
|
**/.vite
|
||||||
|
**/.next
|
||||||
|
coverage
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
bun.lock
|
||||||
|
.DS_Store
|
||||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"js/ts.tsdk.path": "node_modules/typescript/lib"
|
||||||
|
}
|
||||||
184
README.md
Normal file
184
README.md
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
# Monorepo (Frontend + Backend + Contrato API)
|
||||||
|
|
||||||
|
Este repositorio contiene:
|
||||||
|
|
||||||
|
- `apps/frontend`: app web en React + Vite + TanStack Router/Query.
|
||||||
|
- `apps/backend`: API en Bun + Hono + Prisma + Supabase Auth.
|
||||||
|
- `packages/api-contract`: contratos compartidos (schemas/types/rutas) entre frontend y backend.
|
||||||
|
|
||||||
|
## Objetivo de este README
|
||||||
|
|
||||||
|
Dejarte productivo/a en pocos minutos para que puedas:
|
||||||
|
|
||||||
|
1. Levantar frontend y backend en local.
|
||||||
|
2. Configurar variables de entorno.
|
||||||
|
3. Ejecutar migraciones de base de datos.
|
||||||
|
4. Entender la estructura y el flujo de trabajo del proyecto.
|
||||||
|
|
||||||
|
## Stack técnico
|
||||||
|
|
||||||
|
- Runtime/package manager: `bun`
|
||||||
|
- Frontend: `React 19`, `Vite`, `TanStack Router`, `TanStack Query`
|
||||||
|
- Backend: `Bun`, `Hono`, `Prisma`, `PostgreSQL`
|
||||||
|
- Auth: `Supabase Auth` (token Bearer validado en backend)
|
||||||
|
- Validación de contratos: `zod` en `@repo/api-contract`
|
||||||
|
|
||||||
|
## Prerrequisitos
|
||||||
|
|
||||||
|
- `bun` instalado (versión reciente).
|
||||||
|
- `PostgreSQL` corriendo localmente (o accesible remotamente).
|
||||||
|
- Proyecto de `Supabase` para autenticación (URL + anon key).
|
||||||
|
|
||||||
|
## Setup rápido
|
||||||
|
|
||||||
|
1. Instalar dependencias desde la raíz:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Configurar variables de entorno del backend:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp apps/backend/.env.example apps/backend/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Configurar variables de entorno del frontend:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp apps/frontend/.env.example apps/frontend/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Revisar/editar valores en ambos `.env` según tu entorno.
|
||||||
|
|
||||||
|
## Variables de entorno
|
||||||
|
|
||||||
|
### Backend (`apps/backend/.env`)
|
||||||
|
|
||||||
|
Variables esperadas:
|
||||||
|
|
||||||
|
- `SUPABASE_URL`
|
||||||
|
- `SUPABASE_ANON_KEY`
|
||||||
|
- `CORS_ORIGIN` (por defecto local frontend)
|
||||||
|
- `LOG_LEVEL`
|
||||||
|
- `DATABASE_URL` (PostgreSQL)
|
||||||
|
|
||||||
|
Notas:
|
||||||
|
|
||||||
|
- El backend también acepta `VITE_SUPABASE_URL` y `VITE_SUPABASE_ANON_KEY` como fallback.
|
||||||
|
- Si falta `DATABASE_URL`, Prisma no puede inicializarse.
|
||||||
|
|
||||||
|
### Frontend (`apps/frontend/.env`)
|
||||||
|
|
||||||
|
Variables esperadas:
|
||||||
|
|
||||||
|
- `VITE_SUPABASE_URL`
|
||||||
|
- `VITE_SUPABASE_ANON_KEY`
|
||||||
|
- `VITE_API_BASE_URL` (por defecto: `http://localhost:3000`)
|
||||||
|
|
||||||
|
## Base de datos (Prisma)
|
||||||
|
|
||||||
|
Antes de arrancar backend por primera vez, corré migraciones:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun --filter backend prisma:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
Si cambiás el schema, regenerá cliente:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun --filter backend prisma:generate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ejecutar en desarrollo
|
||||||
|
|
||||||
|
Terminal 1 (backend):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun run dev:backend
|
||||||
|
```
|
||||||
|
|
||||||
|
Terminal 2 (frontend):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun run dev:frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
Puertos por defecto:
|
||||||
|
|
||||||
|
- Frontend: `http://localhost:5173`
|
||||||
|
- Backend: `http://localhost:3000`
|
||||||
|
|
||||||
|
## Scripts útiles
|
||||||
|
|
||||||
|
Desde la raíz:
|
||||||
|
|
||||||
|
- `bun run dev:frontend`
|
||||||
|
- `bun run dev:backend`
|
||||||
|
- `bun run build:frontend`
|
||||||
|
- `bun run lint:frontend`
|
||||||
|
|
||||||
|
Desde `apps/backend`:
|
||||||
|
|
||||||
|
- `bun run dev`
|
||||||
|
- `bun run start`
|
||||||
|
- `bun run prisma:migrate`
|
||||||
|
- `bun run prisma:generate`
|
||||||
|
|
||||||
|
## Estructura del repo
|
||||||
|
|
||||||
|
```txt
|
||||||
|
.
|
||||||
|
├─ apps/
|
||||||
|
│ ├─ frontend/ # UI y navegación
|
||||||
|
│ └─ backend/ # API, auth middleware, acceso a DB
|
||||||
|
└─ packages/
|
||||||
|
└─ api-contract/ # Schemas/tipos/rutas compartidos
|
||||||
|
```
|
||||||
|
|
||||||
|
## Flujo de autenticación (resumen)
|
||||||
|
|
||||||
|
1. Frontend autentica con Supabase.
|
||||||
|
2. Frontend guarda el access token y lo envía como `Authorization: Bearer ...` al backend.
|
||||||
|
3. Backend valida token con Supabase (`requireAuth` middleware).
|
||||||
|
4. Si el token es válido, habilita rutas protegidas (`/api/user/*`, `/api/tenants/*`).
|
||||||
|
|
||||||
|
## Endpoints principales
|
||||||
|
|
||||||
|
- `GET /api/user/profile`
|
||||||
|
- `POST /api/tenants`
|
||||||
|
- `GET /api/tenants/:id`
|
||||||
|
- `PATCH /api/tenants/:id`
|
||||||
|
|
||||||
|
## Convenciones recomendadas para el equipo
|
||||||
|
|
||||||
|
- Mantener contratos de API en `packages/api-contract` (evitar duplicar tipos).
|
||||||
|
- Si agregás/cambiás endpoint:
|
||||||
|
1. Actualizá schema/tipos en `api-contract`.
|
||||||
|
2. Implementá handler/ruta en backend.
|
||||||
|
3. Consumí el contrato desde frontend.
|
||||||
|
- Validar payloads con Zod tanto en contrato como en rutas.
|
||||||
|
- Mantener PRs chicos y enfocados.
|
||||||
|
|
||||||
|
## Troubleshooting rápido
|
||||||
|
|
||||||
|
- Error de CORS:
|
||||||
|
- Revisar `CORS_ORIGIN` en backend y que incluya `http://localhost:5173`.
|
||||||
|
- `403 Missing bearer token`:
|
||||||
|
- Verificar login en frontend y envío del header `Authorization`.
|
||||||
|
- Error de Supabase env vars:
|
||||||
|
- Confirmar `SUPABASE_URL`/`SUPABASE_ANON_KEY` (backend) y `VITE_*` (frontend).
|
||||||
|
- Error de Prisma/DB:
|
||||||
|
- Confirmar `DATABASE_URL`, conectividad a PostgreSQL y migraciones aplicadas.
|
||||||
|
|
||||||
|
## Primer día sugerido para onboarding
|
||||||
|
|
||||||
|
1. Levantar backend y frontend en local.
|
||||||
|
2. Crear usuario o iniciar sesión desde la pantalla de onboard.
|
||||||
|
3. Crear un tenant de prueba.
|
||||||
|
4. Probar lectura/edición de tenant.
|
||||||
|
5. Revisar `packages/api-contract` para entender cómo se comparten tipos y rutas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Si te trabás en el setup, empezá verificando `.env`, estado de PostgreSQL y que ambos procesos (`dev:backend` y `dev:frontend`) estén corriendo.
|
||||||
12
apps/backend/.env
Normal file
12
apps/backend/.env
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
SUPABASE_URL=https://afzzqgvdkkpyxmifpaya.supabase.co
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImFmenpxZ3Zka2tweXhtaWZwYXlhIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc3NDg5Njc5MiwiZXhwIjoyMDkwNDcyNzkyfQ.2tex8lMCGzxBqeIk01Z08Wl15K7AavyfClUFgUl2NPM
|
||||||
|
SUPABASE_ANON_KEY=sb_publishable_RgMLVBTCTClhK-1Ks_X02Q_Sq1Z1Pq-
|
||||||
|
CORS_ORIGIN=http://localhost:5173,http://127.0.0.1:5173
|
||||||
|
DATABASE_URL=postgres://super_admin:solotech25@localhost:5432/playzer-dev
|
||||||
|
APP_BASE_URL=http://localhost:5173
|
||||||
|
ONBOARDING_TTL_MINUTES=60
|
||||||
|
SMTP_HOST=sandbox.smtp.mailtrap.io
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=6af2bf58132d12
|
||||||
|
SMTP_PASS=1253b2fe0fc47a
|
||||||
|
SMTP_FROM=Playzer <no-reply@playzer.app>
|
||||||
18
apps/backend/.env.example
Normal file
18
apps/backend/.env.example
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
SUPABASE_URL=https://your-project-ref.supabase.co
|
||||||
|
SUPABASE_ANON_KEY=your-anon-key
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
|
||||||
|
# Fallbacks accepted too:
|
||||||
|
# VITE_SUPABASE_URL=https://your-project-ref.supabase.co
|
||||||
|
# VITE_SUPABASE_ANON_KEY=your-anon-key
|
||||||
|
CORS_ORIGIN=http://localhost:5173,http://127.0.0.1:5173
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/monorepo
|
||||||
|
APP_BASE_URL=http://localhost:5173
|
||||||
|
ONBOARDING_OTP_TTL_MINUTES=10
|
||||||
|
ONBOARDING_OTP_MAX_ATTEMPTS=5
|
||||||
|
ONBOARDING_OTP_RESEND_COOLDOWN_SECONDS=30
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=user@example.com
|
||||||
|
SMTP_PASS=your-smtp-password
|
||||||
|
SMTP_FROM=Playzer <no-reply@example.com>
|
||||||
2
apps/backend/.gitignore
vendored
Normal file
2
apps/backend/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# deps
|
||||||
|
node_modules/
|
||||||
40
apps/backend/Dockerfile
Normal file
40
apps/backend/Dockerfile
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
|
FROM oven/bun:1.3 AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Workspace manifests first for better layer caching.
|
||||||
|
COPY package.json bun.lock ./
|
||||||
|
COPY apps/backend/package.json apps/backend/package.json
|
||||||
|
COPY packages/api-contract/package.json packages/api-contract/package.json
|
||||||
|
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
FROM deps AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY apps/backend apps/backend
|
||||||
|
COPY packages/api-contract packages/api-contract
|
||||||
|
|
||||||
|
# Prisma 7 config resolves DATABASE_URL at generate time.
|
||||||
|
ARG DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres
|
||||||
|
ENV DATABASE_URL=${DATABASE_URL}
|
||||||
|
|
||||||
|
RUN bun --cwd apps/backend run prisma:generate
|
||||||
|
|
||||||
|
FROM oven/bun:1.3-slim AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/apps/backend ./apps/backend
|
||||||
|
COPY --from=build /app/packages ./packages
|
||||||
|
COPY package.json bun.lock ./
|
||||||
|
|
||||||
|
WORKDIR /app/apps/backend
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["bun", "run", "start"]
|
||||||
11
apps/backend/README.md
Normal file
11
apps/backend/README.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
To install dependencies:
|
||||||
|
```sh
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
To run:
|
||||||
|
```sh
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
open http://localhost:3000
|
||||||
31
apps/backend/package.json
Normal file
31
apps/backend/package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun run --hot src/server.ts",
|
||||||
|
"start": "bun src/server.ts",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:migrate": "prisma migrate dev",
|
||||||
|
"prisma:seed": "prisma db seed",
|
||||||
|
"prisma:seed:reset:plans": "bun prisma/reset-plans.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hono/zod-validator": "^0.7.6",
|
||||||
|
"@prisma/adapter-pg": "^7.6.0",
|
||||||
|
"@prisma/client": "^7",
|
||||||
|
"@repo/api-contract": "workspace:*",
|
||||||
|
"@supabase/supabase-js": "2.101.1",
|
||||||
|
"dotenv": "^17.4.1",
|
||||||
|
"hono": "4.12.10",
|
||||||
|
"nodemailer": "^8.0.5",
|
||||||
|
"pg": "^8.20.0",
|
||||||
|
"pino": "10.3.1",
|
||||||
|
"pino-pretty": "13.1.3",
|
||||||
|
"pino-std-serializers": "7.1.0",
|
||||||
|
"uuid": "^13.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
|
"prisma": "^7"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/backend/prisma.config.ts
Normal file
13
apps/backend/prisma.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import 'dotenv/config'
|
||||||
|
import { defineConfig, env } from 'prisma/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: 'prisma',
|
||||||
|
migrations: {
|
||||||
|
path: 'prisma/migrations',
|
||||||
|
seed: 'bun ./prisma/seed.ts',
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: env('DATABASE_URL'),
|
||||||
|
},
|
||||||
|
})
|
||||||
34
apps/backend/prisma/complex.prisma
Normal file
34
apps/backend/prisma/complex.prisma
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
model Complex {
|
||||||
|
id String @id @db.Uuid
|
||||||
|
complexName String @map("complex_name")
|
||||||
|
physicalAddress String? @map("physical_address") @db.VarChar(200)
|
||||||
|
complexSlug String @unique @map("complex_slug")
|
||||||
|
adminEmail String @map("admin_email")
|
||||||
|
planCode String? @map("plan_code") @db.VarChar(10)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
plan Plan? @relation(fields: [planCode], references: [code])
|
||||||
|
users ComplexUser[]
|
||||||
|
courts Court[]
|
||||||
|
|
||||||
|
@@index([planCode])
|
||||||
|
@@index([complexSlug])
|
||||||
|
@@map("complexes")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ComplexUserRole {
|
||||||
|
ADMIN
|
||||||
|
}
|
||||||
|
|
||||||
|
model ComplexUser {
|
||||||
|
complexId String @map("complex_id") @db.Uuid
|
||||||
|
userId String @map("user_id") @db.Uuid
|
||||||
|
role ComplexUserRole @default(ADMIN)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
complex Complex @relation(fields: [complexId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([complexId, userId])
|
||||||
|
@@index([userId])
|
||||||
|
@@map("complex_users")
|
||||||
|
}
|
||||||
95
apps/backend/prisma/court.prisma
Normal file
95
apps/backend/prisma/court.prisma
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
enum DayOfWeek {
|
||||||
|
MONDAY
|
||||||
|
TUESDAY
|
||||||
|
WEDNESDAY
|
||||||
|
THURSDAY
|
||||||
|
FRIDAY
|
||||||
|
SATURDAY
|
||||||
|
SUNDAY
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CourtBookingStatus {
|
||||||
|
CONFIRMED
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
model Sport {
|
||||||
|
id String @id @db.Uuid
|
||||||
|
name String @unique
|
||||||
|
slug String @unique
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
courts Court[]
|
||||||
|
|
||||||
|
@@map("sports")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Court {
|
||||||
|
id String @id @db.Uuid
|
||||||
|
complexId String @map("complex_id") @db.Uuid
|
||||||
|
sportId String @map("sport_id") @db.Uuid
|
||||||
|
name String
|
||||||
|
slotDurationMinutes Int @map("slot_duration_minutes")
|
||||||
|
basePrice Decimal @map("base_price") @db.Decimal(19, 2)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
complex Complex @relation(fields: [complexId], references: [id], onDelete: Cascade)
|
||||||
|
sport Sport @relation(fields: [sportId], references: [id])
|
||||||
|
availabilities CourtAvailability[]
|
||||||
|
priceRules CourtPriceRule[]
|
||||||
|
bookings CourtBooking[]
|
||||||
|
|
||||||
|
@@index([complexId])
|
||||||
|
@@index([sportId])
|
||||||
|
@@map("courts")
|
||||||
|
}
|
||||||
|
|
||||||
|
model CourtAvailability {
|
||||||
|
id String @id @db.Uuid
|
||||||
|
courtId String @map("court_id") @db.Uuid
|
||||||
|
dayOfWeek DayOfWeek @map("day_of_week")
|
||||||
|
startTime String @map("start_time") @db.VarChar(5)
|
||||||
|
endTime String @map("end_time") @db.VarChar(5)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
court Court @relation(fields: [courtId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([courtId, dayOfWeek])
|
||||||
|
@@map("court_availabilities")
|
||||||
|
}
|
||||||
|
|
||||||
|
model CourtPriceRule {
|
||||||
|
id String @id @db.Uuid
|
||||||
|
courtId String @map("court_id") @db.Uuid
|
||||||
|
dayOfWeek DayOfWeek? @map("day_of_week")
|
||||||
|
startTime String? @map("start_time") @db.VarChar(5)
|
||||||
|
endTime String? @map("end_time") @db.VarChar(5)
|
||||||
|
price Decimal @db.Decimal(19, 2)
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
court Court @relation(fields: [courtId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([courtId, isActive])
|
||||||
|
@@map("court_price_rules")
|
||||||
|
}
|
||||||
|
|
||||||
|
model CourtBooking {
|
||||||
|
id String @id @db.Uuid
|
||||||
|
bookingCode String @unique @map("booking_code") @db.VarChar(8)
|
||||||
|
courtId String @map("court_id") @db.Uuid
|
||||||
|
bookingDate DateTime @map("booking_date") @db.Date
|
||||||
|
startTime String @map("start_time") @db.VarChar(5)
|
||||||
|
endTime String @map("end_time") @db.VarChar(5)
|
||||||
|
customerName String @map("customer_name") @db.VarChar(120)
|
||||||
|
customerPhone String @map("customer_phone") @db.VarChar(30)
|
||||||
|
status CourtBookingStatus @default(CONFIRMED)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
court Court @relation(fields: [courtId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([courtId, bookingDate, startTime])
|
||||||
|
@@index([courtId, bookingDate])
|
||||||
|
@@index([bookingDate])
|
||||||
|
@@map("court_bookings")
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "plans" (
|
||||||
|
"code" VARCHAR(10) NOT NULL,
|
||||||
|
"name" VARCHAR(30) NOT NULL,
|
||||||
|
"price" DECIMAL(19,2) NOT NULL,
|
||||||
|
"rules" JSONB NOT NULL,
|
||||||
|
"last_updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "plans_pkey" PRIMARY KEY ("code")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "complexes" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"complex_name" TEXT NOT NULL,
|
||||||
|
"complex_slug" TEXT NOT NULL,
|
||||||
|
"admin_email" TEXT NOT NULL,
|
||||||
|
"plan_code" VARCHAR(10),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "complexes_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "complexes_complex_slug_key" ON "complexes"("complex_slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "complexes_plan_code_idx" ON "complexes"("plan_code");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "complexes" ADD CONSTRAINT "complexes_plan_code_fkey" FOREIGN KEY ("plan_code") REFERENCES "plans"("code") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ComplexUserRole" AS ENUM ('ADMIN');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "complex_users" (
|
||||||
|
"complex_id" UUID NOT NULL,
|
||||||
|
"user_id" UUID NOT NULL,
|
||||||
|
"role" "ComplexUserRole" NOT NULL DEFAULT 'ADMIN',
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "complex_users_pkey" PRIMARY KEY ("complex_id","user_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "onboarding_requests" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"full_name" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"email_verification_token_hash" TEXT NOT NULL,
|
||||||
|
"email_verification_sent_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"email_verified_at" TIMESTAMP(3),
|
||||||
|
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"completed_at" TIMESTAMP(3),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "onboarding_requests_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"supabase_user_id" UUID NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"full_name" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "complex_users_user_id_idx" ON "complex_users"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "onboarding_requests_email_verification_token_hash_key" ON "onboarding_requests"("email_verification_token_hash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "onboarding_requests_email_idx" ON "onboarding_requests"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "onboarding_requests_expires_at_idx" ON "onboarding_requests"("expires_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "users_supabase_user_id_key" ON "users"("supabase_user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "complex_users" ADD CONSTRAINT "complex_users_complex_id_fkey" FOREIGN KEY ("complex_id") REFERENCES "complexes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "complex_users" ADD CONSTRAINT "complex_users_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- DropIndex
|
||||||
|
DROP INDEX IF EXISTS "onboarding_requests_email_verification_token_hash_key";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX IF EXISTS "onboarding_requests_expires_at_idx";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "onboarding_requests"
|
||||||
|
ADD COLUMN "otp_hash" TEXT,
|
||||||
|
ADD COLUMN "otp_expires_at" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "otp_attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "otp_last_sent_at" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "otp_resend_count" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Backfill for existing rows
|
||||||
|
UPDATE "onboarding_requests"
|
||||||
|
SET
|
||||||
|
"otp_hash" = md5("id"::text || ':legacy'),
|
||||||
|
"otp_expires_at" = COALESCE("expires_at", NOW()),
|
||||||
|
"otp_last_sent_at" = COALESCE("email_verification_sent_at", NOW());
|
||||||
|
|
||||||
|
-- Enforce required fields
|
||||||
|
ALTER TABLE "onboarding_requests"
|
||||||
|
ALTER COLUMN "otp_hash" SET NOT NULL,
|
||||||
|
ALTER COLUMN "otp_expires_at" SET NOT NULL,
|
||||||
|
ALTER COLUMN "otp_last_sent_at" SET NOT NULL;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "onboarding_requests_otp_expires_at_idx" ON "onboarding_requests"("otp_expires_at");
|
||||||
|
|
||||||
|
-- Drop legacy columns
|
||||||
|
ALTER TABLE "onboarding_requests"
|
||||||
|
DROP COLUMN "email_verification_token_hash",
|
||||||
|
DROP COLUMN "email_verification_sent_at",
|
||||||
|
DROP COLUMN "expires_at";
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "DayOfWeek" AS ENUM (
|
||||||
|
'MONDAY',
|
||||||
|
'TUESDAY',
|
||||||
|
'WEDNESDAY',
|
||||||
|
'THURSDAY',
|
||||||
|
'FRIDAY',
|
||||||
|
'SATURDAY',
|
||||||
|
'SUNDAY'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "sports" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "sports_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "courts" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"complex_id" UUID NOT NULL,
|
||||||
|
"sport_id" UUID NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"slot_duration_minutes" INTEGER NOT NULL,
|
||||||
|
"base_price" DECIMAL(19,2) NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "courts_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "court_availabilities" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"court_id" UUID NOT NULL,
|
||||||
|
"day_of_week" "DayOfWeek" NOT NULL,
|
||||||
|
"start_time" VARCHAR(5) NOT NULL,
|
||||||
|
"end_time" VARCHAR(5) NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "court_availabilities_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "court_price_rules" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"court_id" UUID NOT NULL,
|
||||||
|
"day_of_week" "DayOfWeek",
|
||||||
|
"start_time" VARCHAR(5),
|
||||||
|
"end_time" VARCHAR(5),
|
||||||
|
"price" DECIMAL(19,2) NOT NULL,
|
||||||
|
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "court_price_rules_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "sports_name_key" ON "sports"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "sports_slug_key" ON "sports"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "courts_complex_id_idx" ON "courts"("complex_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "courts_sport_id_idx" ON "courts"("sport_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "court_availabilities_court_id_day_of_week_idx" ON "court_availabilities"("court_id", "day_of_week");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "court_price_rules_court_id_is_active_idx" ON "court_price_rules"("court_id", "is_active");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "courts" ADD CONSTRAINT "courts_complex_id_fkey" FOREIGN KEY ("complex_id") REFERENCES "complexes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "courts" ADD CONSTRAINT "courts_sport_id_fkey" FOREIGN KEY ("sport_id") REFERENCES "sports"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "court_availabilities" ADD CONSTRAINT "court_availabilities_court_id_fkey" FOREIGN KEY ("court_id") REFERENCES "courts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "court_price_rules" ADD CONSTRAINT "court_price_rules_court_id_fkey" FOREIGN KEY ("court_id") REFERENCES "courts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "CourtBookingStatus" AS ENUM ('CONFIRMED', 'CANCELLED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "court_bookings" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"court_id" UUID NOT NULL,
|
||||||
|
"booking_date" DATE NOT NULL,
|
||||||
|
"start_time" VARCHAR(5) NOT NULL,
|
||||||
|
"end_time" VARCHAR(5) NOT NULL,
|
||||||
|
"customer_name" VARCHAR(120) NOT NULL,
|
||||||
|
"customer_phone" VARCHAR(30) NOT NULL,
|
||||||
|
"status" "CourtBookingStatus" NOT NULL DEFAULT 'CONFIRMED',
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "court_bookings_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "court_bookings_court_id_booking_date_start_time_key" ON "court_bookings"("court_id", "booking_date", "start_time");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "court_bookings_court_id_booking_date_idx" ON "court_bookings"("court_id", "booking_date");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "court_bookings_booking_date_idx" ON "court_bookings"("booking_date");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "court_bookings" ADD CONSTRAINT "court_bookings_court_id_fkey" FOREIGN KEY ("court_id") REFERENCES "courts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "court_bookings" ADD COLUMN "booking_code" VARCHAR(8);
|
||||||
|
|
||||||
|
-- Backfill existing rows
|
||||||
|
UPDATE "court_bookings"
|
||||||
|
SET "booking_code" = UPPER(SUBSTRING(REPLACE("id"::text, '-', '') FROM 1 FOR 8))
|
||||||
|
WHERE "booking_code" IS NULL;
|
||||||
|
|
||||||
|
-- Enforce not-null and uniqueness
|
||||||
|
ALTER TABLE "court_bookings" ALTER COLUMN "booking_code" SET NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX "court_bookings_booking_code_key" ON "court_bookings"("booking_code");
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "complexes_complex_slug_idx" ON "complexes"("complex_slug");
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "complexes"
|
||||||
|
ADD COLUMN "physical_address" VARCHAR(200);
|
||||||
3
apps/backend/prisma/migrations/migration_lock.toml
Normal file
3
apps/backend/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
18
apps/backend/prisma/onboarding.prisma
Normal file
18
apps/backend/prisma/onboarding.prisma
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
model OnboardingRequest {
|
||||||
|
id String @id @db.Uuid
|
||||||
|
fullName String @map("full_name")
|
||||||
|
email String
|
||||||
|
otpHash String @map("otp_hash")
|
||||||
|
otpExpiresAt DateTime @map("otp_expires_at")
|
||||||
|
otpAttempts Int @default(0) @map("otp_attempts")
|
||||||
|
otpLastSentAt DateTime @map("otp_last_sent_at")
|
||||||
|
otpResendCount Int @default(0) @map("otp_resend_count")
|
||||||
|
emailVerifiedAt DateTime? @map("email_verified_at")
|
||||||
|
completedAt DateTime? @map("completed_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@index([email])
|
||||||
|
@@index([otpExpiresAt])
|
||||||
|
@@map("onboarding_requests")
|
||||||
|
}
|
||||||
10
apps/backend/prisma/plan.prisma
Normal file
10
apps/backend/prisma/plan.prisma
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
model Plan {
|
||||||
|
code String @id @db.VarChar(10)
|
||||||
|
name String @db.VarChar(30)
|
||||||
|
price Decimal @db.Decimal(19, 2)
|
||||||
|
rules Json
|
||||||
|
lastUpdatedAt DateTime @default(now()) @map("last_updated_at")
|
||||||
|
complexes Complex[]
|
||||||
|
|
||||||
|
@@map("plans")
|
||||||
|
}
|
||||||
77
apps/backend/prisma/plans.seed-data.ts
Normal file
77
apps/backend/prisma/plans.seed-data.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
export const planSeeds = [
|
||||||
|
{
|
||||||
|
code: 'BASIC',
|
||||||
|
name: 'Basic',
|
||||||
|
price: '49.00',
|
||||||
|
rules: {
|
||||||
|
version: 'v1',
|
||||||
|
limits: {
|
||||||
|
maxCourts: 2,
|
||||||
|
maxBookingsPerDay: 80,
|
||||||
|
maxActiveUsers: 3,
|
||||||
|
maxConcurrentBookingsPerSlot: 1,
|
||||||
|
},
|
||||||
|
policies: {
|
||||||
|
maxAdvanceBookingDays: 7,
|
||||||
|
minCancellationNoticeHours: 2,
|
||||||
|
allowOverbooking: false,
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
onlinePayments: false,
|
||||||
|
publicBookingPage: true,
|
||||||
|
advancedReports: false,
|
||||||
|
whatsappReminders: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'ADVANCED',
|
||||||
|
name: 'Advanced',
|
||||||
|
price: '99.00',
|
||||||
|
rules: {
|
||||||
|
version: 'v1',
|
||||||
|
limits: {
|
||||||
|
maxCourts: 5,
|
||||||
|
maxBookingsPerDay: 220,
|
||||||
|
maxActiveUsers: 8,
|
||||||
|
maxConcurrentBookingsPerSlot: 1,
|
||||||
|
},
|
||||||
|
policies: {
|
||||||
|
maxAdvanceBookingDays: 15,
|
||||||
|
minCancellationNoticeHours: 2,
|
||||||
|
allowOverbooking: false,
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
onlinePayments: true,
|
||||||
|
publicBookingPage: true,
|
||||||
|
advancedReports: true,
|
||||||
|
whatsappReminders: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'ENTERPRISE',
|
||||||
|
name: 'Enterprise',
|
||||||
|
price: '199.00',
|
||||||
|
rules: {
|
||||||
|
version: 'v1',
|
||||||
|
limits: {
|
||||||
|
maxCourts: 20,
|
||||||
|
maxBookingsPerDay: 2000,
|
||||||
|
maxActiveUsers: 50,
|
||||||
|
maxConcurrentBookingsPerSlot: 2,
|
||||||
|
},
|
||||||
|
policies: {
|
||||||
|
maxAdvanceBookingDays: 60,
|
||||||
|
minCancellationNoticeHours: 1,
|
||||||
|
allowOverbooking: true,
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
onlinePayments: true,
|
||||||
|
publicBookingPage: true,
|
||||||
|
advancedReports: true,
|
||||||
|
whatsappReminders: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as const
|
||||||
55
apps/backend/prisma/reset-plans.ts
Normal file
55
apps/backend/prisma/reset-plans.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import 'dotenv/config'
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg'
|
||||||
|
import { PrismaClient } from '../src/generated/prisma/client'
|
||||||
|
import { planSeeds } from './plans.seed-data'
|
||||||
|
|
||||||
|
const databaseUrl = process.env.DATABASE_URL
|
||||||
|
|
||||||
|
if (!databaseUrl) {
|
||||||
|
throw new Error('Missing DATABASE_URL in environment for plan reset.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter = new PrismaPg({
|
||||||
|
connectionString: databaseUrl,
|
||||||
|
})
|
||||||
|
|
||||||
|
const prisma = new PrismaClient({ adapter })
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const complexesUsingPlans = await prisma.complex.count({
|
||||||
|
where: { planCode: { not: null } },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (complexesUsingPlans > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`No se puede resetear planes: hay ${complexesUsingPlans} complejos con plan asignado.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.plan.deleteMany({})
|
||||||
|
|
||||||
|
for (const plan of planSeeds) {
|
||||||
|
await tx.plan.create({
|
||||||
|
data: {
|
||||||
|
code: plan.code,
|
||||||
|
name: plan.name,
|
||||||
|
price: plan.price,
|
||||||
|
rules: plan.rules,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('Planes reseteados: BASIC, ADVANCED, ENTERPRISE')
|
||||||
|
}
|
||||||
|
|
||||||
|
run()
|
||||||
|
.then(async () => {
|
||||||
|
await prisma.$disconnect()
|
||||||
|
})
|
||||||
|
.catch(async (error) => {
|
||||||
|
console.error(error)
|
||||||
|
await prisma.$disconnect()
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
8
apps/backend/prisma/schema.prisma
Normal file
8
apps/backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../src/generated/prisma"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
64
apps/backend/prisma/seed.ts
Normal file
64
apps/backend/prisma/seed.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import 'dotenv/config'
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg'
|
||||||
|
import { v7 as uuidv7 } from 'uuid'
|
||||||
|
import { PrismaClient } from '../src/generated/prisma/client'
|
||||||
|
import { planSeeds } from './plans.seed-data'
|
||||||
|
import { sportSeeds } from './sports.seed-data'
|
||||||
|
|
||||||
|
const databaseUrl = process.env.DATABASE_URL
|
||||||
|
|
||||||
|
if (!databaseUrl) {
|
||||||
|
throw new Error('Missing DATABASE_URL in environment for seeding.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter = new PrismaPg({
|
||||||
|
connectionString: databaseUrl,
|
||||||
|
})
|
||||||
|
|
||||||
|
const prisma = new PrismaClient({ adapter })
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
for (const plan of planSeeds) {
|
||||||
|
await prisma.plan.upsert({
|
||||||
|
where: { code: plan.code },
|
||||||
|
update: {
|
||||||
|
name: plan.name,
|
||||||
|
price: plan.price,
|
||||||
|
rules: plan.rules,
|
||||||
|
lastUpdatedAt: new Date(),
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
code: plan.code,
|
||||||
|
name: plan.name,
|
||||||
|
price: plan.price,
|
||||||
|
rules: plan.rules,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sport of sportSeeds) {
|
||||||
|
await prisma.sport.upsert({
|
||||||
|
where: { slug: sport.slug },
|
||||||
|
update: {
|
||||||
|
name: sport.name,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: uuidv7(),
|
||||||
|
name: sport.name,
|
||||||
|
slug: sport.slug,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run()
|
||||||
|
.then(async () => {
|
||||||
|
await prisma.$disconnect()
|
||||||
|
})
|
||||||
|
.catch(async (error) => {
|
||||||
|
console.error(error)
|
||||||
|
await prisma.$disconnect()
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
8
apps/backend/prisma/sports.seed-data.ts
Normal file
8
apps/backend/prisma/sports.seed-data.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export const sportSeeds = [
|
||||||
|
{ name: 'Futbol 5', slug: 'futbol-5' },
|
||||||
|
{ name: 'Padel', slug: 'padel' },
|
||||||
|
{ name: 'Tenis', slug: 'tenis' },
|
||||||
|
{ name: 'Pickleball', slug: 'pickleball' },
|
||||||
|
{ name: 'Basquet', slug: 'basquet' },
|
||||||
|
{ name: 'Basquet 3', slug: 'basquet-3' },
|
||||||
|
] as const
|
||||||
11
apps/backend/prisma/user.prisma
Normal file
11
apps/backend/prisma/user.prisma
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
model User {
|
||||||
|
id String @id @db.Uuid
|
||||||
|
supabaseUserId String @unique @map("supabase_user_id") @db.Uuid
|
||||||
|
email String @unique
|
||||||
|
fullName String @map("full_name")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
complexes ComplexUser[]
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
48
apps/backend/src/app.ts
Normal file
48
apps/backend/src/app.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
export function createApp() {
|
||||||
|
const app = new Hono<AppEnv>()
|
||||||
|
|
||||||
|
const allowedOrigins = (
|
||||||
|
Bun.env.CORS_ORIGIN ??
|
||||||
|
'http://localhost:5173,http://127.0.0.1:5173'
|
||||||
|
)
|
||||||
|
.split(',')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
'*',
|
||||||
|
cors({
|
||||||
|
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))
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
method: c.req.method,
|
||||||
|
path: c.req.path,
|
||||||
|
status: c.res.status,
|
||||||
|
durationMs,
|
||||||
|
requestId
|
||||||
|
},
|
||||||
|
'http_request',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return app
|
||||||
|
}
|
||||||
69
apps/backend/src/generated/prisma/browser.ts
Normal file
69
apps/backend/src/generated/prisma/browser.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
|
||||||
|
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||||
|
/* eslint-disable */
|
||||||
|
// biome-ignore-all lint: generated file
|
||||||
|
// @ts-nocheck
|
||||||
|
/*
|
||||||
|
* This file should be your main import to use Prisma-related types and utilities in a browser.
|
||||||
|
* Use it to get access to models, enums, and input types.
|
||||||
|
*
|
||||||
|
* This file does not contain a `PrismaClient` class, nor several other helpers that are intended as server-side only.
|
||||||
|
* See `client.ts` for the standard, server-side entry point.
|
||||||
|
*
|
||||||
|
* 🟢 You can import this file directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as Prisma from './internal/prismaNamespaceBrowser'
|
||||||
|
export { Prisma }
|
||||||
|
export * as $Enums from './enums'
|
||||||
|
export * from './enums';
|
||||||
|
/**
|
||||||
|
* Model Complex
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type Complex = Prisma.ComplexModel
|
||||||
|
/**
|
||||||
|
* Model ComplexUser
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type ComplexUser = Prisma.ComplexUserModel
|
||||||
|
/**
|
||||||
|
* Model Sport
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type Sport = Prisma.SportModel
|
||||||
|
/**
|
||||||
|
* Model Court
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type Court = Prisma.CourtModel
|
||||||
|
/**
|
||||||
|
* Model CourtAvailability
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type CourtAvailability = Prisma.CourtAvailabilityModel
|
||||||
|
/**
|
||||||
|
* Model CourtPriceRule
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type CourtPriceRule = Prisma.CourtPriceRuleModel
|
||||||
|
/**
|
||||||
|
* Model CourtBooking
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type CourtBooking = Prisma.CourtBookingModel
|
||||||
|
/**
|
||||||
|
* Model OnboardingRequest
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type OnboardingRequest = Prisma.OnboardingRequestModel
|
||||||
|
/**
|
||||||
|
* Model Plan
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type Plan = Prisma.PlanModel
|
||||||
|
/**
|
||||||
|
* Model User
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type User = Prisma.UserModel
|
||||||
93
apps/backend/src/generated/prisma/client.ts
Normal file
93
apps/backend/src/generated/prisma/client.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
|
||||||
|
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||||
|
/* eslint-disable */
|
||||||
|
// biome-ignore-all lint: generated file
|
||||||
|
// @ts-nocheck
|
||||||
|
/*
|
||||||
|
* This file should be your main import to use Prisma. Through it you get access to all the models, enums, and input types.
|
||||||
|
* If you're looking for something you can import in the client-side of your application, please refer to the `browser.ts` file instead.
|
||||||
|
*
|
||||||
|
* 🟢 You can import this file directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as process from 'node:process'
|
||||||
|
import * as path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
globalThis['__dirname'] = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
import * as runtime from "@prisma/client/runtime/client"
|
||||||
|
import * as $Enums from "./enums"
|
||||||
|
import * as $Class from "./internal/class"
|
||||||
|
import * as Prisma from "./internal/prismaNamespace"
|
||||||
|
|
||||||
|
export * as $Enums from './enums'
|
||||||
|
export * from "./enums"
|
||||||
|
/**
|
||||||
|
* ## Prisma Client
|
||||||
|
*
|
||||||
|
* Type-safe database client for TypeScript
|
||||||
|
* @example
|
||||||
|
* ```
|
||||||
|
* const prisma = new PrismaClient({
|
||||||
|
* adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL })
|
||||||
|
* })
|
||||||
|
* // Fetch zero or more Complexes
|
||||||
|
* const complexes = await prisma.complex.findMany()
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Read more in our [docs](https://pris.ly/d/client).
|
||||||
|
*/
|
||||||
|
export const PrismaClient = $Class.getPrismaClientClass()
|
||||||
|
export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts extends Prisma.PrismaClientOptions["omit"] = Prisma.PrismaClientOptions["omit"], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>
|
||||||
|
export { Prisma }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model Complex
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type Complex = Prisma.ComplexModel
|
||||||
|
/**
|
||||||
|
* Model ComplexUser
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type ComplexUser = Prisma.ComplexUserModel
|
||||||
|
/**
|
||||||
|
* Model Sport
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type Sport = Prisma.SportModel
|
||||||
|
/**
|
||||||
|
* Model Court
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type Court = Prisma.CourtModel
|
||||||
|
/**
|
||||||
|
* Model CourtAvailability
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type CourtAvailability = Prisma.CourtAvailabilityModel
|
||||||
|
/**
|
||||||
|
* Model CourtPriceRule
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type CourtPriceRule = Prisma.CourtPriceRuleModel
|
||||||
|
/**
|
||||||
|
* Model CourtBooking
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type CourtBooking = Prisma.CourtBookingModel
|
||||||
|
/**
|
||||||
|
* Model OnboardingRequest
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type OnboardingRequest = Prisma.OnboardingRequestModel
|
||||||
|
/**
|
||||||
|
* Model Plan
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type Plan = Prisma.PlanModel
|
||||||
|
/**
|
||||||
|
* Model User
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type User = Prisma.UserModel
|
||||||
669
apps/backend/src/generated/prisma/commonInputTypes.ts
Normal file
669
apps/backend/src/generated/prisma/commonInputTypes.ts
Normal file
@@ -0,0 +1,669 @@
|
|||||||
|
|
||||||
|
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||||
|
/* eslint-disable */
|
||||||
|
// biome-ignore-all lint: generated file
|
||||||
|
// @ts-nocheck
|
||||||
|
/*
|
||||||
|
* This file exports various common sort, input & filter types that are not directly linked to a particular model.
|
||||||
|
*
|
||||||
|
* 🟢 You can import this file directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type * as runtime from "@prisma/client/runtime/client"
|
||||||
|
import * as $Enums from "./enums"
|
||||||
|
import type * as Prisma from "./internal/prismaNamespace"
|
||||||
|
|
||||||
|
|
||||||
|
export type UuidFilter<$PrismaModel = never> = {
|
||||||
|
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||||
|
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
mode?: Prisma.QueryMode
|
||||||
|
not?: Prisma.NestedUuidFilter<$PrismaModel> | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StringFilter<$PrismaModel = never> = {
|
||||||
|
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||||
|
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
mode?: Prisma.QueryMode
|
||||||
|
not?: Prisma.NestedStringFilter<$PrismaModel> | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StringNullableFilter<$PrismaModel = never> = {
|
||||||
|
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
mode?: Prisma.QueryMode
|
||||||
|
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DateTimeFilter<$PrismaModel = never> = {
|
||||||
|
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||||
|
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SortOrderInput = {
|
||||||
|
sort: Prisma.SortOrder
|
||||||
|
nulls?: Prisma.NullsOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UuidWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||||
|
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
mode?: Prisma.QueryMode
|
||||||
|
not?: Prisma.NestedUuidWithAggregatesFilter<$PrismaModel> | string
|
||||||
|
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedStringFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedStringFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StringWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||||
|
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
mode?: Prisma.QueryMode
|
||||||
|
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
|
||||||
|
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedStringFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedStringFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
mode?: Prisma.QueryMode
|
||||||
|
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
|
||||||
|
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||||
|
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
|
||||||
|
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EnumComplexUserRoleFilter<$PrismaModel = never> = {
|
||||||
|
equals?: $Enums.ComplexUserRole | Prisma.EnumComplexUserRoleFieldRefInput<$PrismaModel>
|
||||||
|
in?: $Enums.ComplexUserRole[] | Prisma.ListEnumComplexUserRoleFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: $Enums.ComplexUserRole[] | Prisma.ListEnumComplexUserRoleFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedEnumComplexUserRoleFilter<$PrismaModel> | $Enums.ComplexUserRole
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EnumComplexUserRoleWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: $Enums.ComplexUserRole | Prisma.EnumComplexUserRoleFieldRefInput<$PrismaModel>
|
||||||
|
in?: $Enums.ComplexUserRole[] | Prisma.ListEnumComplexUserRoleFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: $Enums.ComplexUserRole[] | Prisma.ListEnumComplexUserRoleFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedEnumComplexUserRoleWithAggregatesFilter<$PrismaModel> | $Enums.ComplexUserRole
|
||||||
|
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedEnumComplexUserRoleFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedEnumComplexUserRoleFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BoolFilter<$PrismaModel = never> = {
|
||||||
|
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BoolWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
|
||||||
|
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IntFilter<$PrismaModel = never> = {
|
||||||
|
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||||
|
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedIntFilter<$PrismaModel> | number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DecimalFilter<$PrismaModel = never> = {
|
||||||
|
equals?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
in?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel>
|
||||||
|
lt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
lte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
gt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
gte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedDecimalFilter<$PrismaModel> | runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IntWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||||
|
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
|
||||||
|
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||||
|
_sum?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DecimalWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
in?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel>
|
||||||
|
lt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
lte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
gt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
gte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedDecimalWithAggregatesFilter<$PrismaModel> | runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||||
|
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_avg?: Prisma.NestedDecimalFilter<$PrismaModel>
|
||||||
|
_sum?: Prisma.NestedDecimalFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedDecimalFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedDecimalFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EnumDayOfWeekFilter<$PrismaModel = never> = {
|
||||||
|
equals?: $Enums.DayOfWeek | Prisma.EnumDayOfWeekFieldRefInput<$PrismaModel>
|
||||||
|
in?: $Enums.DayOfWeek[] | Prisma.ListEnumDayOfWeekFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: $Enums.DayOfWeek[] | Prisma.ListEnumDayOfWeekFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedEnumDayOfWeekFilter<$PrismaModel> | $Enums.DayOfWeek
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EnumDayOfWeekWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: $Enums.DayOfWeek | Prisma.EnumDayOfWeekFieldRefInput<$PrismaModel>
|
||||||
|
in?: $Enums.DayOfWeek[] | Prisma.ListEnumDayOfWeekFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: $Enums.DayOfWeek[] | Prisma.ListEnumDayOfWeekFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedEnumDayOfWeekWithAggregatesFilter<$PrismaModel> | $Enums.DayOfWeek
|
||||||
|
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedEnumDayOfWeekFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedEnumDayOfWeekFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EnumDayOfWeekNullableFilter<$PrismaModel = never> = {
|
||||||
|
equals?: $Enums.DayOfWeek | Prisma.EnumDayOfWeekFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: $Enums.DayOfWeek[] | Prisma.ListEnumDayOfWeekFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: $Enums.DayOfWeek[] | Prisma.ListEnumDayOfWeekFieldRefInput<$PrismaModel> | null
|
||||||
|
not?: Prisma.NestedEnumDayOfWeekNullableFilter<$PrismaModel> | $Enums.DayOfWeek | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EnumDayOfWeekNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: $Enums.DayOfWeek | Prisma.EnumDayOfWeekFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: $Enums.DayOfWeek[] | Prisma.ListEnumDayOfWeekFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: $Enums.DayOfWeek[] | Prisma.ListEnumDayOfWeekFieldRefInput<$PrismaModel> | null
|
||||||
|
not?: Prisma.NestedEnumDayOfWeekNullableWithAggregatesFilter<$PrismaModel> | $Enums.DayOfWeek | null
|
||||||
|
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedEnumDayOfWeekNullableFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedEnumDayOfWeekNullableFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EnumCourtBookingStatusFilter<$PrismaModel = never> = {
|
||||||
|
equals?: $Enums.CourtBookingStatus | Prisma.EnumCourtBookingStatusFieldRefInput<$PrismaModel>
|
||||||
|
in?: $Enums.CourtBookingStatus[] | Prisma.ListEnumCourtBookingStatusFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: $Enums.CourtBookingStatus[] | Prisma.ListEnumCourtBookingStatusFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedEnumCourtBookingStatusFilter<$PrismaModel> | $Enums.CourtBookingStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EnumCourtBookingStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: $Enums.CourtBookingStatus | Prisma.EnumCourtBookingStatusFieldRefInput<$PrismaModel>
|
||||||
|
in?: $Enums.CourtBookingStatus[] | Prisma.ListEnumCourtBookingStatusFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: $Enums.CourtBookingStatus[] | Prisma.ListEnumCourtBookingStatusFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedEnumCourtBookingStatusWithAggregatesFilter<$PrismaModel> | $Enums.CourtBookingStatus
|
||||||
|
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedEnumCourtBookingStatusFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedEnumCourtBookingStatusFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DateTimeNullableFilter<$PrismaModel = never> = {
|
||||||
|
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
|
||||||
|
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JsonFilter<$PrismaModel = never> =
|
||||||
|
| Prisma.PatchUndefined<
|
||||||
|
Prisma.Either<Required<JsonFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonFilterBase<$PrismaModel>>, 'path'>>,
|
||||||
|
Required<JsonFilterBase<$PrismaModel>>
|
||||||
|
>
|
||||||
|
| Prisma.OptionalFlat<Omit<Required<JsonFilterBase<$PrismaModel>>, 'path'>>
|
||||||
|
|
||||||
|
export type JsonFilterBase<$PrismaModel = never> = {
|
||||||
|
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||||
|
path?: string[]
|
||||||
|
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||||
|
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||||
|
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||||
|
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JsonWithAggregatesFilter<$PrismaModel = never> =
|
||||||
|
| Prisma.PatchUndefined<
|
||||||
|
Prisma.Either<Required<JsonWithAggregatesFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonWithAggregatesFilterBase<$PrismaModel>>, 'path'>>,
|
||||||
|
Required<JsonWithAggregatesFilterBase<$PrismaModel>>
|
||||||
|
>
|
||||||
|
| Prisma.OptionalFlat<Omit<Required<JsonWithAggregatesFilterBase<$PrismaModel>>, 'path'>>
|
||||||
|
|
||||||
|
export type JsonWithAggregatesFilterBase<$PrismaModel = never> = {
|
||||||
|
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||||
|
path?: string[]
|
||||||
|
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||||
|
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||||
|
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||||
|
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||||
|
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedJsonFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedJsonFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedUuidFilter<$PrismaModel = never> = {
|
||||||
|
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||||
|
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedUuidFilter<$PrismaModel> | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedStringFilter<$PrismaModel = never> = {
|
||||||
|
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||||
|
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedStringFilter<$PrismaModel> | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedStringNullableFilter<$PrismaModel = never> = {
|
||||||
|
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedDateTimeFilter<$PrismaModel = never> = {
|
||||||
|
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||||
|
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedUuidWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||||
|
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedUuidWithAggregatesFilter<$PrismaModel> | string
|
||||||
|
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedStringFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedStringFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedIntFilter<$PrismaModel = never> = {
|
||||||
|
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||||
|
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedIntFilter<$PrismaModel> | number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedStringWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||||
|
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
|
||||||
|
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedStringFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedStringFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedStringNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
|
||||||
|
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedIntNullableFilter<$PrismaModel = never> = {
|
||||||
|
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||||
|
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
|
||||||
|
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedEnumComplexUserRoleFilter<$PrismaModel = never> = {
|
||||||
|
equals?: $Enums.ComplexUserRole | Prisma.EnumComplexUserRoleFieldRefInput<$PrismaModel>
|
||||||
|
in?: $Enums.ComplexUserRole[] | Prisma.ListEnumComplexUserRoleFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: $Enums.ComplexUserRole[] | Prisma.ListEnumComplexUserRoleFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedEnumComplexUserRoleFilter<$PrismaModel> | $Enums.ComplexUserRole
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedEnumComplexUserRoleWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: $Enums.ComplexUserRole | Prisma.EnumComplexUserRoleFieldRefInput<$PrismaModel>
|
||||||
|
in?: $Enums.ComplexUserRole[] | Prisma.ListEnumComplexUserRoleFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: $Enums.ComplexUserRole[] | Prisma.ListEnumComplexUserRoleFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedEnumComplexUserRoleWithAggregatesFilter<$PrismaModel> | $Enums.ComplexUserRole
|
||||||
|
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedEnumComplexUserRoleFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedEnumComplexUserRoleFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedBoolFilter<$PrismaModel = never> = {
|
||||||
|
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedBoolWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
|
||||||
|
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedDecimalFilter<$PrismaModel = never> = {
|
||||||
|
equals?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
in?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel>
|
||||||
|
lt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
lte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
gt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
gte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedDecimalFilter<$PrismaModel> | runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedIntWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||||
|
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
|
||||||
|
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||||
|
_sum?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedFloatFilter<$PrismaModel = never> = {
|
||||||
|
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||||
|
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||||
|
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||||
|
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||||
|
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||||
|
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedDecimalWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
in?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel>
|
||||||
|
lt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
lte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
gt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
gte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedDecimalWithAggregatesFilter<$PrismaModel> | runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||||
|
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_avg?: Prisma.NestedDecimalFilter<$PrismaModel>
|
||||||
|
_sum?: Prisma.NestedDecimalFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedDecimalFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedDecimalFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedEnumDayOfWeekFilter<$PrismaModel = never> = {
|
||||||
|
equals?: $Enums.DayOfWeek | Prisma.EnumDayOfWeekFieldRefInput<$PrismaModel>
|
||||||
|
in?: $Enums.DayOfWeek[] | Prisma.ListEnumDayOfWeekFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: $Enums.DayOfWeek[] | Prisma.ListEnumDayOfWeekFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedEnumDayOfWeekFilter<$PrismaModel> | $Enums.DayOfWeek
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedEnumDayOfWeekWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: $Enums.DayOfWeek | Prisma.EnumDayOfWeekFieldRefInput<$PrismaModel>
|
||||||
|
in?: $Enums.DayOfWeek[] | Prisma.ListEnumDayOfWeekFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: $Enums.DayOfWeek[] | Prisma.ListEnumDayOfWeekFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedEnumDayOfWeekWithAggregatesFilter<$PrismaModel> | $Enums.DayOfWeek
|
||||||
|
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedEnumDayOfWeekFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedEnumDayOfWeekFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedEnumDayOfWeekNullableFilter<$PrismaModel = never> = {
|
||||||
|
equals?: $Enums.DayOfWeek | Prisma.EnumDayOfWeekFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: $Enums.DayOfWeek[] | Prisma.ListEnumDayOfWeekFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: $Enums.DayOfWeek[] | Prisma.ListEnumDayOfWeekFieldRefInput<$PrismaModel> | null
|
||||||
|
not?: Prisma.NestedEnumDayOfWeekNullableFilter<$PrismaModel> | $Enums.DayOfWeek | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedEnumDayOfWeekNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: $Enums.DayOfWeek | Prisma.EnumDayOfWeekFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: $Enums.DayOfWeek[] | Prisma.ListEnumDayOfWeekFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: $Enums.DayOfWeek[] | Prisma.ListEnumDayOfWeekFieldRefInput<$PrismaModel> | null
|
||||||
|
not?: Prisma.NestedEnumDayOfWeekNullableWithAggregatesFilter<$PrismaModel> | $Enums.DayOfWeek | null
|
||||||
|
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedEnumDayOfWeekNullableFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedEnumDayOfWeekNullableFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedEnumCourtBookingStatusFilter<$PrismaModel = never> = {
|
||||||
|
equals?: $Enums.CourtBookingStatus | Prisma.EnumCourtBookingStatusFieldRefInput<$PrismaModel>
|
||||||
|
in?: $Enums.CourtBookingStatus[] | Prisma.ListEnumCourtBookingStatusFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: $Enums.CourtBookingStatus[] | Prisma.ListEnumCourtBookingStatusFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedEnumCourtBookingStatusFilter<$PrismaModel> | $Enums.CourtBookingStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedEnumCourtBookingStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: $Enums.CourtBookingStatus | Prisma.EnumCourtBookingStatusFieldRefInput<$PrismaModel>
|
||||||
|
in?: $Enums.CourtBookingStatus[] | Prisma.ListEnumCourtBookingStatusFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: $Enums.CourtBookingStatus[] | Prisma.ListEnumCourtBookingStatusFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedEnumCourtBookingStatusWithAggregatesFilter<$PrismaModel> | $Enums.CourtBookingStatus
|
||||||
|
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedEnumCourtBookingStatusFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedEnumCourtBookingStatusFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
|
||||||
|
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
|
||||||
|
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedJsonFilter<$PrismaModel = never> =
|
||||||
|
| Prisma.PatchUndefined<
|
||||||
|
Prisma.Either<Required<NestedJsonFilterBase<$PrismaModel>>, Exclude<keyof Required<NestedJsonFilterBase<$PrismaModel>>, 'path'>>,
|
||||||
|
Required<NestedJsonFilterBase<$PrismaModel>>
|
||||||
|
>
|
||||||
|
| Prisma.OptionalFlat<Omit<Required<NestedJsonFilterBase<$PrismaModel>>, 'path'>>
|
||||||
|
|
||||||
|
export type NestedJsonFilterBase<$PrismaModel = never> = {
|
||||||
|
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||||
|
path?: string[]
|
||||||
|
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||||
|
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||||
|
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||||
|
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
37
apps/backend/src/generated/prisma/enums.ts
Normal file
37
apps/backend/src/generated/prisma/enums.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
|
||||||
|
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||||
|
/* eslint-disable */
|
||||||
|
// biome-ignore-all lint: generated file
|
||||||
|
// @ts-nocheck
|
||||||
|
/*
|
||||||
|
* This file exports all enum related types from the schema.
|
||||||
|
*
|
||||||
|
* 🟢 You can import this file directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ComplexUserRole = {
|
||||||
|
ADMIN: 'ADMIN'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type ComplexUserRole = (typeof ComplexUserRole)[keyof typeof ComplexUserRole]
|
||||||
|
|
||||||
|
|
||||||
|
export const DayOfWeek = {
|
||||||
|
MONDAY: 'MONDAY',
|
||||||
|
TUESDAY: 'TUESDAY',
|
||||||
|
WEDNESDAY: 'WEDNESDAY',
|
||||||
|
THURSDAY: 'THURSDAY',
|
||||||
|
FRIDAY: 'FRIDAY',
|
||||||
|
SATURDAY: 'SATURDAY',
|
||||||
|
SUNDAY: 'SUNDAY'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type DayOfWeek = (typeof DayOfWeek)[keyof typeof DayOfWeek]
|
||||||
|
|
||||||
|
|
||||||
|
export const CourtBookingStatus = {
|
||||||
|
CONFIRMED: 'CONFIRMED',
|
||||||
|
CANCELLED: 'CANCELLED'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type CourtBookingStatus = (typeof CourtBookingStatus)[keyof typeof CourtBookingStatus]
|
||||||
294
apps/backend/src/generated/prisma/internal/class.ts
Normal file
294
apps/backend/src/generated/prisma/internal/class.ts
Normal file
File diff suppressed because one or more lines are too long
1675
apps/backend/src/generated/prisma/internal/prismaNamespace.ts
Normal file
1675
apps/backend/src/generated/prisma/internal/prismaNamespace.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,255 @@
|
|||||||
|
|
||||||
|
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||||
|
/* eslint-disable */
|
||||||
|
// biome-ignore-all lint: generated file
|
||||||
|
// @ts-nocheck
|
||||||
|
/*
|
||||||
|
* WARNING: This is an internal file that is subject to change!
|
||||||
|
*
|
||||||
|
* 🛑 Under no circumstances should you import this file directly! 🛑
|
||||||
|
*
|
||||||
|
* All exports from this file are wrapped under a `Prisma` namespace object in the browser.ts file.
|
||||||
|
* While this enables partial backward compatibility, it is not part of the stable public API.
|
||||||
|
*
|
||||||
|
* If you are looking for your Models, Enums, and Input Types, please import them from the respective
|
||||||
|
* model files in the `model` directory!
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as runtime from "@prisma/client/runtime/index-browser"
|
||||||
|
|
||||||
|
export type * from '../models'
|
||||||
|
export type * from './prismaNamespace'
|
||||||
|
|
||||||
|
export const Decimal = runtime.Decimal
|
||||||
|
|
||||||
|
|
||||||
|
export const NullTypes = {
|
||||||
|
DbNull: runtime.NullTypes.DbNull as (new (secret: never) => typeof runtime.DbNull),
|
||||||
|
JsonNull: runtime.NullTypes.JsonNull as (new (secret: never) => typeof runtime.JsonNull),
|
||||||
|
AnyNull: runtime.NullTypes.AnyNull as (new (secret: never) => typeof runtime.AnyNull),
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Helper for filtering JSON entries that have `null` on the database (empty on the db)
|
||||||
|
*
|
||||||
|
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
|
||||||
|
*/
|
||||||
|
export const DbNull = runtime.DbNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper for filtering JSON entries that have JSON `null` values (not empty on the db)
|
||||||
|
*
|
||||||
|
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
|
||||||
|
*/
|
||||||
|
export const JsonNull = runtime.JsonNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper for filtering JSON entries that are `Prisma.DbNull` or `Prisma.JsonNull`
|
||||||
|
*
|
||||||
|
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
|
||||||
|
*/
|
||||||
|
export const AnyNull = runtime.AnyNull
|
||||||
|
|
||||||
|
|
||||||
|
export const ModelName = {
|
||||||
|
Complex: 'Complex',
|
||||||
|
ComplexUser: 'ComplexUser',
|
||||||
|
Sport: 'Sport',
|
||||||
|
Court: 'Court',
|
||||||
|
CourtAvailability: 'CourtAvailability',
|
||||||
|
CourtPriceRule: 'CourtPriceRule',
|
||||||
|
CourtBooking: 'CourtBooking',
|
||||||
|
OnboardingRequest: 'OnboardingRequest',
|
||||||
|
Plan: 'Plan',
|
||||||
|
User: 'User'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Enums
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const TransactionIsolationLevel = runtime.makeStrictEnum({
|
||||||
|
ReadUncommitted: 'ReadUncommitted',
|
||||||
|
ReadCommitted: 'ReadCommitted',
|
||||||
|
RepeatableRead: 'RepeatableRead',
|
||||||
|
Serializable: 'Serializable'
|
||||||
|
} as const)
|
||||||
|
|
||||||
|
export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel]
|
||||||
|
|
||||||
|
|
||||||
|
export const ComplexScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
complexName: 'complexName',
|
||||||
|
physicalAddress: 'physicalAddress',
|
||||||
|
complexSlug: 'complexSlug',
|
||||||
|
adminEmail: 'adminEmail',
|
||||||
|
planCode: 'planCode',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type ComplexScalarFieldEnum = (typeof ComplexScalarFieldEnum)[keyof typeof ComplexScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
|
export const ComplexUserScalarFieldEnum = {
|
||||||
|
complexId: 'complexId',
|
||||||
|
userId: 'userId',
|
||||||
|
role: 'role',
|
||||||
|
createdAt: 'createdAt'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type ComplexUserScalarFieldEnum = (typeof ComplexUserScalarFieldEnum)[keyof typeof ComplexUserScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
|
export const SportScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
name: 'name',
|
||||||
|
slug: 'slug',
|
||||||
|
isActive: 'isActive',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type SportScalarFieldEnum = (typeof SportScalarFieldEnum)[keyof typeof SportScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
|
export const CourtScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
complexId: 'complexId',
|
||||||
|
sportId: 'sportId',
|
||||||
|
name: 'name',
|
||||||
|
slotDurationMinutes: 'slotDurationMinutes',
|
||||||
|
basePrice: 'basePrice',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type CourtScalarFieldEnum = (typeof CourtScalarFieldEnum)[keyof typeof CourtScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
|
export const CourtAvailabilityScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
courtId: 'courtId',
|
||||||
|
dayOfWeek: 'dayOfWeek',
|
||||||
|
startTime: 'startTime',
|
||||||
|
endTime: 'endTime',
|
||||||
|
createdAt: 'createdAt'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type CourtAvailabilityScalarFieldEnum = (typeof CourtAvailabilityScalarFieldEnum)[keyof typeof CourtAvailabilityScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
|
export const CourtPriceRuleScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
courtId: 'courtId',
|
||||||
|
dayOfWeek: 'dayOfWeek',
|
||||||
|
startTime: 'startTime',
|
||||||
|
endTime: 'endTime',
|
||||||
|
price: 'price',
|
||||||
|
isActive: 'isActive',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type CourtPriceRuleScalarFieldEnum = (typeof CourtPriceRuleScalarFieldEnum)[keyof typeof CourtPriceRuleScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
|
export const CourtBookingScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
bookingCode: 'bookingCode',
|
||||||
|
courtId: 'courtId',
|
||||||
|
bookingDate: 'bookingDate',
|
||||||
|
startTime: 'startTime',
|
||||||
|
endTime: 'endTime',
|
||||||
|
customerName: 'customerName',
|
||||||
|
customerPhone: 'customerPhone',
|
||||||
|
status: 'status',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type CourtBookingScalarFieldEnum = (typeof CourtBookingScalarFieldEnum)[keyof typeof CourtBookingScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
|
export const OnboardingRequestScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
fullName: 'fullName',
|
||||||
|
email: 'email',
|
||||||
|
otpHash: 'otpHash',
|
||||||
|
otpExpiresAt: 'otpExpiresAt',
|
||||||
|
otpAttempts: 'otpAttempts',
|
||||||
|
otpLastSentAt: 'otpLastSentAt',
|
||||||
|
otpResendCount: 'otpResendCount',
|
||||||
|
emailVerifiedAt: 'emailVerifiedAt',
|
||||||
|
completedAt: 'completedAt',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type OnboardingRequestScalarFieldEnum = (typeof OnboardingRequestScalarFieldEnum)[keyof typeof OnboardingRequestScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
|
export const PlanScalarFieldEnum = {
|
||||||
|
code: 'code',
|
||||||
|
name: 'name',
|
||||||
|
price: 'price',
|
||||||
|
rules: 'rules',
|
||||||
|
lastUpdatedAt: 'lastUpdatedAt'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type PlanScalarFieldEnum = (typeof PlanScalarFieldEnum)[keyof typeof PlanScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
|
export const UserScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
supabaseUserId: 'supabaseUserId',
|
||||||
|
email: 'email',
|
||||||
|
fullName: 'fullName',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
|
export const SortOrder = {
|
||||||
|
asc: 'asc',
|
||||||
|
desc: 'desc'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
|
||||||
|
|
||||||
|
|
||||||
|
export const JsonNullValueInput = {
|
||||||
|
JsonNull: JsonNull
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type JsonNullValueInput = (typeof JsonNullValueInput)[keyof typeof JsonNullValueInput]
|
||||||
|
|
||||||
|
|
||||||
|
export const QueryMode = {
|
||||||
|
default: 'default',
|
||||||
|
insensitive: 'insensitive'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type QueryMode = (typeof QueryMode)[keyof typeof QueryMode]
|
||||||
|
|
||||||
|
|
||||||
|
export const NullsOrder = {
|
||||||
|
first: 'first',
|
||||||
|
last: 'last'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
|
||||||
|
|
||||||
|
|
||||||
|
export const JsonNullValueFilter = {
|
||||||
|
DbNull: DbNull,
|
||||||
|
JsonNull: JsonNull,
|
||||||
|
AnyNull: AnyNull
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type JsonNullValueFilter = (typeof JsonNullValueFilter)[keyof typeof JsonNullValueFilter]
|
||||||
|
|
||||||
21
apps/backend/src/generated/prisma/models.ts
Normal file
21
apps/backend/src/generated/prisma/models.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
|
||||||
|
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||||
|
/* eslint-disable */
|
||||||
|
// biome-ignore-all lint: generated file
|
||||||
|
// @ts-nocheck
|
||||||
|
/*
|
||||||
|
* This is a barrel export file for all models and their related types.
|
||||||
|
*
|
||||||
|
* 🟢 You can import this file directly.
|
||||||
|
*/
|
||||||
|
export type * from './models/Complex'
|
||||||
|
export type * from './models/ComplexUser'
|
||||||
|
export type * from './models/Sport'
|
||||||
|
export type * from './models/Court'
|
||||||
|
export type * from './models/CourtAvailability'
|
||||||
|
export type * from './models/CourtPriceRule'
|
||||||
|
export type * from './models/CourtBooking'
|
||||||
|
export type * from './models/OnboardingRequest'
|
||||||
|
export type * from './models/Plan'
|
||||||
|
export type * from './models/User'
|
||||||
|
export type * from './commonInputTypes'
|
||||||
1760
apps/backend/src/generated/prisma/models/Complex.ts
Normal file
1760
apps/backend/src/generated/prisma/models/Complex.ts
Normal file
File diff suppressed because it is too large
Load Diff
1434
apps/backend/src/generated/prisma/models/ComplexUser.ts
Normal file
1434
apps/backend/src/generated/prisma/models/ComplexUser.ts
Normal file
File diff suppressed because it is too large
Load Diff
2083
apps/backend/src/generated/prisma/models/Court.ts
Normal file
2083
apps/backend/src/generated/prisma/models/Court.ts
Normal file
File diff suppressed because it is too large
Load Diff
1384
apps/backend/src/generated/prisma/models/CourtAvailability.ts
Normal file
1384
apps/backend/src/generated/prisma/models/CourtAvailability.ts
Normal file
File diff suppressed because it is too large
Load Diff
1566
apps/backend/src/generated/prisma/models/CourtBooking.ts
Normal file
1566
apps/backend/src/generated/prisma/models/CourtBooking.ts
Normal file
File diff suppressed because it is too large
Load Diff
1533
apps/backend/src/generated/prisma/models/CourtPriceRule.ts
Normal file
1533
apps/backend/src/generated/prisma/models/CourtPriceRule.ts
Normal file
File diff suppressed because it is too large
Load Diff
1399
apps/backend/src/generated/prisma/models/OnboardingRequest.ts
Normal file
1399
apps/backend/src/generated/prisma/models/OnboardingRequest.ts
Normal file
File diff suppressed because it is too large
Load Diff
1367
apps/backend/src/generated/prisma/models/Plan.ts
Normal file
1367
apps/backend/src/generated/prisma/models/Plan.ts
Normal file
File diff suppressed because it is too large
Load Diff
1363
apps/backend/src/generated/prisma/models/Sport.ts
Normal file
1363
apps/backend/src/generated/prisma/models/Sport.ts
Normal file
File diff suppressed because it is too large
Load Diff
1359
apps/backend/src/generated/prisma/models/User.ts
Normal file
1359
apps/backend/src/generated/prisma/models/User.ts
Normal file
File diff suppressed because it is too large
Load Diff
8
apps/backend/src/index.ts
Normal file
8
apps/backend/src/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { createApp } from '@/app'
|
||||||
|
import { registerRoutes } from '@/register-routes'
|
||||||
|
|
||||||
|
const app = createApp()
|
||||||
|
registerRoutes(app)
|
||||||
|
|
||||||
|
export type AppType = typeof app
|
||||||
|
export default app
|
||||||
18
apps/backend/src/lib/logger.ts
Normal file
18
apps/backend/src/lib/logger.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import pino from 'pino'
|
||||||
|
|
||||||
|
const isProd = Bun.env.NODE_ENV === 'production'
|
||||||
|
|
||||||
|
export const logger = pino({
|
||||||
|
level: Bun.env.LOG_LEVEL ?? (isProd ? 'info' : 'debug'),
|
||||||
|
transport: isProd
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
target: 'pino-pretty',
|
||||||
|
options: {
|
||||||
|
colorize: true,
|
||||||
|
singleLine: true,
|
||||||
|
translateTime: 'SYS:standard',
|
||||||
|
ignore: 'pid,hostname',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
51
apps/backend/src/lib/mailer.ts
Normal file
51
apps/backend/src/lib/mailer.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import nodemailer from 'nodemailer'
|
||||||
|
|
||||||
|
let cachedTransporter: ReturnType<typeof nodemailer.createTransport> | null = null
|
||||||
|
|
||||||
|
function getTransporter() {
|
||||||
|
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
|
||||||
|
|
||||||
|
if (!smtpHost || !smtpUser || !smtpPass) {
|
||||||
|
throw new Error(
|
||||||
|
'Missing SMTP env vars. Set SMTP_HOST, SMTP_PORT, SMTP_USER and SMTP_PASS.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedTransporter = nodemailer.createTransport({
|
||||||
|
host: smtpHost,
|
||||||
|
port: smtpPort,
|
||||||
|
secure: smtpPort === 465,
|
||||||
|
auth: {
|
||||||
|
user: smtpUser,
|
||||||
|
pass: smtpPass,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return cachedTransporter
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendMail(input: {
|
||||||
|
to: string
|
||||||
|
subject: string
|
||||||
|
html: string
|
||||||
|
text: string
|
||||||
|
}) {
|
||||||
|
const smtpFrom = Bun.env.SMTP_FROM
|
||||||
|
|
||||||
|
if (!smtpFrom) {
|
||||||
|
throw new Error('Missing SMTP_FROM env var.')
|
||||||
|
}
|
||||||
|
|
||||||
|
await getTransporter().sendMail({
|
||||||
|
from: smtpFrom,
|
||||||
|
to: input.to,
|
||||||
|
subject: input.subject,
|
||||||
|
html: input.html,
|
||||||
|
text: input.text,
|
||||||
|
})
|
||||||
|
}
|
||||||
50
apps/backend/src/lib/prisma.ts
Normal file
50
apps/backend/src/lib/prisma.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { PrismaPg } from '@prisma/adapter-pg'
|
||||||
|
import { PrismaClient } from '@/generated/prisma/client'
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }
|
||||||
|
|
||||||
|
function hasExpectedDelegates(client: PrismaClient) {
|
||||||
|
const prismaClient = client as unknown as {
|
||||||
|
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
|
||||||
|
|
||||||
|
if (!databaseUrl) {
|
||||||
|
throw new Error('Missing DATABASE_URL in backend environment.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter = new PrismaPg({
|
||||||
|
connectionString: databaseUrl,
|
||||||
|
})
|
||||||
|
|
||||||
|
return new PrismaClient({ adapter })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPrismaClient() {
|
||||||
|
if (globalForPrisma.prisma && hasExpectedDelegates(globalForPrisma.prisma)) {
|
||||||
|
return globalForPrisma.prisma
|
||||||
|
}
|
||||||
|
|
||||||
|
if (globalForPrisma.prisma && !hasExpectedDelegates(globalForPrisma.prisma)) {
|
||||||
|
void globalForPrisma.prisma.$disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
const prisma = buildPrismaClient()
|
||||||
|
|
||||||
|
if (Bun.env.NODE_ENV !== 'production') {
|
||||||
|
globalForPrisma.prisma = prisma
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma
|
||||||
|
}
|
||||||
|
|
||||||
|
export const db = getPrismaClient()
|
||||||
26
apps/backend/src/lib/supabase-admin.ts
Normal file
26
apps/backend/src/lib/supabase-admin.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js'
|
||||||
|
|
||||||
|
let cachedClient: ReturnType<typeof createClient> | null = null
|
||||||
|
|
||||||
|
export function getSupabaseAdminClient() {
|
||||||
|
if (cachedClient) return cachedClient
|
||||||
|
|
||||||
|
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.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedClient = createClient(supabaseUrl, supabaseServiceRoleKey, {
|
||||||
|
auth: {
|
||||||
|
autoRefreshToken: false,
|
||||||
|
persistSession: false,
|
||||||
|
detectSessionInUrl: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return cachedClient
|
||||||
|
}
|
||||||
19
apps/backend/src/lib/supabase.ts
Normal file
19
apps/backend/src/lib/supabase.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
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.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||||
|
auth: {
|
||||||
|
autoRefreshToken: false,
|
||||||
|
persistSession: false,
|
||||||
|
detectSessionInUrl: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
50
apps/backend/src/middlewares/require-auth.middleware.ts
Normal file
50
apps/backend/src/middlewares/require-auth.middleware.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type { MiddlewareHandler } from 'hono'
|
||||||
|
import { db } from '@/lib/prisma'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
import type { AppEnv } from '@/types/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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase.auth.getUser(token)
|
||||||
|
|
||||||
|
if (error || !data.user) {
|
||||||
|
return c.json({ message: 'Invalid or expired token.' }, 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = data.user.email?.toLowerCase()
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return c.json({ message: 'Auth user does not contain email.' }, 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
const appUser = await db.user.findFirst({
|
||||||
|
where: {
|
||||||
|
supabaseUserId: data.user.id,
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!appUser) {
|
||||||
|
return c.json({ message: 'User not provisioned in backend.' }, 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.set('authUser', data.user)
|
||||||
|
c.set('appUserId', appUser.id)
|
||||||
|
await next()
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { MiddlewareHandler } from 'hono'
|
||||||
|
import type { AppEnv } from '@/types/hono'
|
||||||
|
|
||||||
|
function getSuperAdminEmails(): Set<string> {
|
||||||
|
const raw = Bun.env.SUPER_ADMIN_EMAILS ?? ''
|
||||||
|
const emails = raw
|
||||||
|
.split(',')
|
||||||
|
.map((value) => value.trim().toLowerCase())
|
||||||
|
.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()
|
||||||
|
|
||||||
|
if (role === 'super_admin') {
|
||||||
|
await next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (email && getSuperAdminEmails().has(email)) {
|
||||||
|
await next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ message: 'Este recurso requiere permisos de super admin.' }, 403)
|
||||||
|
}
|
||||||
37
apps/backend/src/modules/complex/complex.routes.ts
Normal file
37
apps/backend/src/modules/complex/complex.routes.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
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.post('/', zValidator('json', createComplexSchema), createComplexHandler)
|
||||||
|
complexRoutes.get('/mine', listMyComplexesHandler)
|
||||||
|
complexRoutes.get(
|
||||||
|
'/slug/:slug',
|
||||||
|
zValidator('param', complexSlugParamsSchema),
|
||||||
|
getComplexBySlugHandler,
|
||||||
|
)
|
||||||
|
|
||||||
|
complexRoutes.get(
|
||||||
|
'/:id',
|
||||||
|
zValidator('param', complexIdParamsSchema),
|
||||||
|
getComplexByIdHandler,
|
||||||
|
)
|
||||||
|
complexRoutes.patch(
|
||||||
|
'/:id',
|
||||||
|
zValidator('param', complexIdParamsSchema),
|
||||||
|
zValidator('json', updateComplexSchema),
|
||||||
|
updateComplexHandler,
|
||||||
|
)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { CreateComplexInput } from '@repo/api-contract'
|
||||||
|
import type { AppContext } from '@/types/hono'
|
||||||
|
import { createComplex } from '@/modules/complex/services/complex.service'
|
||||||
|
|
||||||
|
export async function createComplexHandler(c: AppContext) {
|
||||||
|
const payload = c.req.valid('json' as never) as CreateComplexInput
|
||||||
|
|
||||||
|
const authUser = c.get('authUser')
|
||||||
|
const adminEmail = authUser.email
|
||||||
|
|
||||||
|
if (!adminEmail) {
|
||||||
|
return c.json({ message: 'El usuario autenticado no tiene email.' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const complex = await createComplex({
|
||||||
|
complexName: payload.complexName,
|
||||||
|
physicalAddress: payload.physicalAddress,
|
||||||
|
adminEmail,
|
||||||
|
planCode: payload.planCode,
|
||||||
|
})
|
||||||
|
|
||||||
|
return c.json(complex, 201)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { AppContext } from '@/types/hono'
|
||||||
|
import { getComplexById } from '@/modules/complex/services/complex.service'
|
||||||
|
|
||||||
|
type ComplexIdParams = { id: string }
|
||||||
|
|
||||||
|
export async function getComplexByIdHandler(c: AppContext) {
|
||||||
|
const { id } = c.req.valid('param' as never) as ComplexIdParams
|
||||||
|
|
||||||
|
const complex = await getComplexById(id)
|
||||||
|
|
||||||
|
if (!complex) {
|
||||||
|
return c.json({ message: 'Complejo no encontrado.' }, 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(complex)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { AppContext } from '@/types/hono'
|
||||||
|
import { getComplexBySlug } from '@/modules/complex/services/complex.service'
|
||||||
|
|
||||||
|
type ComplexSlugParams = { slug: string }
|
||||||
|
|
||||||
|
export async function getComplexBySlugHandler(c: AppContext) {
|
||||||
|
const { slug } = c.req.valid('param' as never) as ComplexSlugParams
|
||||||
|
|
||||||
|
const complex = await getComplexBySlug(slug)
|
||||||
|
|
||||||
|
if (!complex) {
|
||||||
|
return c.json({ message: 'Complejo no encontrado.' }, 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(complex)
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { AppContext } from '@/types/hono'
|
||||||
|
import { listMyComplexes } from '@/modules/complex/services/complex.service'
|
||||||
|
|
||||||
|
export async function listMyComplexesHandler(c: AppContext) {
|
||||||
|
const appUserId = c.get('appUserId')
|
||||||
|
const complexes = await listMyComplexes(appUserId)
|
||||||
|
return c.json(complexes)
|
||||||
|
}
|
||||||
@@ -0,0 +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'
|
||||||
|
|
||||||
|
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 existing = await getComplexById(id)
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return c.json({ message: 'Complejo no encontrado.' }, 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
132
apps/backend/src/modules/complex/services/complex.service.ts
Normal file
132
apps/backend/src/modules/complex/services/complex.service.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { v7 as uuidv7 } from 'uuid'
|
||||||
|
import { Prisma } from '@/generated/prisma/client'
|
||||||
|
import { db } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export type CreateComplexInput = {
|
||||||
|
complexName: string
|
||||||
|
physicalAddress: string
|
||||||
|
adminEmail: string
|
||||||
|
planCode?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateComplexInput = {
|
||||||
|
complexName?: string
|
||||||
|
physicalAddress?: string | null
|
||||||
|
complexSlug?: string
|
||||||
|
adminEmail?: string
|
||||||
|
planCode?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(value: string): string {
|
||||||
|
return value
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const existing = await db.complex.findFirst({
|
||||||
|
where: {
|
||||||
|
complexSlug: candidate,
|
||||||
|
...(excludeComplexId
|
||||||
|
? {
|
||||||
|
id: {
|
||||||
|
not: excludeComplexId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existing) return candidate
|
||||||
|
|
||||||
|
index += 1
|
||||||
|
candidate = `${fallback}-${index}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createComplex(input: CreateComplexInput) {
|
||||||
|
const complexSlug = await buildUniqueSlug(input.complexName)
|
||||||
|
|
||||||
|
return db.complex.create({
|
||||||
|
data: {
|
||||||
|
id: uuidv7(),
|
||||||
|
complexName: input.complexName,
|
||||||
|
physicalAddress: input.physicalAddress.trim(),
|
||||||
|
complexSlug,
|
||||||
|
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) {
|
||||||
|
const complexUsers = await db.complexUser.findMany({
|
||||||
|
where: { userId: appUserId },
|
||||||
|
include: {
|
||||||
|
complex: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'asc',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return complexUsers.map((complexUser) => complexUser.complex)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateComplex(id: string, input: UpdateComplexInput) {
|
||||||
|
const data: Record<string, unknown> = {}
|
||||||
|
|
||||||
|
if (input.complexName) {
|
||||||
|
data.complexName = input.complexName
|
||||||
|
data.complexSlug = await buildUniqueSlug(input.complexName, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.complexSlug) {
|
||||||
|
data.complexSlug = await buildUniqueSlug(input.complexSlug, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.adminEmail) {
|
||||||
|
data.adminEmail = input.adminEmail
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.planCode !== undefined) {
|
||||||
|
data.planCode = input.planCode
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.physicalAddress !== undefined) {
|
||||||
|
data.physicalAddress = input.physicalAddress?.trim() ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.complex.update({
|
||||||
|
where: { id },
|
||||||
|
data: data as Prisma.ComplexUncheckedUpdateInput,
|
||||||
|
})
|
||||||
|
}
|
||||||
33
apps/backend/src/modules/court/court.routes.ts
Normal file
33
apps/backend/src/modules/court/court.routes.ts
Normal file
@@ -0,0 +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'
|
||||||
|
|
||||||
|
export const courtRoutes = new Hono<AppEnv>()
|
||||||
|
const complexIdParamsSchema = z.object({ complexId: z.uuid() })
|
||||||
|
const courtIdParamsSchema = z.object({ id: z.uuid() })
|
||||||
|
|
||||||
|
courtRoutes.use('*', requireAuth)
|
||||||
|
|
||||||
|
courtRoutes.get(
|
||||||
|
'/complex/:complexId',
|
||||||
|
zValidator('param', complexIdParamsSchema),
|
||||||
|
listCourtsByComplexHandler,
|
||||||
|
)
|
||||||
|
courtRoutes.post(
|
||||||
|
'/complex/:complexId',
|
||||||
|
zValidator('param', complexIdParamsSchema),
|
||||||
|
zValidator('json', createCourtSchema),
|
||||||
|
createCourtHandler,
|
||||||
|
)
|
||||||
|
courtRoutes.patch(
|
||||||
|
'/:id',
|
||||||
|
zValidator('param', courtIdParamsSchema),
|
||||||
|
zValidator('json', updateCourtSchema),
|
||||||
|
updateCourtHandler,
|
||||||
|
)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { CreateCourtInput } from '@repo/api-contract'
|
||||||
|
import { CourtServiceError, createCourt } from '@/modules/court/services/court.service'
|
||||||
|
import type { AppContext } from '@/types/hono'
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
try {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { CourtServiceError, listCourtsByComplex } from '@/modules/court/services/court.service'
|
||||||
|
import type { AppContext } from '@/types/hono'
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const courts = await listCourtsByComplex(complexId, appUserId)
|
||||||
|
return c.json(courts)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof CourtServiceError) {
|
||||||
|
return c.json({ message: error.message }, error.status)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { UpdateCourtInput } from '@repo/api-contract'
|
||||||
|
import { CourtServiceError, updateCourt } from '@/modules/court/services/court.service'
|
||||||
|
import type { AppContext } from '@/types/hono'
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
try {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
339
apps/backend/src/modules/court/services/court.service.ts
Normal file
339
apps/backend/src/modules/court/services/court.service.ts
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
type CourtWithRelations = Court & {
|
||||||
|
sport: Sport
|
||||||
|
availabilities: CourtAvailability[]
|
||||||
|
priceRules: CourtPriceRule[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CourtServiceError extends Error {
|
||||||
|
status: 400 | 403 | 404 | 409
|
||||||
|
|
||||||
|
constructor(message: string, status: 400 | 403 | 404 | 409) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertAvailabilityRanges(
|
||||||
|
availability: Array<{
|
||||||
|
dayOfWeek: DayOfWeek
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
const grouped = new Map<DayOfWeek, Array<{ start: number; end: number }>>()
|
||||||
|
|
||||||
|
for (const range of availability) {
|
||||||
|
const start = toMinutes(range.startTime)
|
||||||
|
const end = toMinutes(range.endTime)
|
||||||
|
|
||||||
|
if (start >= end) {
|
||||||
|
throw new 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ranges of grouped.values()) {
|
||||||
|
ranges.sort((a, b) => a.start - b.start)
|
||||||
|
|
||||||
|
for (let index = 1; index < ranges.length; index += 1) {
|
||||||
|
const previous = ranges[index - 1]
|
||||||
|
const current = ranges[index]
|
||||||
|
|
||||||
|
if (previous.end > current.start) {
|
||||||
|
throw new CourtServiceError(
|
||||||
|
'Hay rangos horarios superpuestos para el mismo dia.',
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureComplexAccess(complexId: string, appUserId: string) {
|
||||||
|
const complexUser = await db.complexUser.findUnique({
|
||||||
|
where: {
|
||||||
|
complexId_userId: {
|
||||||
|
complexId,
|
||||||
|
userId: appUserId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
complex: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
plan: {
|
||||||
|
select: {
|
||||||
|
rules: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!complexUser) {
|
||||||
|
throw new CourtServiceError(
|
||||||
|
'No tienes permisos para administrar este complejo.',
|
||||||
|
403,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return complexUser.complex
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureActiveSport(sportId: string) {
|
||||||
|
const sport = await db.sport.findFirst({
|
||||||
|
where: {
|
||||||
|
id: sportId,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!sport) {
|
||||||
|
throw new CourtServiceError('El deporte seleccionado no existe o esta inactivo.', 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enforcePlanCourtLimit(complexId: string) {
|
||||||
|
const complex = await db.complex.findUnique({
|
||||||
|
where: { id: complexId },
|
||||||
|
select: {
|
||||||
|
plan: {
|
||||||
|
select: {
|
||||||
|
rules: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!complex?.plan) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (maxCourtViolation) {
|
||||||
|
throw new CourtServiceError(maxCourtViolation.message, 409)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCourtByIdForUser(courtId: string, appUserId: string) {
|
||||||
|
return db.court.findFirst({
|
||||||
|
where: {
|
||||||
|
id: courtId,
|
||||||
|
complex: {
|
||||||
|
users: {
|
||||||
|
some: {
|
||||||
|
userId: appUserId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
sport: true,
|
||||||
|
availabilities: {
|
||||||
|
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
||||||
|
},
|
||||||
|
priceRules: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCourtResponse(court: CourtWithRelations) {
|
||||||
|
return {
|
||||||
|
id: court.id,
|
||||||
|
complexId: court.complexId,
|
||||||
|
name: court.name,
|
||||||
|
sportId: court.sportId,
|
||||||
|
sport: {
|
||||||
|
id: court.sport.id,
|
||||||
|
name: court.sport.name,
|
||||||
|
slug: court.sport.slug,
|
||||||
|
},
|
||||||
|
slotDurationMinutes: court.slotDurationMinutes,
|
||||||
|
basePrice: Number(court.basePrice),
|
||||||
|
availability: court.availabilities.map((availability) => ({
|
||||||
|
id: availability.id,
|
||||||
|
dayOfWeek: availability.dayOfWeek,
|
||||||
|
startTime: availability.startTime,
|
||||||
|
endTime: availability.endTime,
|
||||||
|
})),
|
||||||
|
priceRules: court.priceRules.map((rule) => ({
|
||||||
|
id: rule.id,
|
||||||
|
dayOfWeek: rule.dayOfWeek,
|
||||||
|
startTime: rule.startTime,
|
||||||
|
endTime: rule.endTime,
|
||||||
|
price: Number(rule.price),
|
||||||
|
isActive: rule.isActive,
|
||||||
|
})),
|
||||||
|
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)
|
||||||
|
|
||||||
|
const courts = await db.court.findMany({
|
||||||
|
where: { complexId },
|
||||||
|
include: {
|
||||||
|
sport: true,
|
||||||
|
availabilities: {
|
||||||
|
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
||||||
|
},
|
||||||
|
priceRules: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'asc',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
const createdCourt = await db.$transaction(async (tx) => {
|
||||||
|
const court = await tx.court.create({
|
||||||
|
data: {
|
||||||
|
id: uuidv7(),
|
||||||
|
complexId,
|
||||||
|
sportId: input.sportId,
|
||||||
|
name: input.name.trim(),
|
||||||
|
slotDurationMinutes: input.slotDurationMinutes,
|
||||||
|
basePrice: input.basePrice,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await tx.courtAvailability.createMany({
|
||||||
|
data: input.availability.map((availability) => ({
|
||||||
|
id: uuidv7(),
|
||||||
|
courtId: court.id,
|
||||||
|
dayOfWeek: availability.dayOfWeek,
|
||||||
|
startTime: availability.startTime,
|
||||||
|
endTime: availability.endTime,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
return court
|
||||||
|
})
|
||||||
|
|
||||||
|
const court = await getCourtByIdForUser(createdCourt.id, appUserId)
|
||||||
|
|
||||||
|
if (!court) {
|
||||||
|
throw new CourtServiceError('No se pudo obtener la cancha creada.', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapCourtResponse(court)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.sportId) {
|
||||||
|
await ensureActiveSport(input.sportId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.availability) {
|
||||||
|
assertAvailabilityRanges(input.availability)
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.$transaction(async (tx) => {
|
||||||
|
await tx.court.update({
|
||||||
|
where: {
|
||||||
|
id: courtId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
...(input.name !== undefined ? { name: input.name.trim() } : {}),
|
||||||
|
...(input.sportId !== undefined ? { sportId: input.sportId } : {}),
|
||||||
|
...(input.slotDurationMinutes !== undefined
|
||||||
|
? { slotDurationMinutes: input.slotDurationMinutes }
|
||||||
|
: {}),
|
||||||
|
...(input.basePrice !== undefined ? { basePrice: input.basePrice } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (input.availability) {
|
||||||
|
await tx.courtAvailability.deleteMany({
|
||||||
|
where: { courtId },
|
||||||
|
})
|
||||||
|
|
||||||
|
await tx.courtAvailability.createMany({
|
||||||
|
data: input.availability.map((availability) => ({
|
||||||
|
id: uuidv7(),
|
||||||
|
courtId,
|
||||||
|
dayOfWeek: availability.dayOfWeek,
|
||||||
|
startTime: availability.startTime,
|
||||||
|
endTime: availability.endTime,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatedCourt = await getCourtByIdForUser(courtId, appUserId)
|
||||||
|
|
||||||
|
if (!updatedCourt) {
|
||||||
|
throw new CourtServiceError('Cancha no encontrada.', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapCourtResponse(updatedCourt)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { OnboardingCompleteInput } from '@repo/api-contract'
|
||||||
|
import type { AppContext } from '@/types/hono'
|
||||||
|
import {
|
||||||
|
OnboardingError,
|
||||||
|
completeOnboarding,
|
||||||
|
} from '@/modules/onboarding/services/onboarding.service'
|
||||||
|
|
||||||
|
export async function completeOnboardingHandler(c: AppContext) {
|
||||||
|
const payload = c.req.valid('json' as never) as OnboardingCompleteInput
|
||||||
|
|
||||||
|
try {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return c.json({ message: error.message }, 400)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { OnboardingResendOtpInput } from '@repo/api-contract'
|
||||||
|
import type { AppContext } from '@/types/hono'
|
||||||
|
import {
|
||||||
|
OnboardingError,
|
||||||
|
resendOnboardingOtp,
|
||||||
|
} from '@/modules/onboarding/services/onboarding.service'
|
||||||
|
|
||||||
|
export async function resendOtpHandler(c: AppContext) {
|
||||||
|
const payload = c.req.valid('json' as never) as OnboardingResendOtpInput
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await resendOnboardingOtp(payload)
|
||||||
|
return c.json(result)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OnboardingError) {
|
||||||
|
return c.json({ message: error.message }, error.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return c.json({ message: error.message }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { OnboardingStartInput } from '@repo/api-contract'
|
||||||
|
import type { AppContext } from '@/types/hono'
|
||||||
|
import { startOnboarding } from '@/modules/onboarding/services/onboarding.service'
|
||||||
|
|
||||||
|
export async function startOnboardingHandler(c: AppContext) {
|
||||||
|
const payload = c.req.valid('json' as never) as OnboardingStartInput
|
||||||
|
|
||||||
|
const result = await startOnboarding(payload)
|
||||||
|
return c.json(result, 202)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { OnboardingVerifyOtpInput } from '@repo/api-contract'
|
||||||
|
import type { AppContext } from '@/types/hono'
|
||||||
|
import { verifyOnboardingOtp } from '@/modules/onboarding/services/onboarding.service'
|
||||||
|
|
||||||
|
export async function verifyOtpHandler(c: AppContext) {
|
||||||
|
const payload = c.req.valid('json' as never) as OnboardingVerifyOtpInput
|
||||||
|
const result = await verifyOnboardingOtp(payload)
|
||||||
|
|
||||||
|
return c.json(result)
|
||||||
|
}
|
||||||
39
apps/backend/src/modules/onboarding/onboarding.routes.ts
Normal file
39
apps/backend/src/modules/onboarding/onboarding.routes.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
export const onboardingRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
|
onboardingRoutes.post(
|
||||||
|
'/start',
|
||||||
|
zValidator('json', onboardingStartSchema),
|
||||||
|
startOnboardingHandler,
|
||||||
|
)
|
||||||
|
|
||||||
|
onboardingRoutes.post(
|
||||||
|
'/verify-otp',
|
||||||
|
zValidator('json', onboardingVerifyOtpSchema),
|
||||||
|
verifyOtpHandler,
|
||||||
|
)
|
||||||
|
|
||||||
|
onboardingRoutes.post(
|
||||||
|
'/resend-otp',
|
||||||
|
zValidator('json', onboardingResendOtpSchema),
|
||||||
|
resendOtpHandler,
|
||||||
|
)
|
||||||
|
|
||||||
|
onboardingRoutes.post(
|
||||||
|
'/complete',
|
||||||
|
zValidator('json', onboardingCompleteSchema),
|
||||||
|
completeOnboardingHandler,
|
||||||
|
)
|
||||||
@@ -0,0 +1,510 @@
|
|||||||
|
import { createHash, randomInt } from 'node:crypto'
|
||||||
|
import { v7 as uuidv7 } from 'uuid'
|
||||||
|
import type { User as SupabaseAuthUser } from '@supabase/supabase-js'
|
||||||
|
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'
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResendOtpResult = {
|
||||||
|
message: string
|
||||||
|
requestId: string
|
||||||
|
email: string
|
||||||
|
expiresAt: string
|
||||||
|
cooldownSeconds: number
|
||||||
|
remainingAttempts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type StartOnboardingResult = {
|
||||||
|
message: string
|
||||||
|
requestId: string
|
||||||
|
email: string
|
||||||
|
expiresAt: string
|
||||||
|
cooldownSeconds: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OnboardingError extends Error {
|
||||||
|
status: 400 | 404 | 409 | 429
|
||||||
|
|
||||||
|
constructor(message: string, status: 400 | 404 | 409 | 429 = 400) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'OnboardingError'
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowPlusMinutes(minutes: number): Date {
|
||||||
|
const date = new Date()
|
||||||
|
date.setMinutes(date.getMinutes() + minutes)
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEmail(email: string): string {
|
||||||
|
return email.trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOtpCode(): string {
|
||||||
|
return randomInt(0, 10 ** OTP_LENGTH).toString().padStart(OTP_LENGTH, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashValue(value: string): string {
|
||||||
|
return createHash('sha256').update(value).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRemainingAttempts(otpAttempts: number): number {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(value: string): string {
|
||||||
|
return value
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildUniqueComplexSlug(complexName: string): Promise<string> {
|
||||||
|
const base = slugify(complexName)
|
||||||
|
const fallback = base.length > 0 ? base : `complex-${uuidv7().slice(0, 8)}`
|
||||||
|
|
||||||
|
let candidate = fallback
|
||||||
|
let index = 1
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const existing = await db.complex.findFirst({
|
||||||
|
where: { complexSlug: candidate },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
index += 1
|
||||||
|
candidate = `${fallback}-${index}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendOtpEmail(email: string, otpCode: string) {
|
||||||
|
await sendMail({
|
||||||
|
to: email,
|
||||||
|
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
|
||||||
|
|
||||||
|
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}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const found = data.users.find(
|
||||||
|
(user) => user.email?.toLowerCase() === email.toLowerCase(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.users.length < perPage) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
page += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSupabaseUser(params: {
|
||||||
|
email: string
|
||||||
|
fullName: string
|
||||||
|
password: string
|
||||||
|
}): Promise<string> {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(
|
||||||
|
`No se pudo actualizar usuario existente en Supabase: ${error.message}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return existingUser.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await getSupabaseAdminClient().auth.admin.createUser({
|
||||||
|
email: params.email,
|
||||||
|
password: params.password,
|
||||||
|
email_confirm: true,
|
||||||
|
user_metadata: {
|
||||||
|
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'}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
const request = await db.onboardingRequest.create({
|
||||||
|
data: {
|
||||||
|
id: uuidv7(),
|
||||||
|
fullName: input.fullName.trim(),
|
||||||
|
email,
|
||||||
|
otpHash: hashValue(otpCode),
|
||||||
|
otpExpiresAt: expiresAt,
|
||||||
|
otpAttempts: 0,
|
||||||
|
otpLastSentAt: now,
|
||||||
|
otpResendCount: 0,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
otpExpiresAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await sendOtpEmail(email, otpCode)
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Te enviamos un codigo OTP para validar tu direccion.',
|
||||||
|
requestId: request.id,
|
||||||
|
email: request.email,
|
||||||
|
expiresAt: request.otpExpiresAt.toISOString(),
|
||||||
|
cooldownSeconds: OTP_RESEND_COOLDOWN_SECONDS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyOnboardingOtp(
|
||||||
|
input: OnboardingVerifyOtpInput,
|
||||||
|
): Promise<VerifyOtpResult> {
|
||||||
|
const request = await db.onboardingRequest.findUnique({
|
||||||
|
where: { id: input.requestId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
return {
|
||||||
|
message: 'Solicitud de onboarding invalida o expirada.',
|
||||||
|
verified: false,
|
||||||
|
requestId: input.requestId,
|
||||||
|
email: null,
|
||||||
|
expiresAt: null,
|
||||||
|
remainingAttempts: 0,
|
||||||
|
cooldownSeconds: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.completedAt) {
|
||||||
|
return {
|
||||||
|
message: 'Este onboarding ya fue completado.',
|
||||||
|
verified: true,
|
||||||
|
requestId: request.id,
|
||||||
|
email: request.email,
|
||||||
|
expiresAt: request.otpExpiresAt.toISOString(),
|
||||||
|
remainingAttempts: getRemainingAttempts(request.otpAttempts),
|
||||||
|
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.otpExpiresAt < new Date()) {
|
||||||
|
return {
|
||||||
|
message: 'El codigo OTP vencio. Solicita un nuevo codigo.',
|
||||||
|
verified: false,
|
||||||
|
requestId: request.id,
|
||||||
|
email: request.email,
|
||||||
|
expiresAt: request.otpExpiresAt.toISOString(),
|
||||||
|
remainingAttempts: getRemainingAttempts(request.otpAttempts),
|
||||||
|
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.otpAttempts >= OTP_MAX_ATTEMPTS) {
|
||||||
|
return {
|
||||||
|
message: 'Se agotaron los intentos de OTP. Solicita un nuevo codigo.',
|
||||||
|
verified: false,
|
||||||
|
requestId: request.id,
|
||||||
|
email: request.email,
|
||||||
|
expiresAt: request.otpExpiresAt.toISOString(),
|
||||||
|
remainingAttempts: 0,
|
||||||
|
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const otpMatches = hashValue(input.otp) === request.otpHash
|
||||||
|
|
||||||
|
if (!otpMatches) {
|
||||||
|
const updated = await db.onboardingRequest.update({
|
||||||
|
where: { id: request.id },
|
||||||
|
data: {
|
||||||
|
otpAttempts: {
|
||||||
|
increment: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
otpAttempts: true,
|
||||||
|
otpExpiresAt: true,
|
||||||
|
otpLastSentAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const remainingAttempts = getRemainingAttempts(updated.otpAttempts)
|
||||||
|
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
remainingAttempts > 0
|
||||||
|
? 'Codigo OTP invalido.'
|
||||||
|
: 'Se agotaron los intentos de OTP. Solicita un nuevo codigo.',
|
||||||
|
verified: false,
|
||||||
|
requestId: updated.id,
|
||||||
|
email: updated.email,
|
||||||
|
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 {
|
||||||
|
message: 'Email verificado correctamente.',
|
||||||
|
verified: true,
|
||||||
|
requestId: request.id,
|
||||||
|
email: request.email,
|
||||||
|
expiresAt: request.otpExpiresAt.toISOString(),
|
||||||
|
remainingAttempts: getRemainingAttempts(request.otpAttempts),
|
||||||
|
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resendOnboardingOtp(
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.completedAt) {
|
||||||
|
throw new OnboardingError('Este onboarding ya fue completado.', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.emailVerifiedAt) {
|
||||||
|
throw new OnboardingError('El email ya fue verificado.', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cooldownSeconds = getCooldownSeconds(request.otpLastSentAt)
|
||||||
|
if (cooldownSeconds > 0) {
|
||||||
|
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 updated = await db.onboardingRequest.update({
|
||||||
|
where: { id: request.id },
|
||||||
|
data: {
|
||||||
|
otpHash: hashValue(otpCode),
|
||||||
|
otpExpiresAt: expiresAt,
|
||||||
|
otpAttempts: 0,
|
||||||
|
otpLastSentAt: now,
|
||||||
|
otpResendCount: {
|
||||||
|
increment: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
otpExpiresAt: true,
|
||||||
|
otpAttempts: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await sendOtpEmail(updated.email, otpCode)
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Te enviamos un nuevo codigo OTP.',
|
||||||
|
requestId: updated.id,
|
||||||
|
email: updated.email,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onboardingRequest.otpExpiresAt < new Date()) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onboardingRequest.completedAt) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabaseUserId = await ensureSupabaseUser({
|
||||||
|
email: onboardingRequest.email,
|
||||||
|
fullName: onboardingRequest.fullName,
|
||||||
|
password: input.password,
|
||||||
|
})
|
||||||
|
|
||||||
|
const complexSlug = await buildUniqueComplexSlug(input.complexName)
|
||||||
|
|
||||||
|
const result = await db.$transaction(async (tx) => {
|
||||||
|
const user = await tx.user.upsert({
|
||||||
|
where: { email: onboardingRequest.email },
|
||||||
|
update: {
|
||||||
|
fullName: onboardingRequest.fullName,
|
||||||
|
supabaseUserId,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: uuidv7(),
|
||||||
|
fullName: onboardingRequest.fullName,
|
||||||
|
email: onboardingRequest.email,
|
||||||
|
supabaseUserId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const complex = await tx.complex.create({
|
||||||
|
data: {
|
||||||
|
id: uuidv7(),
|
||||||
|
complexName: input.complexName.trim(),
|
||||||
|
physicalAddress: input.physicalAddress.trim(),
|
||||||
|
complexSlug,
|
||||||
|
adminEmail: onboardingRequest.email,
|
||||||
|
planCode: input.planCode,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await tx.complexUser.create({
|
||||||
|
data: {
|
||||||
|
complexId: complex.id,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
23
apps/backend/src/modules/plan/handlers/list-plans.handler.ts
Normal file
23
apps/backend/src/modules/plan/handlers/list-plans.handler.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { AppContext } from '@/types/hono'
|
||||||
|
import { db } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export async function listPlansHandler(c: AppContext) {
|
||||||
|
const plans = await db.plan.findMany({
|
||||||
|
select: {
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
price: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
price: 'asc',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return c.json(
|
||||||
|
plans.map((plan) => ({
|
||||||
|
code: plan.code,
|
||||||
|
name: plan.name,
|
||||||
|
price: Number(plan.price),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
7
apps/backend/src/modules/plan/plan.routes.ts
Normal file
7
apps/backend/src/modules/plan/plan.routes.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Hono } from 'hono'
|
||||||
|
import { listPlansHandler } from '@/modules/plan/handlers/list-plans.handler'
|
||||||
|
import type { AppEnv } from '@/types/hono'
|
||||||
|
|
||||||
|
export const planRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
|
planRoutes.get('/', listPlansHandler)
|
||||||
63
apps/backend/src/modules/plan/services/plan-rules.service.ts
Normal file
63
apps/backend/src/modules/plan/services/plan-rules.service.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { PlanRules } from '@repo/api-contract'
|
||||||
|
import { planRulesSchema } from '@repo/api-contract'
|
||||||
|
|
||||||
|
export type PlanUsageSnapshot = {
|
||||||
|
courtsCount: number
|
||||||
|
bookingsToday: number
|
||||||
|
activeUsersCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlanViolationCode =
|
||||||
|
| 'MAX_COURTS_REACHED'
|
||||||
|
| 'MAX_BOOKINGS_PER_DAY_REACHED'
|
||||||
|
| 'MAX_ACTIVE_USERS_REACHED'
|
||||||
|
|
||||||
|
export type PlanViolation = {
|
||||||
|
code: PlanViolationCode
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePlanRules(input: unknown): PlanRules {
|
||||||
|
return planRulesSchema.parse(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
typeof rules.limits.maxActiveUsers === 'number' &&
|
||||||
|
typeof usage.activeUsersCount === 'number' &&
|
||||||
|
usage.activeUsersCount >= rules.limits.maxActiveUsers
|
||||||
|
) {
|
||||||
|
violations.push({
|
||||||
|
code: 'MAX_ACTIVE_USERS_REACHED',
|
||||||
|
message: `El plan permite hasta ${rules.limits.maxActiveUsers} usuarios activos.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFeatureEnabled(
|
||||||
|
rules: PlanRules,
|
||||||
|
feature: keyof PlanRules['features'],
|
||||||
|
): boolean {
|
||||||
|
return rules.features[feature]
|
||||||
|
}
|
||||||
@@ -0,0 +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'
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
try {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import {
|
||||||
|
PublicBookingServiceError,
|
||||||
|
getPublicBookingConfirmation,
|
||||||
|
} from '@/modules/public-booking/services/public-booking.service'
|
||||||
|
import type { AppContext } from '@/types/hono'
|
||||||
|
|
||||||
|
type BookingCodeParams = {
|
||||||
|
complexSlug: string
|
||||||
|
bookingCode: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPublicBookingConfirmationHandler(c: AppContext) {
|
||||||
|
const { complexSlug, bookingCode } = c.req.valid('param' as never) as BookingCodeParams
|
||||||
|
|
||||||
|
try {
|
||||||
|
const booking = await getPublicBookingConfirmation(complexSlug, bookingCode)
|
||||||
|
return c.json(booking)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PublicBookingServiceError) {
|
||||||
|
return c.json({ message: error.message }, error.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +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'
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
try {
|
||||||
|
const availability = await listPublicAvailability(complexSlug, query)
|
||||||
|
return c.json(availability)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PublicBookingServiceError) {
|
||||||
|
return c.json({ message: error.message }, error.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
publicBookingRoutes.post(
|
||||||
|
'/complex/:complexSlug',
|
||||||
|
zValidator('param', complexSlugParamsSchema),
|
||||||
|
zValidator('json', createPublicBookingSchema),
|
||||||
|
createPublicBookingHandler,
|
||||||
|
)
|
||||||
|
|
||||||
|
publicBookingRoutes.get(
|
||||||
|
'/complex/:complexSlug/confirmation/:bookingCode',
|
||||||
|
zValidator('param', confirmationParamsSchema),
|
||||||
|
getPublicBookingConfirmationHandler,
|
||||||
|
)
|
||||||
@@ -0,0 +1,616 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
type Slot = {
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComplexWithPublicBookingData = Awaited<ReturnType<typeof getComplexWithBookingData>>
|
||||||
|
|
||||||
|
const DAY_OF_WEEK_BY_INDEX: DayOfWeek[] = [
|
||||||
|
'SUNDAY',
|
||||||
|
'MONDAY',
|
||||||
|
'TUESDAY',
|
||||||
|
'WEDNESDAY',
|
||||||
|
'THURSDAY',
|
||||||
|
'FRIDAY',
|
||||||
|
'SATURDAY',
|
||||||
|
]
|
||||||
|
const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||||
|
const BOOKING_CODE_LENGTH = 6
|
||||||
|
|
||||||
|
export class PublicBookingServiceError extends Error {
|
||||||
|
status: 400 | 403 | 404 | 409
|
||||||
|
|
||||||
|
constructor(message: string, status: 400 | 403 | 404 | 409) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIsoDate(date: string): { bookingDate: Date; dayOfWeek: DayOfWeek } {
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date)
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
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 bookingDate = new Date(Date.UTC(year, month - 1, day))
|
||||||
|
|
||||||
|
if (
|
||||||
|
Number.isNaN(bookingDate.getTime()) ||
|
||||||
|
bookingDate.getUTCFullYear() !== year ||
|
||||||
|
bookingDate.getUTCMonth() + 1 !== month ||
|
||||||
|
bookingDate.getUTCDate() !== day
|
||||||
|
) {
|
||||||
|
throw new PublicBookingServiceError('La fecha enviada no es valida.', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayOfWeek = DAY_OF_WEEK_BY_INDEX[bookingDate.getUTCDay()]
|
||||||
|
|
||||||
|
if (!dayOfWeek) {
|
||||||
|
throw new PublicBookingServiceError('No se pudo resolver el dia de la semana.', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bookingDate, dayOfWeek }
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIsoDate(date: Date): string {
|
||||||
|
return date.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateBookingCode(): string {
|
||||||
|
let code = ''
|
||||||
|
|
||||||
|
for (let index = 0; index < BOOKING_CODE_LENGTH; index += 1) {
|
||||||
|
code += BOOKING_CODE_ALPHABET[randomInt(0, BOOKING_CODE_ALPHABET.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasOverlap(slot: Slot, existing: Slot): boolean {
|
||||||
|
return slot.startTime < existing.endTime && slot.endTime > existing.startTime
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSlots(
|
||||||
|
availability: Array<{
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
}>,
|
||||||
|
slotDurationMinutes: number,
|
||||||
|
): Slot[] {
|
||||||
|
const slots: Slot[] = []
|
||||||
|
|
||||||
|
for (const range of availability) {
|
||||||
|
const start = toMinutes(range.startTime)
|
||||||
|
const end = toMinutes(range.endTime)
|
||||||
|
|
||||||
|
for (
|
||||||
|
let current = start;
|
||||||
|
current + slotDurationMinutes <= end;
|
||||||
|
current += slotDurationMinutes
|
||||||
|
) {
|
||||||
|
slots.push({
|
||||||
|
startTime: minutesToTime(current),
|
||||||
|
endTime: minutesToTime(current + slotDurationMinutes),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return slots
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSports(data: ComplexWithPublicBookingData) {
|
||||||
|
const map = new Map<string, { id: string; name: string; slug: string }>()
|
||||||
|
|
||||||
|
for (const court of data.courts) {
|
||||||
|
if (!court.sport.isActive) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.has(court.sport.id)) {
|
||||||
|
map.set(court.sport.id, {
|
||||||
|
id: court.sport.id,
|
||||||
|
name: court.sport.name,
|
||||||
|
slug: court.sport.slug,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name, 'es'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSportSelection(
|
||||||
|
availableSports: Array<{ id: string }>,
|
||||||
|
sportId: string | undefined,
|
||||||
|
): { sportSelectionRequired: boolean; selectedSportId: string | undefined } {
|
||||||
|
const sportSelectionRequired = availableSports.length > 1
|
||||||
|
|
||||||
|
if (sportSelectionRequired && !sportId) {
|
||||||
|
throw new PublicBookingServiceError(
|
||||||
|
'Debes seleccionar un deporte para consultar disponibilidad.',
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sportId && !availableSports.some((sport) => sport.id === sportId)) {
|
||||||
|
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) {
|
||||||
|
const complex = await db.complex.findUnique({
|
||||||
|
where: { complexSlug },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
complexName: true,
|
||||||
|
complexSlug: true,
|
||||||
|
plan: {
|
||||||
|
select: {
|
||||||
|
rules: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
courts: {
|
||||||
|
include: {
|
||||||
|
sport: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
availabilities: {
|
||||||
|
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!complex) {
|
||||||
|
throw new PublicBookingServiceError('Complejo no encontrado.', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (complex.plan) {
|
||||||
|
const rules = parsePlanRules(complex.plan.rules)
|
||||||
|
|
||||||
|
if (!rules.features.publicBookingPage) {
|
||||||
|
throw new PublicBookingServiceError(
|
||||||
|
'El complejo no tiene habilitada la reserva publica.',
|
||||||
|
403,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return complex
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBookingResponse(input: {
|
||||||
|
bookingId: string
|
||||||
|
bookingCode: string
|
||||||
|
bookingDate: Date
|
||||||
|
createdAt: Date
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
customerName: string
|
||||||
|
customerPhone: string
|
||||||
|
status: 'CONFIRMED' | 'CANCELLED'
|
||||||
|
court: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
complexId: string
|
||||||
|
complexName: string
|
||||||
|
complexSlug: string
|
||||||
|
sport: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: input.bookingId,
|
||||||
|
bookingCode: input.bookingCode,
|
||||||
|
complexId: input.court.complexId,
|
||||||
|
complexName: input.court.complexName,
|
||||||
|
complexSlug: input.court.complexSlug,
|
||||||
|
courtId: input.court.id,
|
||||||
|
courtName: input.court.name,
|
||||||
|
sport: input.court.sport,
|
||||||
|
date: formatIsoDate(input.bookingDate),
|
||||||
|
startTime: input.startTime,
|
||||||
|
endTime: input.endTime,
|
||||||
|
customerName: input.customerName,
|
||||||
|
customerPhone: input.customerPhone,
|
||||||
|
status: input.status,
|
||||||
|
createdAt: input.createdAt.toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPublicBookingConfirmation(complexSlug: string, bookingCode: string) {
|
||||||
|
const normalizedBookingCode = bookingCode.toUpperCase()
|
||||||
|
const booking = await db.courtBooking.findFirst({
|
||||||
|
where: {
|
||||||
|
bookingCode: normalizedBookingCode,
|
||||||
|
court: {
|
||||||
|
complex: {
|
||||||
|
complexSlug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
bookingCode: true,
|
||||||
|
bookingDate: true,
|
||||||
|
startTime: true,
|
||||||
|
endTime: true,
|
||||||
|
status: true,
|
||||||
|
createdAt: true,
|
||||||
|
court: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
sport: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
complex: {
|
||||||
|
select: {
|
||||||
|
complexName: true,
|
||||||
|
complexSlug: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
throw new PublicBookingServiceError('Reserva no encontrada.', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bookingCode: booking.bookingCode,
|
||||||
|
complexName: booking.court.complex.complexName,
|
||||||
|
complexSlug: booking.court.complex.complexSlug,
|
||||||
|
date: formatIsoDate(booking.bookingDate),
|
||||||
|
startTime: booking.startTime,
|
||||||
|
endTime: booking.endTime,
|
||||||
|
courtName: booking.court.name,
|
||||||
|
sport: {
|
||||||
|
id: booking.court.sport.id,
|
||||||
|
name: booking.court.sport.name,
|
||||||
|
slug: booking.court.sport.slug,
|
||||||
|
},
|
||||||
|
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
|
||||||
|
|
||||||
|
if (query.sportId && !sports.some((sport) => sport.id === query.sportId)) {
|
||||||
|
throw new PublicBookingServiceError('El deporte seleccionado no pertenece al complejo.', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedSportId = sportSelectionRequired
|
||||||
|
? query.sportId
|
||||||
|
: (query.sportId ?? sports[0]?.id)
|
||||||
|
|
||||||
|
const candidateCourts = complex.courts.filter((court) => {
|
||||||
|
if (!court.sport.isActive) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedSportId) {
|
||||||
|
return court.sportId === selectedSportId
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const courtIds = candidateCourts.map((court) => court.id)
|
||||||
|
|
||||||
|
const bookings =
|
||||||
|
courtIds.length > 0
|
||||||
|
? await db.courtBooking.findMany({
|
||||||
|
where: {
|
||||||
|
bookingDate,
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
courtId: {
|
||||||
|
in: courtIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
courtId: true,
|
||||||
|
startTime: true,
|
||||||
|
endTime: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
|
||||||
|
const bookingsByCourt = new Map<string, Slot[]>()
|
||||||
|
|
||||||
|
for (const booking of bookings) {
|
||||||
|
const current = bookingsByCourt.get(booking.courtId) ?? []
|
||||||
|
current.push({
|
||||||
|
startTime: booking.startTime,
|
||||||
|
endTime: booking.endTime,
|
||||||
|
})
|
||||||
|
bookingsByCourt.set(booking.courtId, current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const courts = candidateCourts
|
||||||
|
.map((court) => {
|
||||||
|
const dayAvailability = court.availabilities.filter(
|
||||||
|
(availability) => availability.dayOfWeek === dayOfWeek,
|
||||||
|
)
|
||||||
|
|
||||||
|
const allSlots = buildSlots(dayAvailability, court.slotDurationMinutes)
|
||||||
|
const occupiedSlots = bookingsByCourt.get(court.id) ?? []
|
||||||
|
|
||||||
|
const availableSlots = allSlots.filter(
|
||||||
|
(slot) => !occupiedSlots.some((occupied) => hasOverlap(slot, occupied)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
courtId: court.id,
|
||||||
|
courtName: court.name,
|
||||||
|
sport: {
|
||||||
|
id: court.sport.id,
|
||||||
|
name: court.sport.name,
|
||||||
|
slug: court.sport.slug,
|
||||||
|
},
|
||||||
|
slotDurationMinutes: court.slotDurationMinutes,
|
||||||
|
availabilityDay: dayOfWeek,
|
||||||
|
availableSlots,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((court) => court.availableSlots.length > 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
complexId: complex.id,
|
||||||
|
complexName: complex.complexName,
|
||||||
|
complexSlug: complex.complexSlug,
|
||||||
|
date: query.date,
|
||||||
|
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)
|
||||||
|
|
||||||
|
const selectedCourt = complex.courts.find(
|
||||||
|
(court) => court.id === input.courtId && court.sport.isActive,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!selectedCourt) {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.sportId && input.sportId !== selectedCourt.sportId) {
|
||||||
|
throw new PublicBookingServiceError(
|
||||||
|
'La cancha seleccionada no corresponde al deporte indicado.',
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayAvailability = selectedCourt.availabilities.filter(
|
||||||
|
(availability) => availability.dayOfWeek === dayOfWeek,
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!slotExists) {
|
||||||
|
throw new PublicBookingServiceError(
|
||||||
|
'El horario seleccionado no esta disponible para esa cancha.',
|
||||||
|
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 bookingsForDate = await tx.courtBooking.count({
|
||||||
|
where: {
|
||||||
|
bookingDate,
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
court: {
|
||||||
|
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',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (maxBookingsViolation) {
|
||||||
|
throw new PublicBookingServiceError(maxBookingsViolation.message, 409)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlappingBooking = await tx.courtBooking.findFirst({
|
||||||
|
where: {
|
||||||
|
courtId: selectedCourt.id,
|
||||||
|
bookingDate,
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
startTime: {
|
||||||
|
lt: selectedSlot.endTime,
|
||||||
|
},
|
||||||
|
endTime: {
|
||||||
|
gt: selectedSlot.startTime,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (overlappingBooking) {
|
||||||
|
throw new PublicBookingServiceError('El horario seleccionado ya fue reservado.', 409)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.courtBooking.create({
|
||||||
|
data: {
|
||||||
|
id: uuidv7(),
|
||||||
|
bookingCode: generateBookingCode(),
|
||||||
|
courtId: selectedCourt.id,
|
||||||
|
bookingDate,
|
||||||
|
startTime: selectedSlot.startTime,
|
||||||
|
endTime: selectedSlot.endTime,
|
||||||
|
customerName: input.customerName.trim(),
|
||||||
|
customerPhone: input.customerPhone.trim(),
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
bookingCode: true,
|
||||||
|
bookingDate: true,
|
||||||
|
createdAt: true,
|
||||||
|
startTime: true,
|
||||||
|
endTime: true,
|
||||||
|
customerName: true,
|
||||||
|
customerPhone: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return mapBookingResponse({
|
||||||
|
bookingId: booking.id,
|
||||||
|
bookingCode: booking.bookingCode,
|
||||||
|
bookingDate: booking.bookingDate,
|
||||||
|
createdAt: booking.createdAt,
|
||||||
|
startTime: booking.startTime,
|
||||||
|
endTime: booking.endTime,
|
||||||
|
customerName: booking.customerName,
|
||||||
|
customerPhone: booking.customerPhone,
|
||||||
|
status: booking.status,
|
||||||
|
court: {
|
||||||
|
id: selectedCourt.id,
|
||||||
|
name: selectedCourt.name,
|
||||||
|
complexId: complex.id,
|
||||||
|
complexName: complex.complexName,
|
||||||
|
complexSlug: complex.complexSlug,
|
||||||
|
sport: {
|
||||||
|
id: selectedCourt.sport.id,
|
||||||
|
name: selectedCourt.sport.name,
|
||||||
|
slug: selectedCourt.sport.slug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PublicBookingServiceError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const prismaError = error as {
|
||||||
|
code?: string
|
||||||
|
meta?: {
|
||||||
|
target?: string[] | string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prismaError.code === 'P2002') {
|
||||||
|
const targets = Array.isArray(prismaError.meta?.target)
|
||||||
|
? prismaError.meta?.target
|
||||||
|
: [prismaError.meta?.target]
|
||||||
|
const isBookingCodeCollision = targets.some((target) =>
|
||||||
|
String(target).includes('booking_code'),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isBookingCodeCollision) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new PublicBookingServiceError('El horario seleccionado ya fue reservado.', 409)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new PublicBookingServiceError(
|
||||||
|
'No se pudo generar un codigo de reserva unico. Intenta nuevamente.',
|
||||||
|
409,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +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'
|
||||||
|
|
||||||
|
export async function createSportHandler(c: AppContext) {
|
||||||
|
const payload = c.req.valid('json' as never) as CreateSportInput
|
||||||
|
|
||||||
|
try {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { AppContext } from '@/types/hono'
|
||||||
|
import { listSports } from '@/modules/sport/services/sport.service'
|
||||||
|
|
||||||
|
export async function listSportsHandler(c: AppContext) {
|
||||||
|
const sports = await listSports()
|
||||||
|
return c.json(sports)
|
||||||
|
}
|
||||||
@@ -0,0 +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'
|
||||||
|
|
||||||
|
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 existing = await getSportById(id)
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return c.json({ message: 'Deporte no encontrado.' }, 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
96
apps/backend/src/modules/sport/services/sport.service.ts
Normal file
96
apps/backend/src/modules/sport/services/sport.service.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { v7 as uuidv7 } from 'uuid'
|
||||||
|
import { Prisma } from '@/generated/prisma/client'
|
||||||
|
import { db } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export type CreateSportInput = {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateSportInput = {
|
||||||
|
name?: string
|
||||||
|
isActive?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(value: string): string {
|
||||||
|
return value
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const existing = await db.sport.findFirst({
|
||||||
|
where: {
|
||||||
|
slug: candidate,
|
||||||
|
...(excludeSportId
|
||||||
|
? {
|
||||||
|
id: {
|
||||||
|
not: excludeSportId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existing) return candidate
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
return db.sport.create({
|
||||||
|
data: {
|
||||||
|
id: uuidv7(),
|
||||||
|
name: input.name.trim(),
|
||||||
|
slug,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSportById(id: string) {
|
||||||
|
return db.sport.findUnique({ where: { id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSport(id: string, input: UpdateSportInput) {
|
||||||
|
const data: Prisma.SportUncheckedUpdateInput = {}
|
||||||
|
|
||||||
|
if (input.name) {
|
||||||
|
data.name = input.name
|
||||||
|
data.slug = await buildUniqueSlug(input.name, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.isActive !== undefined) {
|
||||||
|
data.isActive = input.isActive
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.sport.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
30
apps/backend/src/modules/sport/sport.routes.ts
Normal file
30
apps/backend/src/modules/sport/sport.routes.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
export const sportRoutes = new Hono<AppEnv>()
|
||||||
|
const sportIdParamsSchema = z.object({ id: z.uuid() })
|
||||||
|
|
||||||
|
sportRoutes.use('*', requireAuth)
|
||||||
|
|
||||||
|
sportRoutes.get('/', listSportsHandler)
|
||||||
|
sportRoutes.post(
|
||||||
|
'/',
|
||||||
|
requireSuperAdmin,
|
||||||
|
zValidator('json', createSportSchema),
|
||||||
|
createSportHandler,
|
||||||
|
)
|
||||||
|
sportRoutes.patch(
|
||||||
|
'/:id',
|
||||||
|
requireSuperAdmin,
|
||||||
|
zValidator('param', sportIdParamsSchema),
|
||||||
|
zValidator('json', updateSportSchema),
|
||||||
|
updateSportHandler,
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { AppContext } from '@/types/hono'
|
||||||
|
import { getUserProfile } from '@/modules/user/services/user.service'
|
||||||
|
|
||||||
|
export function getUserProfileHandler(c: AppContext) {
|
||||||
|
const authUser = c.get('authUser')
|
||||||
|
const profile = getUserProfile(authUser)
|
||||||
|
return c.json(profile)
|
||||||
|
}
|
||||||
28
apps/backend/src/modules/user/services/user.service.ts
Normal file
28
apps/backend/src/modules/user/services/user.service.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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?.name === 'string' && user.user_metadata.name) ||
|
||||||
|
(user.email ?? 'Usuario')
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
fullName,
|
||||||
|
email: user.email ?? null,
|
||||||
|
role,
|
||||||
|
avatarUrl,
|
||||||
|
createdAt: user.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/backend/src/modules/user/user.routes.ts
Normal file
9
apps/backend/src/modules/user/user.routes.ts
Normal file
@@ -0,0 +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'
|
||||||
|
|
||||||
|
export const userRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
|
userRoutes.use('*', requireAuth)
|
||||||
|
userRoutes.get('/profile', getUserProfileHandler)
|
||||||
23
apps/backend/src/register-routes.ts
Normal file
23
apps/backend/src/register-routes.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { registerApiRoutes } from '@repo/api-contract'
|
||||||
|
import type { Hono } from 'hono'
|
||||||
|
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'
|
||||||
|
|
||||||
|
export function registerRoutes(app: Hono<AppEnv>) {
|
||||||
|
registerApiRoutes(app, {
|
||||||
|
user: userRoutes,
|
||||||
|
plans: planRoutes,
|
||||||
|
onboarding: onboardingRoutes,
|
||||||
|
complexes: complexRoutes,
|
||||||
|
sports: sportRoutes,
|
||||||
|
courts: courtRoutes,
|
||||||
|
})
|
||||||
|
|
||||||
|
app.route('/api/public-bookings', publicBookingRoutes)
|
||||||
|
}
|
||||||
16
apps/backend/src/server.ts
Normal file
16
apps/backend/src/server.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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 server = Bun.serve({
|
||||||
|
hostname,
|
||||||
|
port,
|
||||||
|
fetch: app.fetch,
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ url: `http://${server.hostname}:${server.port}` },
|
||||||
|
'backend_listening',
|
||||||
|
)
|
||||||
3
apps/backend/src/types/auth-user.ts
Normal file
3
apps/backend/src/types/auth-user.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import type { User } from '@supabase/supabase-js'
|
||||||
|
|
||||||
|
export type AuthUser = User
|
||||||
13
apps/backend/src/types/hono.ts
Normal file
13
apps/backend/src/types/hono.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Context } from 'hono'
|
||||||
|
import type { AuthUser } from '@/types/auth-user'
|
||||||
|
|
||||||
|
export type AppVariables = {
|
||||||
|
authUser: AuthUser
|
||||||
|
appUserId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppEnv = {
|
||||||
|
Variables: AppVariables
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppContext = Context<AppEnv>
|
||||||
15
apps/backend/tsconfig.json
Normal file
15
apps/backend/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["bun"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "hono/jsx"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
apps/frontend/.env
Normal file
3
apps/frontend/.env
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
VITE_SUPABASE_URL=https://afzzqgvdkkpyxmifpaya.supabase.co
|
||||||
|
VITE_SUPABASE_ANON_KEY=sb_publishable_RgMLVBTCTClhK-1Ks_X02Q_Sq1Z1Pq-
|
||||||
|
VITE_API_BASE_URL=http://localhost:3000
|
||||||
3
apps/frontend/.env.example
Normal file
3
apps/frontend/.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
VITE_SUPABASE_URL=https://your-project-ref.supabase.co
|
||||||
|
VITE_SUPABASE_ANON_KEY=your-anon-key
|
||||||
|
VITE_API_BASE_URL=http://localhost:3000
|
||||||
24
apps/frontend/.gitignore
vendored
Normal file
24
apps/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user