Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .air.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 18 additions & 2 deletions internal/api/users/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"database/sql"
"net/http"
"strconv"
"strings"

"github.com/labstack/echo/v4"
"github.com/livereview/internal/api/auth"
Expand All @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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" {
Expand Down
92 changes: 72 additions & 20 deletions internal/api/users/user_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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())
Expand All @@ -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)
Expand Down Expand Up @@ -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{}
Expand Down Expand Up @@ -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"`
}
6 changes: 6 additions & 0 deletions storage/license/plan_change_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions storage/payment/subscription_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions ui/src/api/users.ts
Original file line number Diff line number Diff line change
@@ -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<UserCheckResponse> => {
return apiClient.get<UserCheckResponse>(`/orgs/${orgId}/users/check?email=${encodeURIComponent(email)}`);
};

// --- TypeScript Interfaces ---

export interface Member {
Expand Down
Loading
Loading