From 28fd51f9262198293d2b2d6eba8b53ac50c841d8 Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Mon, 20 Apr 2026 15:47:47 -0300 Subject: [PATCH] feat: implement complex selection flow --- AGENTS.md | 38 +++++++ .../src/generated/prisma/internal/class.ts | 2 +- .../src/modules/complex/complex.routes.ts | 4 + .../handlers/get-current-complex.handler.ts | 22 +++++ .../handlers/select-complex.handler.ts | 38 +++++++ .../complex/services/complex.service.ts | 47 ++++++++- .../user/handlers/get-user-profile.handler.ts | 8 +- .../src/modules/user/services/user.service.ts | 22 ++++- apps/frontend/src/features/home/home-page.tsx | 44 ++++----- .../src/features/login/login-page.tsx | 26 ++++- .../select-complex/select-complex-page.tsx | 98 +++++++++++++++++++ apps/frontend/src/lib/api-client.ts | 14 ++- apps/frontend/src/routeTree.gen.ts | 42 ++++++++ apps/frontend/src/routes/auth-callback.tsx | 50 ++++++++++ apps/frontend/src/routes/select-complex.tsx | 6 ++ packages/api-contract/src/complex.ts | 18 ++++ packages/api-contract/src/index.ts | 12 ++- 17 files changed, 453 insertions(+), 38 deletions(-) create mode 100644 apps/backend/src/modules/complex/handlers/get-current-complex.handler.ts create mode 100644 apps/backend/src/modules/complex/handlers/select-complex.handler.ts create mode 100644 apps/frontend/src/features/select-complex/select-complex-page.tsx create mode 100644 apps/frontend/src/routes/auth-callback.tsx create mode 100644 apps/frontend/src/routes/select-complex.tsx diff --git a/AGENTS.md b/AGENTS.md index eb0198a..14357be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,3 +111,41 @@ To enable login with Google: 3. The backend configures `socialProviders.google` automatically. 4. Frontend uses `authClient.signIn.social({ provider: 'google', callbackURL: 'http://localhost:5173' })`. + +## Complex Selection (Multi-tenancy) + +The app supports users with multiple complexes. Each user has a role per complex (`ADMIN` or `EMPLOYEE`), stored in `ComplexUser` table. + +### Flow + +1. **Login** (password or Google OAuth): + - After auth, call `GET /api/complexes/mine` to get user's complexes + - If 1 complex → auto-select via `POST /api/complexes/select` → redirect to `/` + - If >1 complexes → redirect to `/select-complex` + +2. **Select Complex Page** (`/select-complex`): + - List all complexes with roles + - User selects one → `POST /api/complexes/select` → set cookie → redirect to `/` + +3. **Home Page** (`/`): + - If no cookie (first visit) → auto-select if 1 complex, else redirect + - Use `GET /api/complexes/me` to get current selected complex + +### Cookie + +- Name: `selected-complex-id` +- httpOnly, secure (prod), sameSite: strict +- MaxAge: 30 days +- Set by `POST /api/complexes/select` + +### Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/complexes/mine` | List all complexes with role | +| GET | `/api/complexes/me` | Get currently selected complex | +| POST | `/api/complexes/select` | Select complex (sets cookie) | + +### Profile Role + +The profile page shows the user's role for the **selected complex**, not the global user role. This is fetched from `ComplexUser` table using the cookie value. diff --git a/apps/backend/src/generated/prisma/internal/class.ts b/apps/backend/src/generated/prisma/internal/class.ts index 80ae773..cedd782 100644 --- a/apps/backend/src/generated/prisma/internal/class.ts +++ b/apps/backend/src/generated/prisma/internal/class.ts @@ -20,7 +20,7 @@ const config: runtime.GetPrismaClientConfig = { "clientVersion": "7.6.0", "engineVersion": "75cbdc1eb7150937890ad5465d861175c6624711", "activeProvider": "postgresql", - "inlineSchema": "model User {\n id String @id\n name String\n email String\n emailVerified Boolean @default(false)\n image String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n sessions Session[]\n accounts Account[]\n role String @default(\"member\")\n complexes ComplexUser[]\n\n @@unique([email])\n @@map(\"users\")\n}\n\nmodel Session {\n id String @id\n expiresAt DateTime\n token String\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n ipAddress String?\n userAgent String?\n userId String\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@unique([token])\n @@index([userId])\n @@map(\"sessions\")\n}\n\nmodel Account {\n id String @id\n accountId String\n providerId String\n userId String\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n accessToken String?\n refreshToken String?\n idToken String?\n accessTokenExpiresAt DateTime?\n refreshTokenExpiresAt DateTime?\n scope String?\n password String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([userId])\n @@map(\"accounts\")\n}\n\nmodel Verification {\n id String @id\n identifier String\n value String\n expiresAt DateTime\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([identifier])\n @@map(\"verification\")\n}\n\nmodel Complex {\n id String @id @db.Uuid\n complexName String @map(\"complex_name\")\n physicalAddress String? @map(\"physical_address\") @db.VarChar(200)\n city String? @map(\"city\") @db.VarChar(100)\n state String? @map(\"state\") @db.VarChar(100)\n country String? @map(\"country\") @db.VarChar(100)\n complexSlug String @unique @map(\"complex_slug\")\n adminEmail String @map(\"admin_email\")\n planCode String? @map(\"plan_code\") @db.VarChar(10)\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n plan Plan? @relation(fields: [planCode], references: [code])\n users ComplexUser[]\n courts Court[]\n\n @@index([planCode])\n @@index([complexSlug])\n @@map(\"complexes\")\n}\n\nenum ComplexUserRole {\n ADMIN\n EMPLOYEE\n}\n\nmodel ComplexUser {\n complexId String @map(\"complex_id\") @db.Uuid\n userId String @map(\"user_id\")\n role ComplexUserRole @default(ADMIN)\n createdAt DateTime @default(now()) @map(\"created_at\")\n complex Complex @relation(fields: [complexId], references: [id], onDelete: Cascade)\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@id([complexId, userId])\n @@index([userId])\n @@map(\"complex_users\")\n}\n\nenum DayOfWeek {\n MONDAY\n TUESDAY\n WEDNESDAY\n THURSDAY\n FRIDAY\n SATURDAY\n SUNDAY\n}\n\nenum CourtBookingStatus {\n CONFIRMED\n CANCELLED\n COMPLETED\n}\n\nmodel Sport {\n id String @id @db.Uuid\n name String @unique\n slug String @unique\n isActive Boolean @default(true) @map(\"is_active\")\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n courts Court[]\n\n @@map(\"sports\")\n}\n\nmodel Court {\n id String @id @db.Uuid\n complexId String @map(\"complex_id\") @db.Uuid\n sportId String @map(\"sport_id\") @db.Uuid\n name String\n slotDurationMinutes Int @map(\"slot_duration_minutes\")\n basePrice Decimal @map(\"base_price\") @db.Decimal(19, 2)\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n complex Complex @relation(fields: [complexId], references: [id], onDelete: Cascade)\n sport Sport @relation(fields: [sportId], references: [id])\n availabilities CourtAvailability[]\n priceRules CourtPriceRule[]\n bookings CourtBooking[]\n\n @@index([complexId])\n @@index([sportId])\n @@map(\"courts\")\n}\n\nmodel CourtAvailability {\n id String @id @db.Uuid\n courtId String @map(\"court_id\") @db.Uuid\n dayOfWeek DayOfWeek @map(\"day_of_week\")\n startTime String @map(\"start_time\") @db.VarChar(5)\n endTime String @map(\"end_time\") @db.VarChar(5)\n createdAt DateTime @default(now()) @map(\"created_at\")\n court Court @relation(fields: [courtId], references: [id], onDelete: Cascade)\n\n @@index([courtId, dayOfWeek])\n @@map(\"court_availabilities\")\n}\n\nmodel CourtPriceRule {\n id String @id @db.Uuid\n courtId String @map(\"court_id\") @db.Uuid\n dayOfWeek DayOfWeek? @map(\"day_of_week\")\n startTime String? @map(\"start_time\") @db.VarChar(5)\n endTime String? @map(\"end_time\") @db.VarChar(5)\n price Decimal @db.Decimal(19, 2)\n isActive Boolean @default(true) @map(\"is_active\")\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n court Court @relation(fields: [courtId], references: [id], onDelete: Cascade)\n\n @@index([courtId, isActive])\n @@map(\"court_price_rules\")\n}\n\nmodel CourtBooking {\n id String @id @db.Uuid\n bookingCode String @unique @map(\"booking_code\") @db.VarChar(8)\n courtId String @map(\"court_id\") @db.Uuid\n bookingDate DateTime @map(\"booking_date\") @db.Date\n startTime String @map(\"start_time\") @db.VarChar(5)\n endTime String @map(\"end_time\") @db.VarChar(5)\n customerName String @map(\"customer_name\") @db.VarChar(120)\n customerPhone String @map(\"customer_phone\") @db.VarChar(30)\n status CourtBookingStatus @default(CONFIRMED)\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n court Court @relation(fields: [courtId], references: [id], onDelete: Cascade)\n\n @@unique([courtId, bookingDate, startTime])\n @@index([courtId, bookingDate])\n @@index([bookingDate])\n @@map(\"court_bookings\")\n}\n\nmodel OnboardingRequest {\n id String @id @db.Uuid\n fullName String @map(\"full_name\")\n email String\n otpHash String @map(\"otp_hash\")\n otpExpiresAt DateTime @map(\"otp_expires_at\")\n otpAttempts Int @default(0) @map(\"otp_attempts\")\n otpLastSentAt DateTime @map(\"otp_last_sent_at\")\n otpResendCount Int @default(0) @map(\"otp_resend_count\")\n emailVerifiedAt DateTime? @map(\"email_verified_at\")\n completedAt DateTime? @map(\"completed_at\")\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n\n @@index([email])\n @@index([otpExpiresAt])\n @@map(\"onboarding_requests\")\n}\n\nmodel PasswordResetRequest {\n id String @id @db.Uuid\n email String\n otpHash String @map(\"otp_hash\")\n otpExpiresAt DateTime @map(\"otp_expires_at\")\n otpAttempts Int @default(0) @map(\"otp_attempts\")\n otpLastSentAt DateTime @map(\"otp_last_sent_at\")\n otpResendCount Int @default(0) @map(\"otp_resend_count\")\n usedAt DateTime? @map(\"used_at\")\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n\n @@index([email])\n @@index([otpExpiresAt])\n @@map(\"password_reset_requests\")\n}\n\nmodel Plan {\n code String @id @db.VarChar(10)\n name String @db.VarChar(30)\n price Decimal @db.Decimal(19, 2)\n rules Json\n lastUpdatedAt DateTime @default(now()) @map(\"last_updated_at\")\n complexes Complex[]\n\n @@map(\"plans\")\n}\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../src/generated/prisma\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\n// model User {\n// id String @id @db.Uuid\n// supabaseUserId String @unique @map(\"supabase_user_id\") @db.Uuid\n// email String @unique\n// fullName String @map(\"full_name\")\n// createdAt DateTime @default(now()) @map(\"created_at\")\n// updatedAt DateTime @updatedAt @map(\"updated_at\")\n// complexes ComplexUser[]\n\n// @@map(\"users\")\n// }\n", + "inlineSchema": "model User {\n id String @id\n name String\n email String\n emailVerified Boolean @default(false)\n image String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n sessions Session[]\n accounts Account[]\n role String @default(\"member\")\n complexes ComplexUser[]\n\n @@unique([email])\n @@map(\"users\")\n}\n\nmodel Session {\n id String @id\n expiresAt DateTime\n token String\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n ipAddress String?\n userAgent String?\n userId String\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@unique([token])\n @@index([userId])\n @@map(\"sessions\")\n}\n\nmodel Account {\n id String @id\n accountId String\n providerId String\n userId String\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n accessToken String?\n refreshToken String?\n idToken String?\n accessTokenExpiresAt DateTime?\n refreshTokenExpiresAt DateTime?\n scope String?\n password String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([userId])\n @@map(\"accounts\")\n}\n\nmodel Verification {\n id String @id\n identifier String\n value String\n expiresAt DateTime\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([identifier])\n @@map(\"verification\")\n}\n\nmodel Complex {\n id String @id @db.Uuid\n complexName String @map(\"complex_name\")\n physicalAddress String? @map(\"physical_address\") @db.VarChar(200)\n city String? @map(\"city\") @db.VarChar(100)\n state String? @map(\"state\") @db.VarChar(100)\n country String? @map(\"country\") @db.VarChar(100)\n complexSlug String @unique @map(\"complex_slug\")\n adminEmail String @map(\"admin_email\")\n planCode String? @map(\"plan_code\") @db.VarChar(10)\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n plan Plan? @relation(fields: [planCode], references: [code])\n users ComplexUser[]\n courts Court[]\n\n @@index([planCode])\n @@index([complexSlug])\n @@map(\"complexes\")\n}\n\nenum ComplexUserRole {\n ADMIN\n EMPLOYEE\n}\n\nmodel ComplexUser {\n complexId String @map(\"complex_id\") @db.Uuid\n userId String @map(\"user_id\")\n role ComplexUserRole @default(ADMIN)\n createdAt DateTime @default(now()) @map(\"created_at\")\n complex Complex @relation(fields: [complexId], references: [id], onDelete: Cascade)\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@id([complexId, userId])\n @@index([userId])\n @@map(\"complex_users\")\n}\n\nenum DayOfWeek {\n MONDAY\n TUESDAY\n WEDNESDAY\n THURSDAY\n FRIDAY\n SATURDAY\n SUNDAY\n}\n\nenum CourtBookingStatus {\n CONFIRMED\n CANCELLED\n COMPLETED\n}\n\nmodel Sport {\n id String @id @db.Uuid\n name String @unique\n slug String @unique\n isActive Boolean @default(true) @map(\"is_active\")\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n courts Court[]\n\n @@map(\"sports\")\n}\n\nmodel Court {\n id String @id @db.Uuid\n complexId String @map(\"complex_id\") @db.Uuid\n sportId String @map(\"sport_id\") @db.Uuid\n name String\n slotDurationMinutes Int @map(\"slot_duration_minutes\")\n basePrice Decimal @map(\"base_price\") @db.Decimal(19, 2)\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n complex Complex @relation(fields: [complexId], references: [id], onDelete: Cascade)\n sport Sport @relation(fields: [sportId], references: [id])\n availabilities CourtAvailability[]\n priceRules CourtPriceRule[]\n bookings CourtBooking[]\n\n @@index([complexId])\n @@index([sportId])\n @@map(\"courts\")\n}\n\nmodel CourtAvailability {\n id String @id @db.Uuid\n courtId String @map(\"court_id\") @db.Uuid\n dayOfWeek DayOfWeek @map(\"day_of_week\")\n startTime String @map(\"start_time\") @db.VarChar(5)\n endTime String @map(\"end_time\") @db.VarChar(5)\n createdAt DateTime @default(now()) @map(\"created_at\")\n court Court @relation(fields: [courtId], references: [id], onDelete: Cascade)\n\n @@index([courtId, dayOfWeek])\n @@map(\"court_availabilities\")\n}\n\nmodel CourtPriceRule {\n id String @id @db.Uuid\n courtId String @map(\"court_id\") @db.Uuid\n dayOfWeek DayOfWeek? @map(\"day_of_week\")\n startTime String? @map(\"start_time\") @db.VarChar(5)\n endTime String? @map(\"end_time\") @db.VarChar(5)\n price Decimal @db.Decimal(19, 2)\n isActive Boolean @default(true) @map(\"is_active\")\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n court Court @relation(fields: [courtId], references: [id], onDelete: Cascade)\n\n @@index([courtId, isActive])\n @@map(\"court_price_rules\")\n}\n\nmodel CourtBooking {\n id String @id @db.Uuid\n bookingCode String @unique @map(\"booking_code\") @db.VarChar(8)\n courtId String @map(\"court_id\") @db.Uuid\n bookingDate DateTime @map(\"booking_date\") @db.Date\n startTime String @map(\"start_time\") @db.VarChar(5)\n endTime String @map(\"end_time\") @db.VarChar(5)\n customerName String @map(\"customer_name\") @db.VarChar(120)\n customerPhone String @map(\"customer_phone\") @db.VarChar(30)\n status CourtBookingStatus @default(CONFIRMED)\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n court Court @relation(fields: [courtId], references: [id], onDelete: Cascade)\n\n @@unique([courtId, bookingDate, startTime])\n @@index([courtId, bookingDate])\n @@index([bookingDate])\n @@map(\"court_bookings\")\n}\n\nmodel OnboardingRequest {\n id String @id @db.Uuid\n fullName String @map(\"full_name\")\n email String\n otpHash String @map(\"otp_hash\")\n otpExpiresAt DateTime @map(\"otp_expires_at\")\n otpAttempts Int @default(0) @map(\"otp_attempts\")\n otpLastSentAt DateTime @map(\"otp_last_sent_at\")\n otpResendCount Int @default(0) @map(\"otp_resend_count\")\n emailVerifiedAt DateTime? @map(\"email_verified_at\")\n completedAt DateTime? @map(\"completed_at\")\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n\n @@index([email])\n @@index([otpExpiresAt])\n @@map(\"onboarding_requests\")\n}\n\nmodel PasswordResetRequest {\n id String @id @db.Uuid\n email String\n otpHash String @map(\"otp_hash\")\n otpExpiresAt DateTime @map(\"otp_expires_at\")\n otpAttempts Int @default(0) @map(\"otp_attempts\")\n otpLastSentAt DateTime @map(\"otp_last_sent_at\")\n otpResendCount Int @default(0) @map(\"otp_resend_count\")\n usedAt DateTime? @map(\"used_at\")\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n\n @@index([email])\n @@index([otpExpiresAt])\n @@map(\"password_reset_requests\")\n}\n\nmodel Plan {\n code String @id @db.VarChar(10)\n name String @db.VarChar(30)\n price Decimal @db.Decimal(19, 2)\n rules Json\n lastUpdatedAt DateTime @default(now()) @map(\"last_updated_at\")\n complexes Complex[]\n\n @@map(\"plans\")\n}\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../src/generated/prisma\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\n// User model is defined in auth.prisma (Better Auth schema).\n", "runtimeDataModel": { "models": {}, "enums": {}, diff --git a/apps/backend/src/modules/complex/complex.routes.ts b/apps/backend/src/modules/complex/complex.routes.ts index af9581c..79894c2 100644 --- a/apps/backend/src/modules/complex/complex.routes.ts +++ b/apps/backend/src/modules/complex/complex.routes.ts @@ -2,7 +2,9 @@ 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 { getCurrentComplexHandler } from '@/modules/complex/handlers/get-current-complex.handler'; import { listMyComplexesHandler } from '@/modules/complex/handlers/list-my-complexes.handler'; +import { selectComplexHandler } from '@/modules/complex/handlers/select-complex.handler'; import { updateComplexHandler } from '@/modules/complex/handlers/update-complex.handler'; import type { AppEnv } from '@/types/hono'; import { zValidator } from '@hono/zod-validator'; @@ -17,6 +19,8 @@ const complexSlugParamsSchema = z.object({ slug: z.string().trim().min(1) }); complexRoutes.use('*', requireAuth); complexRoutes.post('/', zValidator('json', createComplexSchema), createComplexHandler); +complexRoutes.get('/me', getCurrentComplexHandler); +complexRoutes.post('/select', selectComplexHandler); complexRoutes.get('/mine', listMyComplexesHandler); complexRoutes.get( '/slug/:slug', diff --git a/apps/backend/src/modules/complex/handlers/get-current-complex.handler.ts b/apps/backend/src/modules/complex/handlers/get-current-complex.handler.ts new file mode 100644 index 0000000..a2cc5a0 --- /dev/null +++ b/apps/backend/src/modules/complex/handlers/get-current-complex.handler.ts @@ -0,0 +1,22 @@ +import { getCurrentComplex } from '@/modules/complex/services/complex.service'; +import type { AppContext } from '@/types/hono'; +import { getCookie } from 'hono/cookie'; + +const SELECTED_COMPLEX_COOKIE = 'selected-complex-id'; + +export async function getCurrentComplexHandler(c: AppContext) { + const user = c.get('user'); + const complexId = getCookie(c, SELECTED_COMPLEX_COOKIE); + + if (!complexId) { + return c.json(null); + } + + const complex = await getCurrentComplex(user.id, complexId); + + if (!complex) { + return c.json(null); + } + + return c.json(complex); +} diff --git a/apps/backend/src/modules/complex/handlers/select-complex.handler.ts b/apps/backend/src/modules/complex/handlers/select-complex.handler.ts new file mode 100644 index 0000000..700f8e1 --- /dev/null +++ b/apps/backend/src/modules/complex/handlers/select-complex.handler.ts @@ -0,0 +1,38 @@ +import { selectComplex } from '@/modules/complex/services/complex.service'; +import type { AppContext } from '@/types/hono'; +import { setCookie } from 'hono/cookie'; +import { z } from 'zod'; + +const SELECTED_COMPLEX_COOKIE = 'selected-complex-id'; +const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; + +const selectComplexSchema = z.object({ + complexId: z.string().uuid(), +}); + +export async function selectComplexHandler(c: AppContext) { + const user = c.get('user'); + const body = await c.req.json(); + const result = selectComplexSchema.safeParse(body); + + if (!result.success) { + return c.json({ message: 'Invalid payload', issues: result.error.issues }, { status: 400 }); + } + + const { complexId } = result.data; + const complex = await selectComplex(user.id, complexId); + + if (!complex) { + return c.json({ message: 'Complex not found or not associated with user' }, { status: 404 }); + } + + setCookie(c, SELECTED_COMPLEX_COOKIE, complexId, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + path: '/', + maxAge: COOKIE_MAX_AGE, + }); + + return c.json(complex); +} diff --git a/apps/backend/src/modules/complex/services/complex.service.ts b/apps/backend/src/modules/complex/services/complex.service.ts index 1782c6c..027f005 100644 --- a/apps/backend/src/modules/complex/services/complex.service.ts +++ b/apps/backend/src/modules/complex/services/complex.service.ts @@ -104,7 +104,52 @@ export async function listMyComplexes(userId: string) { }, }); - return complexUsers.map((complexUser) => complexUser.complex); + return complexUsers.map((complexUser) => ({ + ...complexUser.complex, + role: complexUser.role, + })); +} + +export async function getCurrentComplex(userId: string, complexId: string) { + const complexUser = await db.complexUser.findUnique({ + where: { + complexId_userId: { + complexId, + userId, + }, + }, + include: { + complex: true, + }, + }); + + if (!complexUser) return null; + + return { + ...complexUser.complex, + role: complexUser.role, + }; +} + +export async function selectComplex(userId: string, complexId: string) { + const complexUser = await db.complexUser.findUnique({ + where: { + complexId_userId: { + complexId, + userId, + }, + }, + include: { + complex: true, + }, + }); + + if (!complexUser) return null; + + return { + ...complexUser.complex, + role: complexUser.role, + }; } export async function updateComplex(id: string, input: UpdateComplexInput) { diff --git a/apps/backend/src/modules/user/handlers/get-user-profile.handler.ts b/apps/backend/src/modules/user/handlers/get-user-profile.handler.ts index 3dcbf66..cdf4e76 100644 --- a/apps/backend/src/modules/user/handlers/get-user-profile.handler.ts +++ b/apps/backend/src/modules/user/handlers/get-user-profile.handler.ts @@ -1,8 +1,12 @@ import { getUserProfile } from '@/modules/user/services/user.service'; import type { AppContext } from '@/types/hono'; +import { getCookie } from 'hono/cookie'; -export function getUserProfileHandler(c: AppContext) { +const SELECTED_COMPLEX_COOKIE = 'selected-complex-id'; + +export async function getUserProfileHandler(c: AppContext) { const user = c.get('user'); - const profile = getUserProfile(user); + const complexId = getCookie(c, SELECTED_COMPLEX_COOKIE); + const profile = await getUserProfile(user, complexId); return c.json(profile); } diff --git a/apps/backend/src/modules/user/services/user.service.ts b/apps/backend/src/modules/user/services/user.service.ts index eda3f73..225aba5 100644 --- a/apps/backend/src/modules/user/services/user.service.ts +++ b/apps/backend/src/modules/user/services/user.service.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; import type { User } from '@/lib/auth'; +import { db } from '@/lib/prisma'; import type { UserProfile } from '@repo/api-contract'; function getGravatarUrl(email: string): string { @@ -7,12 +8,29 @@ function getGravatarUrl(email: string): string { return `https://www.gravatar.com/avatar/${hash}?d=identicon`; } -export function getUserProfile(user: User): UserProfile { +export async function getUserProfile(user: User, complexId?: string): Promise { + let role = 'member'; + + if (complexId) { + const complexUser = await db.complexUser.findUnique({ + where: { + complexId_userId: { + complexId, + userId: user.id, + }, + }, + }); + + if (complexUser) { + role = complexUser.role.toLowerCase(); + } + } + return { id: user.id, fullName: user.name, email: user.email, - role: (user as any).role || 'member', + role, avatarUrl: user.image || getGravatarUrl(user.email), createdAt: user.createdAt.toISOString(), }; diff --git a/apps/frontend/src/features/home/home-page.tsx b/apps/frontend/src/features/home/home-page.tsx index 44375c4..a8e16ca 100644 --- a/apps/frontend/src/features/home/home-page.tsx +++ b/apps/frontend/src/features/home/home-page.tsx @@ -20,13 +20,10 @@ import { SelectValue, } from '@/components/ui/select'; import { ApiClientError, apiClient } from '@/lib/api-client'; -import { - getCurrentComplexSlug, - setCurrentComplexSlug as persistCurrentComplexSlug, -} from '@/lib/current-complex'; import { zodResolver } from '@hookform/resolvers/zod'; import type { AdminBooking } from '@repo/api-contract'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; import { Check, Copy } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; @@ -123,9 +120,9 @@ function effectiveStatusClassName(status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED export function HomePage() { const queryClient = useQueryClient(); + const navigate = useNavigate(); const todayIso = useMemo(() => toIsoDateLocal(new Date()), []); - const [currentComplexSlug, setCurrentComplexSlug] = useState(null); const [fromDate, setFromDate] = useState(todayIso); const [manualDate, setManualDate] = useState(todayIso); const [selectedSportId, setSelectedSportId] = useState(); @@ -134,35 +131,32 @@ export function HomePage() { const [isManualBookingOpen, setIsManualBookingOpen] = useState(false); const [urlCopied, setUrlCopied] = useState(false); - useEffect(() => { - setCurrentComplexSlug(getCurrentComplexSlug()); - }, []); + const currentComplexQuery = useQuery({ + queryKey: ['current-complex'], + queryFn: () => apiClient.complexes.getCurrent(), + }); const myComplexesQuery = useQuery({ queryKey: ['my-complexes'], queryFn: () => apiClient.complexes.listMine(), }); - const selectedComplex = useMemo(() => { - const complexes = myComplexesQuery.data ?? []; - - if (complexes.length === 0) return null; - - if (currentComplexSlug) { - const match = complexes.find((complex) => complex.complexSlug === currentComplexSlug); - - if (match) return match; + useEffect(() => { + async function autoSelect() { + if (myComplexesQuery.data && myComplexesQuery.data.length === 1) { + await apiClient.complexes.select({ complexId: myComplexesQuery.data[0].id }); + queryClient.invalidateQueries({ queryKey: ['current-complex'] }); + } else if (myComplexesQuery.data && myComplexesQuery.data.length > 1 && !currentComplexQuery.data) { + navigate({ to: '/select-complex' }); + } } - return complexes[0]; - }, [currentComplexSlug, myComplexesQuery.data]); + if (!currentComplexQuery.isLoading && !currentComplexQuery.data && myComplexesQuery.data) { + autoSelect(); + } + }, [currentComplexQuery.isLoading, currentComplexQuery.data, myComplexesQuery.data, navigate, queryClient]); - useEffect(() => { - if (!selectedComplex) return; - - setCurrentComplexSlug(selectedComplex.complexSlug); - persistCurrentComplexSlug(selectedComplex.complexSlug); - }, [selectedComplex]); + const selectedComplex = currentComplexQuery.data ?? null; useEffect(() => { if (!selectedComplex?.id) return; diff --git a/apps/frontend/src/features/login/login-page.tsx b/apps/frontend/src/features/login/login-page.tsx index e487131..67fd3b9 100644 --- a/apps/frontend/src/features/login/login-page.tsx +++ b/apps/frontend/src/features/login/login-page.tsx @@ -1,7 +1,7 @@ import { Button } from '@/components/ui/button'; import { Field, FieldError, FieldLabel } from '@/components/ui/field'; import { Input } from '@/components/ui/input'; -import { authClient } from '@/lib/api-client'; +import { apiClient, authClient } from '@/lib/api-client'; import { useAuth } from '@/lib/auth'; import { zodResolver } from '@hookform/resolvers/zod'; import { Link, useNavigate } from '@tanstack/react-router'; @@ -38,12 +38,30 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) { }, }); + async function handlePostLogin() { + const complexes = await apiClient.complexes.listMine(); + + if (complexes.length === 1) { + await apiClient.complexes.select({ complexId: complexes[0].id }); + navigate({ to: redirectTo }); + } else if (complexes.length > 1) { + const current = await apiClient.complexes.getCurrent(); + if (current) { + navigate({ to: redirectTo }); + } else { + navigate({ to: '/select-complex' }); + } + } else { + await navigate({ to: redirectTo }); + } + } + const onSubmit = async (values: LoginForm) => { setSubmitError(null); try { await signInWithPassword(values); - await navigate({ to: redirectTo }); + await handlePostLogin(); } catch { setSubmitError('No pudimos iniciar sesión. Verificá tus credenciales.'); } @@ -55,9 +73,7 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) { try { await authClient.signIn.social({ provider: 'google', - callbackURL: redirectTo.startsWith('http') - ? redirectTo - : `${window.location.origin}${redirectTo}`, + callbackURL: `${window.location.origin}/auth-callback`, }); } catch { setSubmitError('No pudimos iniciar sesión con Google.'); diff --git a/apps/frontend/src/features/select-complex/select-complex-page.tsx b/apps/frontend/src/features/select-complex/select-complex-page.tsx new file mode 100644 index 0000000..70ac0da --- /dev/null +++ b/apps/frontend/src/features/select-complex/select-complex-page.tsx @@ -0,0 +1,98 @@ +import { Button } from '@/components/ui/button'; +import { apiClient } from '@/lib/api-client'; +import type { ComplexWithRole } from '@repo/api-contract'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; +import { Building2, Loader2 } from 'lucide-react'; + +export function SelectComplexPage() { + const navigate = useNavigate(); + + const complexesQuery = useQuery({ + queryKey: ['my-complexes'], + queryFn: () => apiClient.complexes.listMine(), + }); + + const selectMutation = useMutation({ + mutationFn: async (complexId: string) => { + const response = await apiClient.complexes.select({ complexId }); + return response; + }, + onSuccess: () => { + navigate({ to: '/' }); + }, + }); + + const handleSelect = (complex: ComplexWithRole) => { + selectMutation.mutate(complex.id); + }; + + if (complexesQuery.isLoading) { + return ( +
+ +
+ ); + } + + if (complexesQuery.isError) { + return ( +
+
+

Error al cargar tus complejos.

+ +
+
+ ); + } + + const complexes = complexesQuery.data ?? []; + + if (complexes.length === 0) { + return ( +
+
+

No tenés complejos asociados.

+
+
+ ); + } + + return ( +
+
+
+

Seleccioná un complejo

+

+ Elegí el complejo con el que vas a trabajar. +

+
+ +
+ {complexes.map((complex) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/frontend/src/lib/api-client.ts b/apps/frontend/src/lib/api-client.ts index 93eb15a..66823dc 100644 --- a/apps/frontend/src/lib/api-client.ts +++ b/apps/frontend/src/lib/api-client.ts @@ -1,6 +1,7 @@ import type { AdminBooking, Complex, + ComplexWithRole, Court, CreateAdminBookingInput, CreateComplexPayload, @@ -20,6 +21,7 @@ import type { PublicAvailabilityResponse, PublicBooking, PublicBookingConfirmation, + SelectComplexInput, Sport, UpdateAdminBookingStatusInput, UpdateComplexPayload, @@ -187,7 +189,17 @@ export const apiClient = { return response.data; }, listMine: async () => { - const response = await http.get('/api/complexes/mine'); + const response = await http.get('/api/complexes/mine'); + return response.data; + }, + getCurrent: async () => { + const response = await http.get('/api/complexes/me'); + return response.data; + }, + select: async (payload: SelectComplexInput) => { + const response = await http.post('/api/complexes/select', JSON.stringify(payload), { + headers: { 'Content-Type': 'application/json' } + }); return response.data; }, }, diff --git a/apps/frontend/src/routeTree.gen.ts b/apps/frontend/src/routeTree.gen.ts index d39fd03..75feeb7 100644 --- a/apps/frontend/src/routeTree.gen.ts +++ b/apps/frontend/src/routeTree.gen.ts @@ -9,9 +9,11 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as SelectComplexRouteImport } from './routes/select-complex' import { Route as ResetPasswordRouteImport } from './routes/reset-password' import { Route as OnboardRouteImport } from './routes/onboard' import { Route as LoginRouteImport } from './routes/login' +import { Route as AuthCallbackRouteImport } from './routes/auth-callback' import { Route as AppRouteRouteImport } from './routes/_app/route' import { Route as OnboardIndexRouteImport } from './routes/onboard/index' import { Route as OnboardVerifyRouteImport } from './routes/onboard/verify' @@ -25,6 +27,11 @@ import { Route as ComplexSlugBookingIndexRouteImport } from './routes/$complexSl import { Route as ComplexSlugBookingConfirmedBookingCodeRouteImport } from './routes/$complexSlug/booking/confirmed/$bookingCode' import { Route as AppAuthenticatedComplexSlugEditRouteImport } from './routes/_app/_authenticated/complex/$slug/edit' +const SelectComplexRoute = SelectComplexRouteImport.update({ + id: '/select-complex', + path: '/select-complex', + getParentRoute: () => rootRouteImport, +} as any) const ResetPasswordRoute = ResetPasswordRouteImport.update({ id: '/reset-password', path: '/reset-password', @@ -40,6 +47,11 @@ const LoginRoute = LoginRouteImport.update({ path: '/login', getParentRoute: () => rootRouteImport, } as any) +const AuthCallbackRoute = AuthCallbackRouteImport.update({ + id: '/auth-callback', + path: '/auth-callback', + getParentRoute: () => rootRouteImport, +} as any) const AppRouteRoute = AppRouteRouteImport.update({ id: '/_app', getParentRoute: () => rootRouteImport, @@ -103,9 +115,11 @@ const AppAuthenticatedComplexSlugEditRoute = export interface FileRoutesByFullPath { '/': typeof AppAuthenticatedIndexRoute + '/auth-callback': typeof AuthCallbackRoute '/login': typeof LoginRoute '/onboard': typeof OnboardRouteWithChildren '/reset-password': typeof ResetPasswordRoute + '/select-complex': typeof SelectComplexRoute '/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren '/about': typeof AppAboutRoute '/profile': typeof AppProfileRoute @@ -118,8 +132,10 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof AppAuthenticatedIndexRoute + '/auth-callback': typeof AuthCallbackRoute '/login': typeof LoginRoute '/reset-password': typeof ResetPasswordRoute + '/select-complex': typeof SelectComplexRoute '/about': typeof AppAboutRoute '/profile': typeof AppProfileRoute '/onboard/complete': typeof OnboardCompleteRoute @@ -132,9 +148,11 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/_app': typeof AppRouteRouteWithChildren + '/auth-callback': typeof AuthCallbackRoute '/login': typeof LoginRoute '/onboard': typeof OnboardRouteWithChildren '/reset-password': typeof ResetPasswordRoute + '/select-complex': typeof SelectComplexRoute '/_app/_authenticated': typeof AppAuthenticatedRouteRouteWithChildren '/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren '/_app/about': typeof AppAboutRoute @@ -151,9 +169,11 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/auth-callback' | '/login' | '/onboard' | '/reset-password' + | '/select-complex' | '/$complexSlug/booking' | '/about' | '/profile' @@ -166,8 +186,10 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/auth-callback' | '/login' | '/reset-password' + | '/select-complex' | '/about' | '/profile' | '/onboard/complete' @@ -179,9 +201,11 @@ export interface FileRouteTypes { id: | '__root__' | '/_app' + | '/auth-callback' | '/login' | '/onboard' | '/reset-password' + | '/select-complex' | '/_app/_authenticated' | '/$complexSlug/booking' | '/_app/about' @@ -197,14 +221,23 @@ export interface FileRouteTypes { } export interface RootRouteChildren { AppRouteRoute: typeof AppRouteRouteWithChildren + AuthCallbackRoute: typeof AuthCallbackRoute LoginRoute: typeof LoginRoute OnboardRoute: typeof OnboardRouteWithChildren ResetPasswordRoute: typeof ResetPasswordRoute + SelectComplexRoute: typeof SelectComplexRoute ComplexSlugBookingRoute: typeof ComplexSlugBookingRouteWithChildren } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/select-complex': { + id: '/select-complex' + path: '/select-complex' + fullPath: '/select-complex' + preLoaderRoute: typeof SelectComplexRouteImport + parentRoute: typeof rootRouteImport + } '/reset-password': { id: '/reset-password' path: '/reset-password' @@ -226,6 +259,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LoginRouteImport parentRoute: typeof rootRouteImport } + '/auth-callback': { + id: '/auth-callback' + path: '/auth-callback' + fullPath: '/auth-callback' + preLoaderRoute: typeof AuthCallbackRouteImport + parentRoute: typeof rootRouteImport + } '/_app': { id: '/_app' path: '' @@ -375,9 +415,11 @@ const ComplexSlugBookingRouteWithChildren = const rootRouteChildren: RootRouteChildren = { AppRouteRoute: AppRouteRouteWithChildren, + AuthCallbackRoute: AuthCallbackRoute, LoginRoute: LoginRoute, OnboardRoute: OnboardRouteWithChildren, ResetPasswordRoute: ResetPasswordRoute, + SelectComplexRoute: SelectComplexRoute, ComplexSlugBookingRoute: ComplexSlugBookingRouteWithChildren, } export const routeTree = rootRouteImport diff --git a/apps/frontend/src/routes/auth-callback.tsx b/apps/frontend/src/routes/auth-callback.tsx new file mode 100644 index 0000000..321f255 --- /dev/null +++ b/apps/frontend/src/routes/auth-callback.tsx @@ -0,0 +1,50 @@ +import { useEffect } from 'react'; +import { apiClient } from '@/lib/api-client'; +import { useAuth } from '@/lib/auth'; +import { createFileRoute, useNavigate } from '@tanstack/react-router'; + +function AuthCallbackPage() { + const navigate = useNavigate(); + const { isAuthenticated } = useAuth(); + + useEffect(() => { + async function handleCallback() { + if (!isAuthenticated) { + navigate({ to: '/login' }); + return; + } + + try { + const complexes = await apiClient.complexes.listMine(); + + if (complexes.length === 1) { + await apiClient.complexes.select({ complexId: complexes[0].id }); + navigate({ to: '/' }); + } else if (complexes.length > 1) { + const current = await apiClient.complexes.getCurrent(); + if (current) { + navigate({ to: '/' }); + } else { + navigate({ to: '/select-complex' }); + } + } else { + navigate({ to: '/' }); + } + } catch { + navigate({ to: '/login' }); + } + } + + handleCallback(); + }, [isAuthenticated, navigate]); + + return ( +
+

Completando sesión...

+
+ ); +} + +export const Route = createFileRoute('/auth-callback')({ + component: AuthCallbackPage, +}); \ No newline at end of file diff --git a/apps/frontend/src/routes/select-complex.tsx b/apps/frontend/src/routes/select-complex.tsx new file mode 100644 index 0000000..8d40509 --- /dev/null +++ b/apps/frontend/src/routes/select-complex.tsx @@ -0,0 +1,6 @@ +import { SelectComplexPage } from '@/features/select-complex/select-complex-page'; +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/select-complex')({ + component: SelectComplexPage, +}); diff --git a/packages/api-contract/src/complex.ts b/packages/api-contract/src/complex.ts index 9d142bf..95f5bbd 100644 --- a/packages/api-contract/src/complex.ts +++ b/packages/api-contract/src/complex.ts @@ -113,6 +113,24 @@ export const updateComplexSchema = z }, ) +export const complexUserRoleSchema = z.enum(['ADMIN', 'EMPLOYEE']) + +export type ComplexUserRole = z.infer + +export const selectComplexSchema = z.object({ + complexId: z.string().uuid(), +}) + +export type SelectComplexInput = z.infer + +export const complexWithRoleSchema = complexSchema.merge( + z.object({ + role: complexUserRoleSchema, + }) +) + +export type ComplexWithRole = z.infer + export type Complex = z.infer export type CreateComplexInput = z.infer export type UpdateComplexInput = z.infer diff --git a/packages/api-contract/src/index.ts b/packages/api-contract/src/index.ts index 8f4d750..cf34d6c 100644 --- a/packages/api-contract/src/index.ts +++ b/packages/api-contract/src/index.ts @@ -26,12 +26,22 @@ export type { OnboardingVerifyOtpInput, OnboardingVerifyOtpResponse, } from './onboarding' -export { complexSchema, createComplexSchema, updateComplexSchema } from './complex' +export { + complexSchema, + complexUserRoleSchema, + complexWithRoleSchema, + createComplexSchema, + selectComplexSchema, + updateComplexSchema, +} from './complex' export type { Complex, + ComplexUserRole, + ComplexWithRole, CreateComplexInput, CreateComplexPayload, CreateComplexResponse, + SelectComplexInput, UpdateComplexInput, UpdateComplexPayload, UpdateComplexResponse,