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

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