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..eabe813 100644 --- a/apps/frontend/src/lib/api-client.ts +++ b/apps/frontend/src/lib/api-client.ts @@ -1,39 +1,7 @@ -import type { - AdminBooking, - Complex, - Court, - CreateAdminBookingInput, - CreateComplexPayload, - CreateComplexResponse, - CreateCourtInput, - CreatePublicBookingInput, - CreateSportInput, - OnboardingCompleteInput, - OnboardingCompleteResponse, - OnboardingResendOtpInput, - OnboardingResendOtpResponse, - OnboardingStartInput, - OnboardingStartResponse, - OnboardingVerifyOtpInput, - OnboardingVerifyOtpResponse, - PlanSummary, - PublicAvailabilityResponse, - PublicBooking, - PublicBookingConfirmation, - Sport, - UpdateAdminBookingStatusInput, - UpdateComplexPayload, - UpdateComplexResponse, - UpdateCourtInput, - UpdateSportInput, - UserProfileResponse, -} from '@repo/api-contract'; -import axios, { AxiosError } from 'axios'; import { createAuthClient } from 'better-auth/react'; +import * as api from './api'; -const apiBaseUrl = - import.meta.env.VITE_API_BASE_URL?.trim() || - (import.meta.env.DEV ? 'http://localhost:3000' : window.location.origin); +const apiBaseUrl = api.apiBaseUrl; export const authClient = createAuthClient({ baseURL: apiBaseUrl, @@ -59,235 +27,30 @@ export class ApiClientError extends Error { } } -type ErrorHandlers = { - onForbidden?: (error: ApiClientError) => void | Promise; - onError?: (error: ApiClientError) => void | Promise; -}; - -const handlers: ErrorHandlers = {}; - -const http = axios.create({ - baseURL: apiBaseUrl, - withCredentials: true, - headers: { - 'Content-Type': 'application/json', - }, -}); - -function extractMessage(details: unknown, fallback: string) { - if ( - typeof details === 'object' && - details && - 'message' in details && - typeof details.message === 'string' - ) { - return details.message; - } - - return fallback; -} - -function normalizeError(error: unknown): ApiClientError { - if (error instanceof ApiClientError) return error; - - if (axios.isAxiosError(error)) { - const status = error.response?.status; - const details = error.response?.data; - - return new ApiClientError(extractMessage(details, error.message || 'Request failed'), { - status, - details, - causeError: error, - }); - } - - if (error instanceof Error) { - return new ApiClientError(error.message, { - causeError: error, - }); - } - - return new ApiClientError('Error inesperado.', { - causeError: error, - }); -} - -http.interceptors.response.use( - (response) => response, - async (error: AxiosError) => { - const normalized = normalizeError(error); - - if (normalized.status === 403 && handlers.onForbidden) { - await handlers.onForbidden(normalized); - } - - if (handlers.onError) { - await handlers.onError(normalized); - } - - return Promise.reject(normalized); - } -); - export const apiClient = { - onboarding: { - start: async (payload: OnboardingStartInput) => { - const response = await http.post('/api/onboarding/start', payload); - return response.data; - }, - verifyOtp: async (payload: OnboardingVerifyOtpInput) => { - const response = await http.post( - '/api/onboarding/verify-otp', - payload - ); - return response.data; - }, - resendOtp: async (payload: OnboardingResendOtpInput) => { - const response = await http.post( - '/api/onboarding/resend-otp', - payload - ); - return response.data; - }, - complete: async (payload: OnboardingCompleteInput) => { - const response = await http.post( - '/api/onboarding/complete', - payload - ); - return response.data; - }, - }, - plans: { - list: async () => { - const response = await http.get('/api/plans'); - return response.data; - }, - }, - user: { - getProfile: async () => { - const response = await http.get('/api/user/profile'); - return response.data; - }, - }, - complexes: { - create: async (payload: CreateComplexPayload) => { - const response = await http.post('/api/complexes', payload); - return response.data; - }, - getById: async (id: string) => { - const response = await http.get(`/api/complexes/${id}`); - return response.data; - }, - getBySlug: async (slug: string) => { - const response = await http.get(`/api/complexes/slug/${slug}`); - return response.data; - }, - update: async (id: string, payload: UpdateComplexPayload) => { - const response = await http.patch(`/api/complexes/${id}`, payload); - return response.data; - }, - listMine: async () => { - const response = await http.get('/api/complexes/mine'); - return response.data; - }, - }, - sports: { - list: async () => { - const response = await http.get('/api/sports'); - return response.data; - }, - create: async (payload: CreateSportInput) => { - const response = await http.post('/api/sports', payload); - return response.data; - }, - update: async (id: string, payload: UpdateSportInput) => { - const response = await http.patch(`/api/sports/${id}`, payload); - return response.data; - }, - }, - courts: { - listByComplex: async (complexId: string) => { - const response = await http.get(`/api/courts/complex/${complexId}`); - return response.data; - }, - create: async (complexId: string, payload: CreateCourtInput) => { - const response = await http.post(`/api/courts/complex/${complexId}`, payload); - return response.data; - }, - update: async (id: string, payload: UpdateCourtInput) => { - const response = await http.patch(`/api/courts/${id}`, payload); - return response.data; - }, - }, + onboarding: api.onboarding, + plans: api.plans, + user: api.user, + complexes: api.complexes, + sports: api.sports, + courts: api.courts, publicBookings: { - getAvailability: async ( - complexSlug: string, - params: { - date: string; - sportId?: string; - } - ) => { - const response = await http.get( - `/api/public-bookings/complex/${complexSlug}/availability`, - { - params, - } - ); - return response.data; - }, - create: async (complexSlug: string, payload: CreatePublicBookingInput) => { - const response = await http.post( - `/api/public-bookings/complex/${complexSlug}`, - payload - ); - return response.data; - }, - getConfirmation: async (complexSlug: string, bookingCode: string) => { - const response = await http.get( - `/api/public-bookings/complex/${complexSlug}/confirmation/${bookingCode}` - ); - return response.data; - }, + getAvailability: api.getAvailability, + create: api.createPublic, + getConfirmation: api.getConfirmation, }, adminBookings: { - listByComplex: async ( - complexId: string, - params: { - fromDate: string; - } - ) => { - const response = await http.get<{ bookings: AdminBooking[] }>( - `/api/admin-bookings/complex/${complexId}`, - { - params, - } - ); - return response.data; - }, - create: async (complexId: string, payload: CreateAdminBookingInput) => { - const response = await http.post( - `/api/admin-bookings/complex/${complexId}`, - payload - ); - return response.data; - }, - updateStatus: async (bookingId: string, payload: UpdateAdminBookingStatusInput) => { - const response = await http.patch( - `/api/admin-bookings/${bookingId}/status`, - payload - ); - return response.data; - }, + listByComplex: api.listByComplex, + create: api.createAdmin, + updateStatus: api.updateStatus, }, }; -export type ApiClient = typeof apiClient; - -export function configureApiClient(nextHandlers: ErrorHandlers) { - handlers.onForbidden = nextHandlers.onForbidden; - handlers.onError = nextHandlers.onError; -} +export type { ErrorHandlers } from './api'; +export { configureApiClient } from './api'; export function setApiAccessToken(_token: string | null) { // accessToken = token; } + +export type ApiClient = typeof apiClient; \ No newline at end of file diff --git a/apps/frontend/src/lib/api/base.ts b/apps/frontend/src/lib/api/base.ts new file mode 100644 index 0000000..34c6776 --- /dev/null +++ b/apps/frontend/src/lib/api/base.ts @@ -0,0 +1,34 @@ +export class ApiClientError extends Error { + status?: number; + details?: unknown; + causeError?: unknown; + + constructor( + message: string, + options?: { status?: number; details?: unknown; causeError?: unknown } + ) { + super(message); + this.name = 'ApiClientError'; + this.status = options?.status; + this.details = options?.details; + this.causeError = options?.causeError; + } +} + +export type ErrorHandlers = { + onForbidden?: (error: ApiClientError) => void | Promise; + onError?: (error: ApiClientError) => void | Promise; +}; + +const handlers: ErrorHandlers = {}; + +export const apiBaseUrl = + import.meta.env.VITE_API_BASE_URL?.trim() || + (import.meta.env.DEV ? 'http://localhost:3000' : window.location.origin); + +export function configureApiClient(nextHandlers: ErrorHandlers) { + handlers.onForbidden = nextHandlers.onForbidden; + handlers.onError = nextHandlers.onError; +} + +export { handlers }; \ No newline at end of file diff --git a/apps/frontend/src/lib/api/http.ts b/apps/frontend/src/lib/api/http.ts new file mode 100644 index 0000000..9e0c38f --- /dev/null +++ b/apps/frontend/src/lib/api/http.ts @@ -0,0 +1,60 @@ +import axios, { AxiosError, type AxiosInstance } from 'axios'; +import { ApiClientError, apiBaseUrl, handlers } from './base'; + +export const http: AxiosInstance = axios.create({ + baseURL: apiBaseUrl, + withCredentials: true, + headers: { + 'Content-Type': 'application/json', + }, +}); + +function extractMessage(details: unknown, fallback: string): string { + if ( + typeof details === 'object' && + details && + 'message' in details && + typeof details.message === 'string' + ) { + return details.message; + } + return fallback; +} + +function normalizeError(error: unknown): ApiClientError { + if (error instanceof ApiClientError) return error; + + if (axios.isAxiosError(error)) { + const status = error.response?.status; + const details = error.response?.data; + + return new ApiClientError(extractMessage(details, error.message || 'Request failed'), { + status, + details, + causeError: error, + }); + } + + if (error instanceof Error) { + return new ApiClientError(error.message, { causeError: error }); + } + + return new ApiClientError('Error inesperado.', { causeError: error }); +} + +http.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const normalized = normalizeError(error); + + if (normalized.status === 403 && handlers.onForbidden) { + await handlers.onForbidden(normalized); + } + + if (handlers.onError) { + await handlers.onError(normalized); + } + + return Promise.reject(normalized); + } +); \ No newline at end of file diff --git a/apps/frontend/src/lib/api/index.ts b/apps/frontend/src/lib/api/index.ts new file mode 100644 index 0000000..73faa9f --- /dev/null +++ b/apps/frontend/src/lib/api/index.ts @@ -0,0 +1,18 @@ +export { ApiClientError, configureApiClient, apiBaseUrl, type ErrorHandlers } from './base'; +export { http } from './http'; + +export * as complexes from './resources/complexes'; +export * as courts from './resources/courts'; +export * as user from './resources/user'; +export * as sports from './resources/sports'; +export * as plans from './resources/plans'; +export * as onboarding from './resources/onboarding'; + +export { + getAvailability, + createPublic, + getConfirmation, + listByComplex, + createAdmin, + updateStatus, +} from './resources/bookings'; \ No newline at end of file diff --git a/apps/frontend/src/lib/api/resources/bookings.ts b/apps/frontend/src/lib/api/resources/bookings.ts new file mode 100644 index 0000000..538a776 --- /dev/null +++ b/apps/frontend/src/lib/api/resources/bookings.ts @@ -0,0 +1,60 @@ +import type { + AdminBooking, + CreateAdminBookingInput, + CreatePublicBookingInput, + PublicAvailabilityResponse, + PublicBooking, + PublicBookingConfirmation, + UpdateAdminBookingStatusInput, +} from '@repo/api-contract'; +import { http } from '../http'; + +export async function getAvailability( + complexSlug: string, + params: { date: string; sportId?: string } +) { + const response = await http.get( + `/api/public-bookings/complex/${complexSlug}/availability`, + { params } + ); + return response.data; +} + +export async function createPublic(complexSlug: string, payload: CreatePublicBookingInput) { + const response = await http.post( + `/api/public-bookings/complex/${complexSlug}`, + payload + ); + return response.data; +} + +export async function getConfirmation(complexSlug: string, bookingCode: string) { + const response = await http.get( + `/api/public-bookings/complex/${complexSlug}/confirmation/${bookingCode}` + ); + return response.data; +} + +export async function listByComplex(complexId: string, params: { fromDate: string }) { + const response = await http.get<{ bookings: AdminBooking[] }>( + `/api/admin-bookings/complex/${complexId}`, + { params } + ); + return response.data; +} + +export async function createAdmin(complexId: string, payload: CreateAdminBookingInput) { + const response = await http.post( + `/api/admin-bookings/complex/${complexId}`, + payload + ); + return response.data; +} + +export async function updateStatus(bookingId: string, payload: UpdateAdminBookingStatusInput) { + const response = await http.patch( + `/api/admin-bookings/${bookingId}/status`, + payload + ); + return response.data; +} \ No newline at end of file diff --git a/apps/frontend/src/lib/api/resources/complexes.ts b/apps/frontend/src/lib/api/resources/complexes.ts new file mode 100644 index 0000000..8b5cbce --- /dev/null +++ b/apps/frontend/src/lib/api/resources/complexes.ts @@ -0,0 +1,49 @@ +import type { + Complex, + ComplexWithRole, + CreateComplexPayload, + CreateComplexResponse, + SelectComplexInput, + UpdateComplexPayload, + UpdateComplexResponse, +} from '@repo/api-contract'; +import { http } from '../http'; + +export async function create(payload: CreateComplexPayload) { + const response = await http.post('/api/complexes', payload); + return response.data; +} + +export async function getById(id: string) { + const response = await http.get(`/api/complexes/${id}`); + return response.data; +} + +export async function getBySlug(slug: string) { + const response = await http.get(`/api/complexes/slug/${slug}`); + return response.data; +} + +export async function update(id: string, payload: UpdateComplexPayload) { + const response = await http.patch(`/api/complexes/${id}`, payload); + return response.data; +} + +export async function listMine() { + const response = await http.get('/api/complexes/mine'); + return response.data; +} + +export async function getCurrent() { + const response = await http.get('/api/complexes/me'); + return response.data; +} + +export async function select(payload: SelectComplexInput) { + const response = await http.post( + '/api/complexes/select', + JSON.stringify(payload), + { headers: { 'Content-Type': 'application/json' } } + ); + return response.data; +} \ No newline at end of file diff --git a/apps/frontend/src/lib/api/resources/courts.ts b/apps/frontend/src/lib/api/resources/courts.ts new file mode 100644 index 0000000..5c2dab9 --- /dev/null +++ b/apps/frontend/src/lib/api/resources/courts.ts @@ -0,0 +1,17 @@ +import type { Court, CreateCourtInput, UpdateCourtInput } from '@repo/api-contract'; +import { http } from '../http'; + +export async function listByComplex(complexId: string) { + const response = await http.get(`/api/courts/complex/${complexId}`); + return response.data; +} + +export async function create(complexId: string, payload: CreateCourtInput) { + const response = await http.post(`/api/courts/complex/${complexId}`, payload); + return response.data; +} + +export async function update(id: string, payload: UpdateCourtInput) { + const response = await http.patch(`/api/courts/${id}`, payload); + return response.data; +} \ No newline at end of file diff --git a/apps/frontend/src/lib/api/resources/onboarding.ts b/apps/frontend/src/lib/api/resources/onboarding.ts new file mode 100644 index 0000000..bc8a70a --- /dev/null +++ b/apps/frontend/src/lib/api/resources/onboarding.ts @@ -0,0 +1,40 @@ +import type { + OnboardingCompleteInput, + OnboardingCompleteResponse, + OnboardingResendOtpInput, + OnboardingResendOtpResponse, + OnboardingStartInput, + OnboardingStartResponse, + OnboardingVerifyOtpInput, + OnboardingVerifyOtpResponse, +} from '@repo/api-contract'; +import { http } from '../http'; + +export async function start(payload: OnboardingStartInput) { + const response = await http.post('/api/onboarding/start', payload); + return response.data; +} + +export async function verifyOtp(payload: OnboardingVerifyOtpInput) { + const response = await http.post( + '/api/onboarding/verify-otp', + payload + ); + return response.data; +} + +export async function resendOtp(payload: OnboardingResendOtpInput) { + const response = await http.post( + '/api/onboarding/resend-otp', + payload + ); + return response.data; +} + +export async function complete(payload: OnboardingCompleteInput) { + const response = await http.post( + '/api/onboarding/complete', + payload + ); + return response.data; +} \ No newline at end of file diff --git a/apps/frontend/src/lib/api/resources/plans.ts b/apps/frontend/src/lib/api/resources/plans.ts new file mode 100644 index 0000000..1db8e02 --- /dev/null +++ b/apps/frontend/src/lib/api/resources/plans.ts @@ -0,0 +1,7 @@ +import type { PlanSummary } from '@repo/api-contract'; +import { http } from '../http'; + +export async function list() { + const response = await http.get('/api/plans'); + return response.data; +} \ No newline at end of file diff --git a/apps/frontend/src/lib/api/resources/sports.ts b/apps/frontend/src/lib/api/resources/sports.ts new file mode 100644 index 0000000..112e56b --- /dev/null +++ b/apps/frontend/src/lib/api/resources/sports.ts @@ -0,0 +1,17 @@ +import type { CreateSportInput, Sport, UpdateSportInput } from '@repo/api-contract'; +import { http } from '../http'; + +export async function list() { + const response = await http.get('/api/sports'); + return response.data; +} + +export async function create(payload: CreateSportInput) { + const response = await http.post('/api/sports', payload); + return response.data; +} + +export async function update(id: string, payload: UpdateSportInput) { + const response = await http.patch(`/api/sports/${id}`, payload); + return response.data; +} \ No newline at end of file diff --git a/apps/frontend/src/lib/api/resources/user.ts b/apps/frontend/src/lib/api/resources/user.ts new file mode 100644 index 0000000..654dc11 --- /dev/null +++ b/apps/frontend/src/lib/api/resources/user.ts @@ -0,0 +1,7 @@ +import type { UserProfileResponse } from '@repo/api-contract'; +import { http } from '../http'; + +export async function getProfile() { + const response = await http.get('/api/user/profile'); + return response.data; +} \ No newline at end of file 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,