Initial commit

This commit is contained in:
Jose Selesan
2026-04-08 22:53:11 -03:00
commit 9ae270609d
179 changed files with 28096 additions and 0 deletions

12
apps/backend/.env Normal file
View 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
View 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
View File

@@ -0,0 +1,2 @@
# deps
node_modules/

40
apps/backend/Dockerfile Normal file
View 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
View 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
View 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"
}
}

View 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'),
},
})

View 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")
}

View 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")
}

View File

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

View File

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

View File

@@ -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";

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "complexes_complex_slug_idx" ON "complexes"("complex_slug");

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "complexes"
ADD COLUMN "physical_address" VARCHAR(200);

View 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"

View 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")
}

View 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")
}

View 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

View 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)
})

View File

@@ -0,0 +1,8 @@
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}

View 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)
})

View 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

View 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
View 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
}

View 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

View 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

View 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
}

View 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]

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -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]

View 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'

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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

View 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',
},
},
})

View 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,
})
}

View 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()

View 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
}

View 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,
},
})

View 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()
}

View File

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

View 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,
)

View File

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

View File

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

View File

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

View File

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

View File

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

View 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,
})
}

View 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,
)

View File

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

View File

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

View File

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

View 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)
}

View File

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

View File

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

View File

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

View File

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

View 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,
)

View File

@@ -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,
}
}

View 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),
})),
)
}

View 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)

View 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]
}

View File

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

View File

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

View File

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

View File

@@ -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,
)

View File

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

View File

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

View File

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

View File

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

View 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,
})
}

View 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,
)

View File

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

View 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,
}
}

View 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)

View 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)
}

View 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',
)

View File

@@ -0,0 +1,3 @@
import type { User } from '@supabase/supabase-js'
export type AuthUser = User

View 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>

View 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
View 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

View 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
View 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?

37
apps/frontend/Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
# 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/frontend/package.json apps/frontend/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/frontend apps/frontend
COPY packages/api-contract packages/api-contract
# Build-time variables for Vite.
ARG VITE_API_BASE_URL=http://localhost:3000
ARG VITE_SUPABASE_URL=
ARG VITE_SUPABASE_ANON_KEY=
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
ENV VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
ENV VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}
RUN bun --filter frontend build
FROM nginx:1.27-alpine AS runner
WORKDIR /usr/share/nginx/html
COPY apps/frontend/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/apps/frontend/dist ./
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

73
apps/frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
apps/frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More