Boost Azure Demo
This commit is contained in:
384
Backend/internal/handlers/web/auth/auth.go
Normal file
384
Backend/internal/handlers/web/auth/auth.go
Normal file
@@ -0,0 +1,384 @@
|
||||
// Path: Backend/internal/handlers/auth/auth.go
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"boostai-backend/internal/config"
|
||||
"boostai-backend/internal/database"
|
||||
"boostai-backend/internal/http/respond"
|
||||
authmw "boostai-backend/internal/middleware"
|
||||
"boostai-backend/internal/sqlc"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const authQueryTimeout = 5 * time.Second
|
||||
|
||||
type Handler struct {
|
||||
db *database.DB
|
||||
queries *sqlc.Queries
|
||||
cfg *config.Config
|
||||
auth *authmw.AuthMiddleware
|
||||
}
|
||||
|
||||
type authProfileResponse struct {
|
||||
PreferredName *string `json:"preferred_name"`
|
||||
ProfileIconURL *string `json:"profile_icon_url"`
|
||||
Headline *string `json:"headline"`
|
||||
Bio *string `json:"bio"`
|
||||
Timezone *string `json:"timezone"`
|
||||
Locale *string `json:"locale"`
|
||||
GradeLevel *string `json:"grade_level"`
|
||||
LearningGoal *string `json:"learning_goal"`
|
||||
CreatedAt *time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type authUserResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
FullName string `json:"full_name"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
Profile authProfileResponse `json:"profile"`
|
||||
}
|
||||
|
||||
type authResponse struct {
|
||||
User authUserResponse `json:"user"`
|
||||
}
|
||||
|
||||
type registerRequest struct {
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
RememberMe bool `json:"remember_me"`
|
||||
}
|
||||
|
||||
type updateProfileRequest struct {
|
||||
FullName *string `json:"full_name"`
|
||||
PreferredName *string `json:"preferred_name"`
|
||||
ProfileIconURL *string `json:"profile_icon_url"`
|
||||
Headline *string `json:"headline"`
|
||||
Bio *string `json:"bio"`
|
||||
Timezone *string `json:"timezone"`
|
||||
Locale *string `json:"locale"`
|
||||
GradeLevel *string `json:"grade_level"`
|
||||
LearningGoal *string `json:"learning_goal"`
|
||||
}
|
||||
|
||||
func NewHandler(cfg *config.Config, db *database.DB, auth *authmw.AuthMiddleware) *Handler {
|
||||
return &Handler{db: db, queries: sqlc.New(db.Pool), cfg: cfg, auth: auth}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterUser(c *fiber.Ctx) error {
|
||||
var req registerRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body")
|
||||
}
|
||||
|
||||
fullName := strings.TrimSpace(strings.TrimSpace(req.FirstName) + " " + strings.TrimSpace(req.LastName))
|
||||
if fullName == "" || strings.TrimSpace(req.Email) == "" || strings.TrimSpace(req.Password) == "" {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "first_name, last_name, email, and password are required")
|
||||
}
|
||||
|
||||
role := sqlc.UserRoleStudent
|
||||
if strings.TrimSpace(req.Role) != "" {
|
||||
role = sqlc.UserRole(strings.TrimSpace(req.Role))
|
||||
}
|
||||
if role != sqlc.UserRoleStudent && role != sqlc.UserRoleTeacher {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "role must be student or teacher")
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return respond.Error(c, fiber.StatusInternalServerError, "auth_error", "Unable to secure password")
|
||||
}
|
||||
|
||||
ctx, cancel := withTimeout()
|
||||
defer cancel()
|
||||
|
||||
user, err := h.queries.CreateUser(ctx, sqlc.CreateUserParams{
|
||||
Email: strings.TrimSpace(strings.ToLower(req.Email)),
|
||||
PasswordHash: pgtype.Text{String: string(hashedPassword), Valid: true},
|
||||
Role: role,
|
||||
FullName: fullName,
|
||||
})
|
||||
if err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
if err := h.setSessionCookie(c, user, false); err != nil {
|
||||
return respond.Error(c, fiber.StatusInternalServerError, "auth_error", "Unable to create session")
|
||||
}
|
||||
|
||||
authUser, err := h.queries.GetAuthUserByID(ctx, user.ID)
|
||||
if err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(authResponse{User: mapAuthUserByID(authUser)})
|
||||
}
|
||||
|
||||
func (h *Handler) Login(c *fiber.Ctx) error {
|
||||
var req loginRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Email) == "" || strings.TrimSpace(req.Password) == "" {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "email and password are required")
|
||||
}
|
||||
|
||||
ctx, cancel := withTimeout()
|
||||
defer cancel()
|
||||
|
||||
user, err := h.queries.GetUserByEmail(ctx, strings.TrimSpace(strings.ToLower(req.Email)))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return respond.Error(c, fiber.StatusUnauthorized, "invalid_credentials", "Invalid email or password")
|
||||
}
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
if !user.IsActive || !user.PasswordHash.Valid {
|
||||
return respond.Error(c, fiber.StatusUnauthorized, "invalid_credentials", "Invalid email or password")
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash.String), []byte(req.Password)); err != nil {
|
||||
return respond.Error(c, fiber.StatusUnauthorized, "invalid_credentials", "Invalid email or password")
|
||||
}
|
||||
|
||||
if err := h.setSessionCookie(c, user, req.RememberMe); err != nil {
|
||||
return respond.Error(c, fiber.StatusInternalServerError, "auth_error", "Unable to create session")
|
||||
}
|
||||
|
||||
authUser, err := h.queries.GetAuthUserByID(ctx, user.ID)
|
||||
if err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
return c.JSON(authResponse{User: mapAuthUserByID(authUser)})
|
||||
}
|
||||
|
||||
func (h *Handler) Me(c *fiber.Ctx) error {
|
||||
userID := authmw.CurrentUserID(c)
|
||||
if userID == 0 {
|
||||
return respond.Error(c, fiber.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
}
|
||||
|
||||
ctx, cancel := withTimeout()
|
||||
defer cancel()
|
||||
|
||||
user, err := h.queries.GetAuthUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return respond.Error(c, fiber.StatusUnauthorized, "unauthorized", "User not found")
|
||||
}
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
return c.JSON(authResponse{User: mapAuthUserByID(user)})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateMe(c *fiber.Ctx) error {
|
||||
userID := authmw.CurrentUserID(c)
|
||||
if userID == 0 {
|
||||
return respond.Error(c, fiber.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
}
|
||||
|
||||
var req updateProfileRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body")
|
||||
}
|
||||
|
||||
ctx, cancel := withTimeout()
|
||||
defer cancel()
|
||||
|
||||
tx, err := h.db.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
defer func() {
|
||||
_ = tx.Rollback(ctx)
|
||||
}()
|
||||
|
||||
queries := h.queries.WithTx(tx)
|
||||
current, err := queries.GetAuthUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return respond.Error(c, fiber.StatusUnauthorized, "unauthorized", "User not found")
|
||||
}
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
fullName, err := mergeRequiredString(current.UserFullName, req.FullName, "full_name")
|
||||
if err != nil {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", err.Error())
|
||||
}
|
||||
|
||||
if fullName != current.UserFullName {
|
||||
if _, err := queries.UpdateUserFullName(ctx, sqlc.UpdateUserFullNameParams{ID: userID, FullName: fullName}); err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := queries.UpsertUserProfile(ctx, sqlc.UpsertUserProfileParams{
|
||||
UserID: userID,
|
||||
PreferredName: mergeNullableText(current.PreferredName, req.PreferredName),
|
||||
ProfileIconUrl: mergeNullableText(current.ProfileIconUrl, req.ProfileIconURL),
|
||||
Headline: mergeNullableText(current.Headline, req.Headline),
|
||||
Bio: mergeNullableText(current.Bio, req.Bio),
|
||||
Timezone: mergeNullableText(current.Timezone, req.Timezone),
|
||||
Locale: mergeNullableText(current.Locale, req.Locale),
|
||||
GradeLevel: mergeNullableText(current.GradeLevel, req.GradeLevel),
|
||||
LearningGoal: mergeNullableText(current.LearningGoal, req.LearningGoal),
|
||||
}); err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
updated, err := queries.GetAuthUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
return c.JSON(authResponse{User: mapAuthUserByID(updated)})
|
||||
}
|
||||
|
||||
func (h *Handler) Logout(c *fiber.Ctx) error {
|
||||
h.clearSessionCookie(c)
|
||||
return c.JSON(fiber.Map{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *Handler) setSessionCookie(c *fiber.Ctx, user sqlc.User, rememberMe bool) error {
|
||||
ttl := 24 * time.Hour
|
||||
if rememberMe {
|
||||
ttl = 30 * 24 * time.Hour
|
||||
}
|
||||
|
||||
token, err := h.auth.CreateToken(user.ID, user.Role, user.Email, ttl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: h.cfg.SessionCookie,
|
||||
Value: token,
|
||||
HTTPOnly: true,
|
||||
Secure: h.cfg.IsProduction(),
|
||||
SameSite: fiber.CookieSameSiteLaxMode,
|
||||
Path: "/",
|
||||
Expires: time.Now().UTC().Add(ttl),
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) clearSessionCookie(c *fiber.Ctx) {
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: h.cfg.SessionCookie,
|
||||
Value: "",
|
||||
HTTPOnly: true,
|
||||
Secure: h.cfg.IsProduction(),
|
||||
SameSite: fiber.CookieSameSiteLaxMode,
|
||||
Path: "/",
|
||||
Expires: time.Unix(0, 0),
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
func mapAuthUserByID(user sqlc.GetAuthUserByIDRow) authUserResponse {
|
||||
return authUserResponse{
|
||||
ID: user.UserID,
|
||||
Email: user.UserEmail,
|
||||
Role: string(user.UserRole),
|
||||
FullName: user.UserFullName,
|
||||
IsActive: user.UserIsActive,
|
||||
CreatedAt: timePointer(user.UserCreatedAt),
|
||||
UpdatedAt: timePointer(user.UserUpdatedAt),
|
||||
Profile: mapAuthProfile(user.PreferredName, user.ProfileIconUrl, user.Headline, user.Bio, user.Timezone, user.Locale, user.GradeLevel, user.LearningGoal, user.ProfileCreatedAt, user.ProfileUpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func mapAuthProfile(preferredName, profileIconURL, headline, bio, timezone, locale, gradeLevel, learningGoal pgtype.Text, createdAt, updatedAt pgtype.Timestamptz) authProfileResponse {
|
||||
return authProfileResponse{
|
||||
PreferredName: textPointer(preferredName),
|
||||
ProfileIconURL: textPointer(profileIconURL),
|
||||
Headline: textPointer(headline),
|
||||
Bio: textPointer(bio),
|
||||
Timezone: textPointer(timezone),
|
||||
Locale: textPointer(locale),
|
||||
GradeLevel: textPointer(gradeLevel),
|
||||
LearningGoal: textPointer(learningGoal),
|
||||
CreatedAt: timePointer(createdAt),
|
||||
UpdatedAt: timePointer(updatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func withTimeout() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), authQueryTimeout)
|
||||
}
|
||||
|
||||
func mergeRequiredString(current string, input *string, fieldName string) (string, error) {
|
||||
if input == nil {
|
||||
return current, nil
|
||||
}
|
||||
|
||||
value := strings.TrimSpace(*input)
|
||||
if value == "" {
|
||||
return "", errors.New(fieldName + " cannot be empty")
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func mergeNullableText(current pgtype.Text, input *string) pgtype.Text {
|
||||
if input == nil {
|
||||
return current
|
||||
}
|
||||
|
||||
value := strings.TrimSpace(*input)
|
||||
if value == "" {
|
||||
return pgtype.Text{}
|
||||
}
|
||||
|
||||
return pgtype.Text{String: value, Valid: true}
|
||||
}
|
||||
|
||||
func textPointer(value pgtype.Text) *string {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
|
||||
text := value.String
|
||||
return &text
|
||||
}
|
||||
|
||||
func timePointer(value pgtype.Timestamptz) *time.Time {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
|
||||
timestamp := value.Time.UTC()
|
||||
return ×tamp
|
||||
}
|
||||
46
Backend/internal/handlers/web/health/health.go
Normal file
46
Backend/internal/handlers/web/health/health.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// Path: Backend/internal/handlers/health/health.go
|
||||
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"boostai-backend/internal/database"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
environment string
|
||||
db *database.DB
|
||||
}
|
||||
|
||||
func NewHandler(environment string, db *database.DB) *Handler {
|
||||
return &Handler{environment: environment, db: db}
|
||||
}
|
||||
|
||||
func (h *Handler) Check(c *fiber.Ctx) error {
|
||||
status := "healthy"
|
||||
databaseStatus := "up"
|
||||
httpStatus := fiber.StatusOK
|
||||
|
||||
if h.db != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.db.Health(ctx); err != nil {
|
||||
status = "unhealthy"
|
||||
databaseStatus = "down"
|
||||
httpStatus = fiber.StatusServiceUnavailable
|
||||
}
|
||||
}
|
||||
|
||||
return c.Status(httpStatus).JSON(fiber.Map{
|
||||
"status": status,
|
||||
"service": "boostai-backend",
|
||||
"environment": h.environment,
|
||||
"database": databaseStatus,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
16
Backend/internal/handlers/web/root/root.go
Normal file
16
Backend/internal/handlers/web/root/root.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package root
|
||||
|
||||
import "github.com/gofiber/fiber/v2"
|
||||
|
||||
type Handler struct{}
|
||||
|
||||
func NewHandler() *Handler {
|
||||
return &Handler{}
|
||||
}
|
||||
|
||||
func (h *Handler) Index(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{
|
||||
"name": "BoostAI Backend",
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user