This commit is contained in:
2026-06-30 10:39:29 +07:00
parent aaafa6ada9
commit d9c8e3930b
28 changed files with 3040 additions and 167 deletions

View File

@@ -0,0 +1,289 @@
package handlers
import (
"fmt"
"strings"
"time"
"server/internal/auth"
"server/internal/mail"
"server/internal/models"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
func normalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
func staffPublic(a models.StaffAccount) fiber.Map {
return fiber.Map{
"id": a.ID,
"email": a.Email,
"fullName": a.FullName,
"mustChangePassword": a.MustChangePassword,
}
}
// POST /api/auth/provision — lần đầu: gửi mật khẩu qua email nếu đuôi hợp lệ
func ProvisionStaffHandler(db *gorm.DB, mailer mail.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
var req struct {
Email string `json:"email"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
}
email := normalizeEmail(req.Email)
if email == "" || !strings.Contains(email, "@") {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Email không hợp lệ"})
}
if !auth.IsEmailDomainAllowed(db, email) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Đuôi email không được phép truy cập hệ thống"})
}
var existing models.StaffAccount
err := db.Where("email = ?", email).First(&existing).Error
if err == nil {
if !existing.IsActive {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Tài khoản đã bị khóa"})
}
return c.JSON(fiber.Map{"ok": true, "message": "Tài khoản đã tồn tại. Dùng đăng nhập hoặc quên mật khẩu."})
}
if err != gorm.ErrRecordNotFound {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
tempPass := auth.RandomPassword(12)
hash, err := auth.HashPassword(tempPass)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
staff := models.StaffAccount{
Email: email,
PasswordHash: hash,
MustChangePassword: true,
IsActive: true,
}
if err := db.Create(&staff).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
body := fmt.Sprintf(`<p>Xin chào,</p><p>Tài khoản Simple Care Management đã được tạo.</p>
<p><strong>Email:</strong> %s<br/><strong>Mật khẩu tạm:</strong> %s</p>
<p>Đăng nhập và đổi mật khẩu ngay lần đầu.</p>`, email, tempPass)
if err := mailer.Send(email, "Simple Care — Mật khẩu truy cập", body); err != nil {
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": "Tạo tài khoản ok nhưng gửi mail thất bại: " + err.Error()})
}
return c.JSON(fiber.Map{"ok": true, "message": "Đã gửi mật khẩu tạm tới email của bạn"})
}
}
// POST /api/auth/login
func LoginStaffHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var req struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
}
email := normalizeEmail(req.Email)
var staff models.StaffAccount
if err := db.Where("email = ?", email).First(&staff).Error; err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Email hoặc mật khẩu không đúng"})
}
if !staff.IsActive {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Tài khoản đã bị khóa"})
}
if !auth.CheckPassword(staff.PasswordHash, req.Password) {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Email hoặc mật khẩu không đúng"})
}
token, err := auth.IssueToken(&staff)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"token": token,
"mustChangePassword": staff.MustChangePassword,
"staff": staffPublic(staff),
})
}
}
// GET /api/auth/me
func MeStaffHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
staffID, ok := c.Locals("staffId").(uint)
if !ok || staffID == 0 {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"})
}
var staff models.StaffAccount
if err := db.First(&staff, staffID).Error; err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Account not found"})
}
return c.JSON(fiber.Map{"staff": staffPublic(staff)})
}
}
// POST /api/auth/change-password
func ChangePasswordHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
staffID, ok := c.Locals("staffId").(uint)
if !ok || staffID == 0 {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"})
}
var req struct {
OldPassword string `json:"oldPassword"`
NewPassword string `json:"newPassword"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
}
if len(req.NewPassword) < 8 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Mật khẩu mới tối thiểu 8 ký tự"})
}
var staff models.StaffAccount
if err := db.First(&staff, staffID).Error; err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Account not found"})
}
if !auth.CheckPassword(staff.PasswordHash, req.OldPassword) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Mật khẩu hiện tại không đúng"})
}
hash, err := auth.HashPassword(req.NewPassword)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
staff.PasswordHash = hash
staff.MustChangePassword = false
if err := db.Save(&staff).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
token, _ := auth.IssueToken(&staff)
return c.JSON(fiber.Map{"ok": true, "token": token, "staff": staffPublic(staff)})
}
}
// POST /api/auth/forgot-password
func ForgotPasswordHandler(db *gorm.DB, mailer mail.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
var req struct {
Email string `json:"email"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
}
email := normalizeEmail(req.Email)
var staff models.StaffAccount
if err := db.Where("email = ?", email).First(&staff).Error; err != nil {
return c.JSON(fiber.Map{"ok": true, "message": "Nếu email tồn tại, link đặt lại mật khẩu đã được gửi"})
}
token := auth.RandomToken(24)
row := models.PasswordResetToken{
StaffID: staff.ID,
Token: token,
ExpiresAt: time.Now().Add(2 * time.Hour),
}
if err := db.Create(&row).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
body := fmt.Sprintf(`<p>Đặt lại mật khẩu Simple Care.</p>
<p>Mã đặt lại (hiệu lực 2 giờ): <strong>%s</strong></p>
<p>Nhập mã này trên màn hình đặt lại mật khẩu.</p>`, token)
_ = mailer.Send(email, "Simple Care — Đặt lại mật khẩu", body)
return c.JSON(fiber.Map{"ok": true, "message": "Nếu email tồn tại, mã đặt lại đã được gửi"})
}
}
// POST /api/auth/reset-password
func ResetPasswordHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var req struct {
Token string `json:"token"`
NewPassword string `json:"newPassword"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
}
if len(req.NewPassword) < 8 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Mật khẩu mới tối thiểu 8 ký tự"})
}
var row models.PasswordResetToken
if err := db.Where("token = ? AND used_at IS NULL AND expires_at > ?", req.Token, time.Now()).First(&row).Error; err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Mã không hợp lệ hoặc đã hết hạn"})
}
var staff models.StaffAccount
if err := db.First(&staff, row.StaffID).Error; err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Tài khoản không tồn tại"})
}
hash, err := auth.HashPassword(req.NewPassword)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
now := time.Now()
staff.PasswordHash = hash
staff.MustChangePassword = false
row.UsedAt = &now
if err := db.Save(&staff).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
_ = db.Save(&row)
return c.JSON(fiber.Map{"ok": true, "message": "Đã đặt lại mật khẩu. Vui lòng đăng nhập."})
}
}
// GET /api/admin/email-domains
func ListEmailDomainsHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var rows []models.EmailDomain
db.Order("domain asc").Find(&rows)
return c.JSON(fiber.Map{"data": rows})
}
}
// POST /api/admin/email-domains
func AddEmailDomainHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var req struct {
Domain string `json:"domain"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
}
domain := strings.ToLower(strings.TrimSpace(req.Domain))
if domain == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "domain required"})
}
row := models.EmailDomain{Domain: domain, IsActive: true}
if err := db.Where("domain = ?", domain).FirstOrCreate(&row).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true, "data": row})
}
}
// DELETE /api/admin/email-domains/:id
func DeleteEmailDomainHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
id := c.Params("id")
if err := db.Delete(&models.EmailDomain{}, id).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
}
// GET /api/admin/staff — danh sách tài khoản
func ListStaffHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var rows []models.StaffAccount
db.Order("email asc").Find(&rows)
out := make([]fiber.Map, 0, len(rows))
for _, r := range rows {
out = append(out, staffPublic(r))
}
return c.JSON(fiber.Map{"data": out})
}
}

View File

@@ -0,0 +1,282 @@
package handlers
import (
"strconv"
"strings"
"time"
"server/internal/middleware"
"server/internal/models"
internalWs "server/internal/websocket"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
func chatMessageDTO(m models.ChatMessage, staffName string) fiber.Map {
return fiber.Map{
"id": m.ID,
"staffId": m.StaffID,
"studentRkId": m.StudentRkID,
"senderRole": m.SenderRole,
"body": m.Body,
"createdAt": m.CreatedAt,
"readAt": m.ReadAt,
"staffName": staffName,
}
}
// GET /api/chat/students?q=
func SearchChatStudentsHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
q := strings.TrimSpace(c.Query("q", ""))
limit := 30
query := db.Model(&models.Student{})
if q != "" {
like := "%" + q + "%"
query = query.Where("full_name LIKE ? OR student_code LIKE ? OR email LIKE ?", like, like, like)
}
var students []models.Student
query.Order("full_name asc").Limit(limit).Find(&students)
out := make([]fiber.Map, 0, len(students))
for _, s := range students {
online := internalWs.Hub.IsStudentOnline(s.RkID)
out = append(out, fiber.Map{
"studentRkId": s.RkID,
"fullName": s.FullName,
"studentCode": s.StudentCode,
"email": s.Email,
"online": online,
})
}
return c.JSON(fiber.Map{"data": out})
}
}
// GET /api/chat/conversations
func ListChatConversationsHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
staffID := middleware.StaffIDFromCtx(c)
type row struct {
StudentRkID int64
LastAt time.Time
Unread int64
}
var rows []row
db.Raw(`
SELECT student_rk_id AS student_rk_id, MAX(created_at) AS last_at,
SUM(CASE WHEN sender_role = 'student' AND read_at IS NULL THEN 1 ELSE 0 END) AS unread
FROM chat_messages WHERE staff_id = ? GROUP BY student_rk_id ORDER BY last_at DESC LIMIT 50
`, staffID).Scan(&rows)
out := make([]fiber.Map, 0, len(rows))
for _, r := range rows {
var st models.Student
db.Where("rk_id = ?", r.StudentRkID).First(&st)
var last models.ChatMessage
db.Where("staff_id = ? AND student_rk_id = ?", staffID, r.StudentRkID).Order("id desc").First(&last)
out = append(out, fiber.Map{
"studentRkId": r.StudentRkID,
"fullName": st.FullName,
"studentCode": st.StudentCode,
"lastMessage": last.Body,
"lastAt": r.LastAt,
"unread": r.Unread,
})
}
return c.JSON(fiber.Map{"data": out})
}
}
// GET /api/chat/messages/:studentRkId
func ListChatMessagesHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
staffID := middleware.StaffIDFromCtx(c)
studentRkID, _ := strconv.ParseInt(c.Params("studentRkId"), 10, 64)
if studentRkID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid student id"})
}
var staff models.StaffAccount
_ = db.First(&staff, staffID).Error
var msgs []models.ChatMessage
db.Where("staff_id = ? AND student_rk_id = ?", staffID, studentRkID).Order("id asc").Limit(200).Find(&msgs)
now := time.Now()
db.Model(&models.ChatMessage{}).
Where("staff_id = ? AND student_rk_id = ? AND sender_role = 'student' AND read_at IS NULL", staffID, studentRkID).
Update("read_at", now)
out := make([]fiber.Map, 0, len(msgs))
for _, m := range msgs {
out = append(out, chatMessageDTO(m, staff.FullName))
}
return c.JSON(fiber.Map{"data": out})
}
}
// POST /api/chat/messages
func SendChatMessageHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
staffID := middleware.StaffIDFromCtx(c)
var req struct {
StudentRkID int64 `json:"studentRkId"`
Body string `json:"body"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
}
body := strings.TrimSpace(req.Body)
if body == "" || req.StudentRkID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "studentRkId and body required"})
}
var staff models.StaffAccount
if err := db.First(&staff, staffID).Error; err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "staff not found"})
}
msg := models.ChatMessage{
StaffID: staffID,
StudentRkID: req.StudentRkID,
SenderRole: "staff",
Body: body,
}
if err := db.Create(&msg).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
dto := chatMessageDTO(msg, staff.FullName)
internalWs.Hub.PushChatToStudent(req.StudentRkID, dto)
return c.JSON(fiber.Map{"ok": true, "data": dto})
}
}
// GET /api/student/chat/conversations?studentRkId=
func StudentListChatConversationsHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
studentRkID, _ := strconv.ParseInt(c.Query("studentRkId"), 10, 64)
if studentRkID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "studentRkId required"})
}
type row struct {
StaffID uint
LastAt time.Time
Unread int64
}
var rows []row
db.Raw(`
SELECT staff_id AS staff_id, MAX(created_at) AS last_at,
SUM(CASE WHEN sender_role = 'staff' AND read_at IS NULL THEN 1 ELSE 0 END) AS unread
FROM chat_messages WHERE student_rk_id = ? GROUP BY staff_id ORDER BY last_at DESC LIMIT 50
`, studentRkID).Scan(&rows)
out := make([]fiber.Map, 0, len(rows))
for _, r := range rows {
var staff models.StaffAccount
name, email := "", ""
if db.First(&staff, r.StaffID).Error == nil {
name = staff.FullName
email = staff.Email
if name == "" {
name = email
}
}
var last models.ChatMessage
db.Where("staff_id = ? AND student_rk_id = ?", r.StaffID, studentRkID).Order("id desc").First(&last)
out = append(out, fiber.Map{
"staffId": r.StaffID,
"staffName": name,
"staffEmail": email,
"lastMessage": last.Body,
"lastAt": r.LastAt,
"unread": r.Unread,
})
}
return c.JSON(fiber.Map{"data": out})
}
}
// GET /api/student/chat/messages?studentRkId=&staffId=
func StudentListChatHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
studentRkID, _ := strconv.ParseInt(c.Query("studentRkId"), 10, 64)
staffID, _ := strconv.ParseUint(c.Query("staffId", "0"), 10, 64)
if studentRkID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "studentRkId required"})
}
query := db.Where("student_rk_id = ?", studentRkID)
if staffID > 0 {
query = query.Where("staff_id = ?", staffID)
}
var msgs []models.ChatMessage
query.Order("id asc").Limit(200).Find(&msgs)
now := time.Now()
readQ := db.Model(&models.ChatMessage{}).
Where("student_rk_id = ? AND sender_role = 'staff' AND read_at IS NULL", studentRkID)
if staffID > 0 {
readQ = readQ.Where("staff_id = ?", staffID)
}
readQ.Update("read_at", now)
staffNames := map[uint]string{}
out := make([]fiber.Map, 0, len(msgs))
for _, m := range msgs {
name := staffNames[m.StaffID]
if name == "" {
var st models.StaffAccount
if db.First(&st, m.StaffID).Error == nil {
name = st.FullName
if name == "" {
name = st.Email
}
}
staffNames[m.StaffID] = name
}
out = append(out, chatMessageDTO(m, name))
}
var lastStaffID uint
for i := len(msgs) - 1; i >= 0; i-- {
if msgs[i].SenderRole == "staff" {
lastStaffID = msgs[i].StaffID
break
}
}
return c.JSON(fiber.Map{"data": out, "replyStaffId": lastStaffID})
}
}
// POST /api/student/chat/messages
func StudentSendChatHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var req struct {
StudentRkID int64 `json:"studentRkId"`
StaffID uint `json:"staffId"`
Body string `json:"body"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
}
body := strings.TrimSpace(req.Body)
if body == "" || req.StudentRkID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid payload"})
}
staffID := req.StaffID
if staffID == 0 {
var last models.ChatMessage
if err := db.Where("student_rk_id = ? AND sender_role = 'staff'", req.StudentRkID).
Order("id desc").First(&last).Error; err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "chưa có giáo viên nhắn tin"})
}
staffID = last.StaffID
}
msg := models.ChatMessage{
StaffID: staffID,
StudentRkID: req.StudentRkID,
SenderRole: "student",
Body: body,
}
if err := db.Create(&msg).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
var staff models.StaffAccount
_ = db.First(&staff, staffID).Error
dto := chatMessageDTO(msg, staff.FullName)
internalWs.Hub.PushChatToStaff(staffID, dto)
return c.JSON(fiber.Map{"ok": true, "data": dto})
}
}