diff --git a/.air.toml b/.air.toml index 7710852f..9c8e3241 100644 --- a/.air.toml +++ b/.air.toml @@ -22,7 +22,7 @@ include_dir = [] # Exclude files. exclude_file = [] # Exclude specific regular expressions. -exclude_regex = ["_test.go"] +exclude_regex = ["_test.go", "internal/api/docs/spec.go"] # Exclude unchanged files. exclude_unchanged = true # Increase build delay to avoid rapid rebuilds during debugging diff --git a/internal/api/server.go b/internal/api/server.go index 647ab0de..6a6f3be3 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -525,6 +525,7 @@ func (s *Server) setupRoutes() { // User management in organization orgGroup.GET("/users", s.orgHandlers.GetOrganizationMembers) + orgGroup.GET("/users/check", s.userHandlers.CheckUser) orgGroup.POST("/users", s.userHandlers.CreateUser) orgGroup.GET("/users/:user_id", s.userHandlers.GetUser) orgGroup.PUT("/users/:user_id", s.userHandlers.UpdateUser) diff --git a/internal/api/users/handlers.go b/internal/api/users/handlers.go index 572204f4..8eb942e1 100644 --- a/internal/api/users/handlers.go +++ b/internal/api/users/handlers.go @@ -4,6 +4,7 @@ import ( "database/sql" "net/http" "strconv" + "strings" "github.com/labstack/echo/v4" "github.com/livereview/internal/api/auth" @@ -23,6 +24,21 @@ func NewUserHandlers(userService *UserService, db *sql.DB) *UserHandlers { } } +// CheckUser handles checking if a user exists by email +func (uh *UserHandlers) CheckUser(c echo.Context) error { + email := c.QueryParam("email") + if email == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Email is required"}) + } + + result, err := uh.userService.CheckUserByEmail(email) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to check user"}) + } + + return c.JSON(http.StatusOK, result) +} + // CreateUser handles creating a new user in an organization func (uh *UserHandlers) CreateUser(c echo.Context) error { // Get permission context from middleware @@ -42,7 +58,7 @@ func (uh *UserHandlers) CreateUser(c echo.Context) error { // Create user user, err := uh.userService.CreateUserInOrg(permCtx.OrgID, permCtx.User.ID, req) if err != nil { - if err.Error() == "user with email "+req.Email+" already exists" { + if strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "already a member") { return echo.NewHTTPError(http.StatusConflict, err.Error()) } return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user") @@ -369,7 +385,7 @@ func (uh *UserHandlers) CreateUserInAnyOrg(c echo.Context) error { // Create user in target organization createdUser, err := uh.userService.CreateUserInAnyOrg(orgID, user.ID, req) if err != nil { - if err.Error() == "user with email "+req.Email+" already exists" { + if strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "already a member") { return echo.NewHTTPError(http.StatusConflict, err.Error()) } if err.Error() == "organization with ID "+orgIDStr+" does not exist" { diff --git a/internal/api/users/user_service.go b/internal/api/users/user_service.go index 8e70b7bc..822fdefb 100644 --- a/internal/api/users/user_service.go +++ b/internal/api/users/user_service.go @@ -49,7 +49,7 @@ type UserWithRole struct { // CreateUserRequest represents the request to create a new user type CreateUserRequest struct { Email string `json:"email" validate:"required,email"` - Password string `json:"password" validate:"required,min=8"` + Password string `json:"password" validate:"omitempty,min=8"` FirstName string `json:"first_name"` LastName string `json:"last_name"` RoleID int64 `json:"role_id" validate:"required"` @@ -65,35 +65,53 @@ type UpdateUserRequest struct { // CreateUserInOrg creates a new user in the specified organization func (us *UserService) CreateUserInOrg(orgID, createdByUserID int64, req CreateUserRequest) (*UserWithRole, error) { - // Hash password - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) - if err != nil { - return nil, fmt.Errorf("failed to hash password: %w", err) + var hashedPassword []byte + var err error + if req.Password != "" { + // Hash password + hashedPassword, err = bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("failed to hash password: %w", err) + } } var userID int64 err = us.store.WithTx(func(tx *sql.Tx) error { - // Check if email already exists + // Check if email already exists globally var existingUserID int64 err := us.store.TxQueryRow(tx, "SELECT id FROM users WHERE email = $1", req.Email).Scan(&existingUserID) - if err != sql.ErrNoRows { - if err == nil { - return fmt.Errorf("user with email %s already exists", req.Email) + + if err == nil { + // User exists globally. Check if they are already in THIS organization. + var existsInOrg bool + err = us.store.TxQueryRow(tx, "SELECT EXISTS(SELECT 1 FROM user_roles WHERE user_id = $1 AND org_id = $2)", existingUserID, orgID).Scan(&existsInOrg) + if err != nil { + return fmt.Errorf("failed to check existing user role: %w", err) + } + if existsInOrg { + return fmt.Errorf("user with email %s is already a member of this organization", req.Email) } - return fmt.Errorf("failed to check existing email: %w", err) - } - // Create user - err = us.store.TxQueryRow(tx, ` - INSERT INTO users (email, password_hash, first_name, last_name, created_by_user_id, password_reset_required, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, true, NOW(), NOW()) - RETURNING id - `, req.Email, string(hashedPassword), req.FirstName, req.LastName, createdByUserID).Scan(&userID) - if err != nil { - return fmt.Errorf("failed to create user: %w", err) + // Link existing user to this organization + userID = existingUserID + } else if err == sql.ErrNoRows { + if len(hashedPassword) == 0 { + return fmt.Errorf("password is required for new users") + } + // Create new user globally + err = us.store.TxQueryRow(tx, ` + INSERT INTO users (email, password_hash, first_name, last_name, created_by_user_id, password_reset_required, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, true, NOW(), NOW()) + RETURNING id + `, req.Email, string(hashedPassword), req.FirstName, req.LastName, createdByUserID).Scan(&userID) + if err != nil { + return fmt.Errorf("failed to create user: %w", err) + } + } else { + return fmt.Errorf("failed to check existing email: %w", err) } - // Add user role + // Add user role in this organization _, err = us.store.TxExec(tx, ` INSERT INTO user_roles (user_id, org_id, role_id, created_at, updated_at) VALUES ($1, $2, $3, NOW(), NOW()) @@ -106,6 +124,7 @@ func (us *UserService) CreateUserInOrg(orgID, createdByUserID int64, req CreateU err = us.addUserAuditLog(tx, orgID, userID, createdByUserID, "created", map[string]interface{}{ "role_id": req.RoleID, "email": req.Email, + "note": "user linked to organization (existing global user)", }) if err != nil { return fmt.Errorf("failed to add audit log: %w", err) @@ -179,6 +198,31 @@ func (us *UserService) getInvitedByUserName(userID int64) string { return name } +// CheckUserByEmail checks if a user exists globally and returns basic info +func (us *UserService) CheckUserByEmail(email string) (*UserCheckResponse, error) { + var id int64 + var firstName, lastName sql.NullString + err := us.store.QueryRow(` + SELECT id, first_name, last_name FROM users WHERE email = $1 + `, email).Scan(&id, &firstName, &lastName) + + if err != nil { + if err == sql.ErrNoRows { + return &UserCheckResponse{ + Exists: false, + }, nil + } + return nil, fmt.Errorf("failed to check user: %w", err) + } + + return &UserCheckResponse{ + Exists: true, + ID: id, + FirstName: firstName.String, + LastName: lastName.String, + }, nil +} + // GetUserInOrg gets a user in a specific organization with their role func (us *UserService) GetUserInOrg(orgID, userID int64) (*UserWithRole, error) { user := &UserWithRole{} @@ -701,3 +745,11 @@ type RoleUserCount struct { RoleName string `json:"role_name"` UserCount int `json:"user_count"` } + +// UserCheckResponse represents the response for checking user existence +type UserCheckResponse struct { + Exists bool `json:"exists"` + ID int64 `json:"id,omitempty"` + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` +} diff --git a/storage/license/plan_change_store.go b/storage/license/plan_change_store.go index c6d3f11a..6e3382cc 100644 --- a/storage/license/plan_change_store.go +++ b/storage/license/plan_change_store.go @@ -275,6 +275,12 @@ func (s *PlanChangeStore) ApplyScheduledPlanChange(ctx context.Context, tr DueTr return fmt.Errorf("apply scheduled plan change: %w", err) } + if tr.TargetPlanCode == "free_30k" { + if _, err := tx.ExecContext(ctx, `DELETE FROM ai_connectors WHERE org_id = $1 AND provider_name = 'livereview-default-ai'`, tr.OrgID); err != nil { + return fmt.Errorf("remove default ai connector on scheduled downgrade to free: %w", err) + } + } + payload := map[string]interface{}{ "from_plan_code": tr.FromPlanCode, "to_plan_code": tr.TargetPlanCode, diff --git a/storage/payment/subscription_store.go b/storage/payment/subscription_store.go index a4deb464..bc27504d 100644 --- a/storage/payment/subscription_store.go +++ b/storage/payment/subscription_store.go @@ -231,6 +231,10 @@ func SyncOrgBillingStateToFreeTx(ctx context.Context, tx *sql.Tx, orgID int, now return fmt.Errorf("sync org billing state to free: %w", err) } + if _, err := tx.ExecContext(ctx, `DELETE FROM ai_connectors WHERE org_id = $1 AND provider_name = 'livereview-default-ai'`, orgID); err != nil { + return fmt.Errorf("remove default ai connector on downgrade to free: %w", err) + } + return nil } @@ -425,6 +429,10 @@ func (s *SubscriptionStore) DowngradeExpiredRoleForUserOrg(ctx context.Context, return false, fmt.Errorf("align org billing state from expired user role downgrade: %w", err) } + if _, err := tx.ExecContext(ctx, `DELETE FROM ai_connectors WHERE org_id = $1 AND provider_name = 'livereview-default-ai'`, orgID); err != nil { + return false, fmt.Errorf("remove default ai connector from expired user role downgrade: %w", err) + } + metadata := map[string]interface{}{ "user_id": userID, "org_id": orgID, @@ -567,6 +575,13 @@ func (s *SubscriptionStore) reconcileExpiredSubscriptions(ctx context.Context, o return nil, fmt.Errorf("align org billing state after expiry reconciliation: %w", err) } + if _, err := tx.ExecContext(ctx, ` + DELETE FROM ai_connectors + WHERE org_id = ANY($1) AND provider_name = 'livereview-default-ai' + `, pq.Array(orgIDs)); err != nil { + return nil, fmt.Errorf("remove default ai connectors after expiry reconciliation: %w", err) + } + for _, item := range results { metadata := map[string]interface{}{ "subscription_id": item.RazorpaySubscriptionID, diff --git a/ui/src/api/users.ts b/ui/src/api/users.ts index 1f4ff532..a401502f 100644 --- a/ui/src/api/users.ts +++ b/ui/src/api/users.ts @@ -1,5 +1,16 @@ import apiClient from './apiClient'; +export interface UserCheckResponse { + exists: boolean; + id?: number; + first_name?: string; + last_name?: string; +} + +export const checkUserByEmail = async (orgId: string, email: string): Promise => { + return apiClient.get(`/orgs/${orgId}/users/check?email=${encodeURIComponent(email)}`); +}; + // --- TypeScript Interfaces --- export interface Member { diff --git a/ui/src/components/UserManagement/UserForm.tsx b/ui/src/components/UserManagement/UserForm.tsx index 5c3a6752..ea2fa1d6 100644 --- a/ui/src/components/UserManagement/UserForm.tsx +++ b/ui/src/components/UserManagement/UserForm.tsx @@ -5,7 +5,7 @@ import * as z from 'zod'; import { useNavigate, useParams } from 'react-router-dom'; import toast from 'react-hot-toast'; import { useOrgContext } from '../../hooks/useOrgContext'; -import { createOrgUser, fetchOrgUser, updateOrgUser, Member } from '../../api/users'; +import { createOrgUser, fetchOrgUser, updateOrgUser, Member, checkUserByEmail } from '../../api/users'; import { Button, Input, Select } from '../UIPrimitives'; import { useAppDispatch } from '../../store/configureStore'; import { loadUserOrganizations } from '../../store/Organizations/reducer'; @@ -13,8 +13,8 @@ import { UpgradePromptModal } from '../Subscriptions'; const baseSchema = z.object({ email: z.string().email({ message: 'Invalid email address' }), - firstName: z.string().min(1, { message: 'First name is required' }), - lastName: z.string().min(1, { message: 'Last name is required' }), + firstName: z.string().optional(), + lastName: z.string().optional(), role: z.enum(['member', 'owner', 'super_admin']), password: z.string().optional(), password_confirmation: z.string().optional(), @@ -31,9 +31,34 @@ const UserForm: React.FC = () => { const isEditMode = !!userId; + const [existsGlobally, setExistsGlobally] = useState(false); + const [checkingEmail, setCheckingEmail] = useState(false); + const userSchema = baseSchema.refine( (data) => { - if (!isEditMode) { + if (!isEditMode && !existsGlobally) { + return data.firstName && data.firstName.length > 0; + } + return true; + }, + { + message: 'First name is required for new users', + path: ['firstName'], + } + ).refine( + (data) => { + if (!isEditMode && !existsGlobally) { + return data.lastName && data.lastName.length > 0; + } + return true; + }, + { + message: 'Last name is required for new users', + path: ['lastName'], + } + ).refine( + (data) => { + if (!isEditMode && !existsGlobally) { return data.password && data.password.length >= 8; } return true; @@ -44,7 +69,7 @@ const UserForm: React.FC = () => { } ).refine( (data) => { - if (!isEditMode) { + if (!isEditMode && !existsGlobally) { return data.password === data.password_confirmation; } return true; @@ -64,6 +89,9 @@ const UserForm: React.FC = () => { handleSubmit, formState: { errors, isSubmitting }, reset, + watch, + setValue, + trigger, } = useForm({ resolver: zodResolver(userSchema), defaultValues: { @@ -71,6 +99,30 @@ const UserForm: React.FC = () => { }, }); + const emailValue = watch('email'); + + const handleEmailCheck = async () => { + if (!currentOrgId || isEditMode || !emailValue || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailValue)) { + return; + } + + setCheckingEmail(true); + try { + const result = await checkUserByEmail(currentOrgId.toString(), emailValue); + setExistsGlobally(result.exists); + if (result.exists) { + setValue('firstName', result.first_name || ''); + setValue('lastName', result.last_name || ''); + // Clear password errors if any + trigger(); + } + } catch (error) { + console.error('Failed to check email', error); + } finally { + setCheckingEmail(false); + } + }; + useEffect(() => { if (userId && currentOrgId) { setLoading(true); @@ -138,14 +190,14 @@ const UserForm: React.FC = () => { toast.success(`User ${updatedUser.email} updated successfully!`); dispatch(loadUserOrganizations()); } else { - if (!data.password) { + if (!existsGlobally && !data.password) { toast.error('Password is required for new users.'); return; } const newUser = await createOrgUser(currentOrgId.toString(), { email: data.email, - first_name: data.firstName, - last_name: data.lastName, + first_name: data.firstName || '', + last_name: data.lastName || '', role_id: roleNameToId(data.role), password: data.password, }); @@ -191,26 +243,43 @@ const UserForm: React.FC = () => { id="email" type="email" {...register('email')} + onBlur={handleEmailCheck} error={errors.email?.message} required disabled={isEditMode} + icon={checkingEmail ? ( + + + + + ) : undefined} + iconPosition="right" /> -
- - -
+ + {existsGlobally && !isEditMode && ( +
+ This user already has a LiveReview account. Please select a role. +
+ )} + + {!existsGlobally && ( +
+ + +
+ )}