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={