From ca9e684ab4daf72a844765a244ff6bfb039b233e Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 29 Apr 2026 20:19:40 +0330 Subject: [PATCH 1/2] improvement: multi organization possibility implemented for users (owners and staffs) --- .../migration.sql | 2 + backend/prisma/schema.prisma | 1 + backend/src/modules/auth/auth.controller.ts | 9 + backend/src/modules/auth/auth.service.ts | 77 ++++++- .../auth/dto/create-organization.dto.ts | 16 ++ backend/src/modules/auth/dto/register.dto.ts | 3 + .../auth/interfaces/jwt-payload.interface.ts | 1 + frontend/src/app/(public)/register/page.tsx | 12 +- .../app/(public)/select-organization/page.tsx | 193 +++++++++++++----- frontend/src/components/ui/Sidebar.tsx | 4 +- frontend/src/lib/api/auth.ts | 11 + frontend/src/lib/hooks/useAuth.tsx | 44 ++++ frontend/src/styles/globals.css | 8 +- frontend/src/types/index.ts | 1 + 14 files changed, 317 insertions(+), 65 deletions(-) create mode 100644 backend/prisma/migrations/20260429134000_add_user_trial_used_at/migration.sql create mode 100644 backend/src/modules/auth/dto/create-organization.dto.ts diff --git a/backend/prisma/migrations/20260429134000_add_user_trial_used_at/migration.sql b/backend/prisma/migrations/20260429134000_add_user_trial_used_at/migration.sql new file mode 100644 index 0000000..023adfc --- /dev/null +++ b/backend/prisma/migrations/20260429134000_add_user_trial_used_at/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "users" +ADD COLUMN "trialUsedAt" TIMESTAMP(3); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index f6517c6..db7b5fa 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -15,6 +15,7 @@ model User { googleId String? @unique facebookId String? @unique name String + trialUsedAt DateTime? memberships Membership[] ownedOrganizations Organization[] @relation("OrganizationOwner") diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index afc0b62..7cde9f7 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -25,6 +25,7 @@ import { import { AuthService } from './auth.service'; import { LoginDto } from './dto/login.dto'; import { RegisterDto } from './dto/register.dto'; +import { CreateOrganizationDto } from './dto/create-organization.dto'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { LocalAuthGuard } from './guards/local-auth.guard'; @@ -120,6 +121,14 @@ export class AuthController { }; } + @Post('organizations') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Create organization for current user' }) + async createOrganization(@Req() req, @Body() dto: CreateOrganizationDto) { + return this.authService.createOrganization(req.user.id, dto); + } + // ========================= // PROFILE // ========================= diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 9b23699..0429885 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -12,6 +12,7 @@ import * as bcrypt from 'bcrypt'; import { PrismaService } from '../../../prisma/prisma.service'; import { LoginDto } from './dto/login.dto'; import { RegisterDto } from './dto/register.dto'; +import { CreateOrganizationDto } from './dto/create-organization.dto'; import { JwtPayload } from './interfaces/jwt-payload.interface'; const ALL_PERMISSIONS = [ @@ -176,7 +177,7 @@ export class AuthService { * @returns Created user info without password */ async register(registerDto: RegisterDto) { - const { email, password, name, organizationName, organizationType } = registerDto; + const { email, password, name, organizationName, organizationEmail, organizationType } = registerDto; // 1. Check existing user const existingUser = await this.prisma.user.findUnique({ @@ -184,7 +185,7 @@ export class AuthService { }); if (existingUser) { - throw new ConflictException('User already exists'); + throw new ConflictException('User already exists. Please login and create a new organization from your account.'); } // 2. Hash password @@ -198,16 +199,15 @@ export class AuthService { email, passwordHash: hashedPassword, name, + trialUsedAt: new Date(), }, }); // Create organization const organization = await tx.organization.create({ data: { - name: registerDto.organizationName, - - // REQUIRED FIELDS 👇 - email: registerDto.email, // or separate org email if you have one + name: organizationName, + email: organizationEmail, owner: { connect: { id: user.id }, @@ -219,7 +219,7 @@ export class AuthService { type: { connect: { - name: registerDto.organizationType, // 'CLINIC' | 'LAB' + name: organizationType, // 'CLINIC' | 'LAB' }, }, }, @@ -247,6 +247,66 @@ export class AuthService { return this.login({ email, password } as any, validatedUser); } + async createOrganization(userId: string, dto: CreateOrganizationDto) { + const owner = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, trialUsedAt: true }, + }); + + if (!owner) { + throw new UnauthorizedException('User not found'); + } + + const planName = dto.planName?.trim() || 'Small'; + const effectivePlanName = owner.trialUsedAt ? planName : 'trial'; + + const organization = await this.prisma.$transaction(async (tx) => { + const createdOrganization = await tx.organization.create({ + data: { + name: dto.organizationName, + email: dto.organizationEmail, + owner: { + connect: { id: userId }, + }, + plan: { + connect: { name: effectivePlanName }, + }, + type: { + connect: { name: dto.organizationType }, + }, + }, + }); + + await tx.membership.create({ + data: { + userId, + organizationId: createdOrganization.id, + isOwner: true, + }, + }); + + if (!owner.trialUsedAt) { + await tx.user.update({ + where: { id: userId }, + data: { trialUsedAt: new Date() }, + }); + } + + return createdOrganization; + }); + + return { + success: true, + data: { + organization: { + id: organization.id, + name: organization.name, + email: organization.email, + }, + }, + }; + } + /** * Get user profile with all memberships and permissions * @param userId - User ID from JWT token @@ -617,6 +677,7 @@ export class AuthService { organizationId, }, include: { + user: true, organization: { include: { type: true, @@ -638,7 +699,7 @@ export class AuthService { // 2. Build payload WITH org context const payload = { sub: userId, - email: membership.organization.email, + email: membership.user.email, organizationId: membership.organizationId, type: 'access', }; diff --git a/backend/src/modules/auth/dto/create-organization.dto.ts b/backend/src/modules/auth/dto/create-organization.dto.ts new file mode 100644 index 0000000..716744d --- /dev/null +++ b/backend/src/modules/auth/dto/create-organization.dto.ts @@ -0,0 +1,16 @@ +import { IsEmail, IsEnum, IsOptional, IsString } from 'class-validator'; + +export class CreateOrganizationDto { + @IsString() + organizationName: string; + + @IsEmail() + organizationEmail: string; + + @IsEnum(['CLINIC', 'LAB']) + organizationType: 'CLINIC' | 'LAB'; + + @IsOptional() + @IsString() + planName?: string; +} diff --git a/backend/src/modules/auth/dto/register.dto.ts b/backend/src/modules/auth/dto/register.dto.ts index ed11b46..3556870 100644 --- a/backend/src/modules/auth/dto/register.dto.ts +++ b/backend/src/modules/auth/dto/register.dto.ts @@ -14,6 +14,9 @@ export class RegisterDto { @IsString() organizationName: string; + @IsEmail() + organizationEmail: string; + @IsEnum(['CLINIC', 'LAB']) organizationType: 'CLINIC' | 'LAB'; } \ No newline at end of file diff --git a/backend/src/modules/auth/interfaces/jwt-payload.interface.ts b/backend/src/modules/auth/interfaces/jwt-payload.interface.ts index ab2f27c..6182b3e 100644 --- a/backend/src/modules/auth/interfaces/jwt-payload.interface.ts +++ b/backend/src/modules/auth/interfaces/jwt-payload.interface.ts @@ -2,6 +2,7 @@ export interface JwtPayload { sub: string; // user id email: string; + organizationId?: string; type?: 'access' | 'refresh'; } diff --git a/frontend/src/app/(public)/register/page.tsx b/frontend/src/app/(public)/register/page.tsx index b37192e..03fc30b 100644 --- a/frontend/src/app/(public)/register/page.tsx +++ b/frontend/src/app/(public)/register/page.tsx @@ -18,6 +18,7 @@ const registerSchema = z.object({ .regex(/[0-9]/, 'Password must contain at least one number'), confirmPassword: z.string(), organizationName: z.string().min(2, 'Organization name must be at least 2 characters'), + organizationEmail: z.string().email('Please enter a valid organization email'), organizationType: z.enum(['CLINIC', 'LAB'], { message: 'Please select organization type', }), @@ -47,7 +48,7 @@ export default function RegisterPage() { const handleNext = async () => { const fieldsToValidate = step === 1 ? ['name', 'email', 'password', 'confirmPassword'] - : ['organizationName', 'organizationType']; + : ['organizationName', 'organizationEmail', 'organizationType']; const isValid = await trigger(fieldsToValidate as any); if (isValid) { @@ -62,6 +63,7 @@ export default function RegisterPage() { data.password, data.name, data.organizationName, + data.organizationEmail, data.organizationType ); // No need to redirect - auth context will handle it @@ -182,6 +184,14 @@ export default function RegisterPage() { error={errors.organizationName?.message} icon={} /> + } + />