tam chat
This commit is contained in:
10
server/.env
10
server/.env
@@ -1 +1,9 @@
|
||||
QLDT_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InBodW9jbnRiQHJpa2tlaWFjYWRlbXkuY29tIiwibmFtZSI6Ik5ndXnhu4VuIFRoYW5oIELDrG5oIFBoxrDhu5tjIiwiaWQiOjI0LCJyb2xlIjpbeyJpZCI6MSwibmFtZSI6IkFETUlOIn0seyJpZCI6MywibmFtZSI6IlRFQUNIRVIifV0sInR5cGUiOiJ1c2VyIiwiaWF0IjoxNzgyNzc3NzEzLCJleHAiOjE3ODI4NjQxMTN9.bj4nMPYZWVIsuiuA3XHLtc8yFFwAC9MDlPx-FnIM6z4
|
||||
QLDT_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InBodW9jbnRiQHJpa2tlaWFjYWRlbXkuY29tIiwibmFtZSI6Ik5ndXnhu4VuIFRoYW5oIELDrG5oIFBoxrDhu5tjIiwiaWQiOjI0LCJyb2xlIjpbeyJpZCI6MSwibmFtZSI6IkFETUlOIn0seyJpZCI6MywibmFtZSI6IlRFQUNIRVIifV0sInR5cGUiOiJ1c2VyIiwiaWF0IjoxNzgyNzc3NzEzLCJleHAiOjE3ODI4NjQxMTN9.bj4nMPYZWVIsuiuA3XHLtc8yFFwAC9MDlPx-FnIM6z4
|
||||
|
||||
JWT_SECRET=simple-care-staff-jwt-change-in-production
|
||||
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=465
|
||||
MAIL_SECURE=true
|
||||
MAIL_AUTH_USER=phuocnguyenbp0@gmail.com
|
||||
MAIL_AUTH_PASS="cygi rtnv kkbw uuoz"
|
||||
@@ -9,6 +9,7 @@ require (
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/gofiber/fiber/v2 v2.52.13 // indirect
|
||||
github.com/gofiber/websocket/v2 v2.2.1 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
@@ -22,8 +23,9 @@ require (
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.51.0 // indirect
|
||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/text v0.20.0 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
gorm.io/driver/mysql v1.6.0 // indirect
|
||||
gorm.io/gorm v1.31.2 // indirect
|
||||
)
|
||||
|
||||
@@ -10,6 +10,8 @@ github.com/gofiber/fiber/v2 v2.52.13 h1:TOKP64iqC9b5P49VrBW5tHhUOvDyrtJ0xePEfzJb
|
||||
github.com/gofiber/fiber/v2 v2.52.13/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w=
|
||||
github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
@@ -37,12 +39,18 @@ github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1S
|
||||
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
|
||||
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
|
||||
105
server/internal/auth/auth.go
Normal file
105
server/internal/auth/auth.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"server/internal/models"
|
||||
)
|
||||
|
||||
const staffCtxKey = "staffId"
|
||||
|
||||
type StaffClaims struct {
|
||||
StaffID uint `json:"staffId"`
|
||||
Email string `json:"email"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func JWTSecret() string {
|
||||
if s := os.Getenv("JWT_SECRET"); s != "" {
|
||||
return s
|
||||
}
|
||||
return "simple-care-staff-jwt-change-me-in-production"
|
||||
}
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
func CheckPassword(hash, password string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
func RandomPassword(n int) string {
|
||||
if n < 8 {
|
||||
n = 8
|
||||
}
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)[:n]
|
||||
}
|
||||
|
||||
func RandomToken(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func IssueToken(staff *models.StaffAccount) (string, error) {
|
||||
claims := StaffClaims{
|
||||
StaffID: staff.ID,
|
||||
Email: staff.Email,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return t.SignedString([]byte(JWTSecret()))
|
||||
}
|
||||
|
||||
func ParseToken(tokenStr string) (*StaffClaims, error) {
|
||||
t, err := jwt.ParseWithClaims(tokenStr, &StaffClaims{}, func(t *jwt.Token) (any, error) {
|
||||
return []byte(JWTSecret()), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := t.Claims.(*StaffClaims)
|
||||
if !ok || !t.Valid {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func EmailDomainPart(email string) string {
|
||||
parts := strings.Split(strings.ToLower(strings.TrimSpace(email)), "@")
|
||||
if len(parts) != 2 {
|
||||
return ""
|
||||
}
|
||||
return parts[1]
|
||||
}
|
||||
|
||||
func IsEmailDomainAllowed(db *gorm.DB, email string) bool {
|
||||
domain := EmailDomainPart(email)
|
||||
if domain == "" {
|
||||
return false
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.EmailDomain{}).Where("is_active = ?", true).Count(&count)
|
||||
if count == 0 {
|
||||
return true
|
||||
}
|
||||
var found int64
|
||||
db.Model(&models.EmailDomain{}).Where("domain = ? AND is_active = ?", domain, true).Count(&found)
|
||||
return found > 0
|
||||
}
|
||||
@@ -53,6 +53,10 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&models.AcceptedWifi{},
|
||||
&models.StudentSession{},
|
||||
&models.AttendanceResult{},
|
||||
&models.StaffAccount{},
|
||||
&models.EmailDomain{},
|
||||
&models.PasswordResetToken{},
|
||||
&models.ChatMessage{},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
289
server/internal/handlers/handlers_auth.go
Normal file
289
server/internal/handlers/handlers_auth.go
Normal 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})
|
||||
}
|
||||
}
|
||||
282
server/internal/handlers/handlers_chat.go
Normal file
282
server/internal/handlers/handlers_chat.go
Normal 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})
|
||||
}
|
||||
}
|
||||
91
server/internal/mail/mail.go
Normal file
91
server/internal/mail/mail.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Host string
|
||||
Port string
|
||||
Secure bool
|
||||
User string
|
||||
Password string
|
||||
From string
|
||||
}
|
||||
|
||||
func LoadConfigFromEnv() Config {
|
||||
return Config{
|
||||
Host: strings.TrimSpace(os.Getenv("MAIL_HOST")),
|
||||
Port: strings.TrimSpace(os.Getenv("MAIL_PORT")),
|
||||
Secure: strings.EqualFold(os.Getenv("MAIL_SECURE"), "true"),
|
||||
User: strings.TrimSpace(os.Getenv("MAIL_AUTH_USER")),
|
||||
Password: strings.Trim(strings.TrimSpace(os.Getenv("MAIL_AUTH_PASS")), `"`),
|
||||
From: strings.TrimSpace(os.Getenv("MAIL_FROM")),
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) Enabled() bool {
|
||||
return c.Host != "" && c.Port != "" && c.User != "" && c.Password != ""
|
||||
}
|
||||
|
||||
func (c Config) fromAddr() string {
|
||||
if c.From != "" {
|
||||
return c.From
|
||||
}
|
||||
return c.User
|
||||
}
|
||||
|
||||
func (c Config) Send(to, subject, body string) error {
|
||||
if !c.Enabled() {
|
||||
return fmt.Errorf("mail chưa cấu hình (MAIL_HOST, MAIL_AUTH_USER, MAIL_AUTH_PASS)")
|
||||
}
|
||||
msg := strings.Join([]string{
|
||||
"From: Simple Care <" + c.fromAddr() + ">",
|
||||
"To: " + to,
|
||||
"Subject: " + subject,
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/html; charset=UTF-8",
|
||||
"",
|
||||
body,
|
||||
}, "\r\n")
|
||||
|
||||
addr := net.JoinHostPort(c.Host, c.Port)
|
||||
auth := smtp.PlainAuth("", c.User, c.Password, c.Host)
|
||||
|
||||
if c.Secure {
|
||||
tlsConfig := &tls.Config{ServerName: c.Host}
|
||||
conn, err := tls.Dial("tcp", addr, tlsConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := smtp.NewClient(conn, c.Host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.Mail(c.fromAddr()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.Rcpt(to); err != nil {
|
||||
return err
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write([]byte(msg)); err != nil {
|
||||
return err
|
||||
}
|
||||
return w.Close()
|
||||
}
|
||||
|
||||
return smtp.SendMail(addr, auth, c.fromAddr(), []string{to}, []byte(msg))
|
||||
}
|
||||
33
server/internal/middleware/staff.go
Normal file
33
server/internal/middleware/staff.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"server/internal/auth"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func RequireStaff() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
header := c.Get("Authorization")
|
||||
if header == "" || !strings.HasPrefix(header, "Bearer ") {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"})
|
||||
}
|
||||
token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
|
||||
claims, err := auth.ParseToken(token)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired token"})
|
||||
}
|
||||
c.Locals("staffId", claims.StaffID)
|
||||
c.Locals("staffEmail", claims.Email)
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func StaffIDFromCtx(c *fiber.Ctx) uint {
|
||||
if v, ok := c.Locals("staffId").(uint); ok {
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
52
server/internal/models/staff.go
Normal file
52
server/internal/models/staff.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// StaffAccount — tài khoản thầy cô truy cập management
|
||||
type StaffAccount struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Email string `gorm:"column:email;size:255;not null;uniqueIndex" json:"email"`
|
||||
PasswordHash string `gorm:"column:password_hash;size:255;not null" json:"-"`
|
||||
FullName string `gorm:"column:full_name;size:255" json:"fullName"`
|
||||
MustChangePassword bool `gorm:"column:must_change_password;not null;default:true" json:"mustChangePassword"`
|
||||
IsActive bool `gorm:"column:is_active;not null;default:true" json:"isActive"`
|
||||
}
|
||||
|
||||
func (StaffAccount) TableName() string { return "staff_accounts" }
|
||||
|
||||
// EmailDomain — đuôi email được phép đăng ký staff
|
||||
type EmailDomain struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Domain string `gorm:"column:domain;size:128;not null;uniqueIndex" json:"domain"`
|
||||
IsActive bool `gorm:"column:is_active;not null;default:true" json:"isActive"`
|
||||
}
|
||||
|
||||
func (EmailDomain) TableName() string { return "email_domains" }
|
||||
|
||||
// PasswordResetToken — quên mật khẩu
|
||||
type PasswordResetToken struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
StaffID uint `gorm:"column:staff_id;not null;index" json:"staffId"`
|
||||
Token string `gorm:"column:token;size:64;not null;uniqueIndex" json:"-"`
|
||||
ExpiresAt time.Time `gorm:"column:expires_at;not null" json:"expiresAt"`
|
||||
UsedAt *time.Time `gorm:"column:used_at" json:"usedAt,omitempty"`
|
||||
}
|
||||
|
||||
func (PasswordResetToken) TableName() string { return "password_reset_tokens" }
|
||||
|
||||
// ChatMessage — tin nhắn thầy cô ↔ sinh viên
|
||||
type ChatMessage struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
StaffID uint `gorm:"column:staff_id;not null;index:idx_chat_thread,priority:1" json:"staffId"`
|
||||
StudentRkID int64 `gorm:"column:student_rk_id;not null;index:idx_chat_thread,priority:2" json:"studentRkId"`
|
||||
SenderRole string `gorm:"column:sender_role;size:16;not null" json:"senderRole"` // staff | student
|
||||
Body string `gorm:"column:body;type:text;not null" json:"body"`
|
||||
ReadAt *time.Time `gorm:"column:read_at" json:"readAt,omitempty"`
|
||||
}
|
||||
|
||||
func (ChatMessage) TableName() string { return "chat_messages" }
|
||||
@@ -20,21 +20,56 @@ type SocketClient struct {
|
||||
Conn *websocket.Conn
|
||||
StudentID int64
|
||||
ClassID int64
|
||||
StaffID uint
|
||||
Role string // "student" | "teacher"
|
||||
Addr string
|
||||
}
|
||||
|
||||
type WsHub struct {
|
||||
mu sync.RWMutex
|
||||
students map[int64]*SocketClient
|
||||
teachers map[string]*SocketClient
|
||||
subscribers map[int64][]string // studentId -> list of teacher connection addresses
|
||||
mu sync.RWMutex
|
||||
students map[int64]*SocketClient
|
||||
teachers map[string]*SocketClient
|
||||
teachersByStaff map[uint][]string // staffId -> teacher connection addresses
|
||||
subscribers map[int64][]string // studentId -> list of teacher connection addresses
|
||||
}
|
||||
|
||||
var Hub = &WsHub{
|
||||
students: make(map[int64]*SocketClient),
|
||||
teachers: make(map[string]*SocketClient),
|
||||
subscribers: make(map[int64][]string),
|
||||
students: make(map[int64]*SocketClient),
|
||||
teachers: make(map[string]*SocketClient),
|
||||
teachersByStaff: make(map[uint][]string),
|
||||
subscribers: make(map[int64][]string),
|
||||
}
|
||||
|
||||
func (h *WsHub) IsStudentOnline(studentRkID int64) bool {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
_, ok := h.students[studentRkID]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (h *WsHub) PushChatToStudent(studentRkID int64, data map[string]any) {
|
||||
h.mu.RLock()
|
||||
client, ok := h.students[studentRkID]
|
||||
h.mu.RUnlock()
|
||||
if !ok || client == nil {
|
||||
return
|
||||
}
|
||||
_ = client.Conn.WriteJSON(SocketMsg{Event: "chat:message", Data: data})
|
||||
}
|
||||
|
||||
func (h *WsHub) PushChatToStaff(staffID uint, data map[string]any) {
|
||||
h.mu.RLock()
|
||||
addrs := append([]string(nil), h.teachersByStaff[staffID]...)
|
||||
h.mu.RUnlock()
|
||||
msg := SocketMsg{Event: "chat:message", Data: data}
|
||||
for _, addr := range addrs {
|
||||
h.mu.RLock()
|
||||
t, found := h.teachers[addr]
|
||||
h.mu.RUnlock()
|
||||
if found && t != nil {
|
||||
_ = t.Conn.WriteJSON(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WsHub) GetOnlineStudentIDs(classID int64) []int64 {
|
||||
@@ -63,7 +98,10 @@ func (h *WsHub) Register(c *SocketClient) {
|
||||
log.Printf("[WS] Student %d registered (Address: %s, Class: %d)", c.StudentID, c.Addr, c.ClassID)
|
||||
} else if c.Role == "teacher" {
|
||||
h.teachers[c.Addr] = c
|
||||
log.Printf("[WS] Teacher registered (Address: %s)", c.Addr)
|
||||
if c.StaffID > 0 {
|
||||
h.teachersByStaff[c.StaffID] = append(h.teachersByStaff[c.StaffID], c.Addr)
|
||||
}
|
||||
log.Printf("[WS] Teacher registered (Address: %s, Staff: %d)", c.Addr, c.StaffID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +127,20 @@ func (h *WsHub) Unregister(c *SocketClient) {
|
||||
}
|
||||
} else if c.Role == "teacher" {
|
||||
delete(h.teachers, c.Addr)
|
||||
if c.StaffID > 0 {
|
||||
list := h.teachersByStaff[c.StaffID]
|
||||
next := list[:0]
|
||||
for _, addr := range list {
|
||||
if addr != c.Addr {
|
||||
next = append(next, addr)
|
||||
}
|
||||
}
|
||||
if len(next) == 0 {
|
||||
delete(h.teachersByStaff, c.StaffID)
|
||||
} else {
|
||||
h.teachersByStaff[c.StaffID] = next
|
||||
}
|
||||
}
|
||||
log.Printf("[WS] Teacher %s disconnected", c.Addr)
|
||||
|
||||
// Dọn dẹp subscriptions của giáo viên này
|
||||
@@ -206,9 +258,11 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
|
||||
role := c.Query("role", "student")
|
||||
studentIDStr := c.Query("studentId", "0")
|
||||
classIDStr := c.Query("classId", "0")
|
||||
staffIDStr := c.Query("staffId", "0")
|
||||
|
||||
studentID, _ := strconv.ParseInt(studentIDStr, 10, 64)
|
||||
classID, _ := strconv.ParseInt(classIDStr, 10, 64)
|
||||
staffID64, _ := strconv.ParseUint(staffIDStr, 10, 64)
|
||||
|
||||
if role == "student" && db != nil {
|
||||
resolvedClassID := internalDb.FindActiveClassForStudent(db, studentID)
|
||||
@@ -221,6 +275,7 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
|
||||
Conn: c,
|
||||
StudentID: studentID,
|
||||
ClassID: classID,
|
||||
StaffID: uint(staffID64),
|
||||
Role: role,
|
||||
}
|
||||
|
||||
|
||||
115
server/main.go
115
server/main.go
@@ -6,6 +6,8 @@ import (
|
||||
|
||||
"server/internal/db"
|
||||
"server/internal/handlers"
|
||||
"server/internal/mail"
|
||||
"server/internal/middleware"
|
||||
"server/internal/qldt"
|
||||
"server/internal/syncjobs"
|
||||
internalWs "server/internal/websocket"
|
||||
@@ -47,6 +49,7 @@ func main() {
|
||||
qldtClient := qldt.NewClient(qldtBaseURL, qldtOrigin)
|
||||
classesJob := syncjobs.NewClassesSyncJob()
|
||||
studentsJob := syncjobs.NewStudentsSyncJob()
|
||||
mailer := mail.LoadConfigFromEnv()
|
||||
|
||||
app := fiber.New(fiber.Config{
|
||||
AppName: "Simple Care Sync Backend",
|
||||
@@ -72,54 +75,82 @@ func main() {
|
||||
|
||||
api := app.Group("/api")
|
||||
|
||||
// Dashboard Stats
|
||||
api.Get("/stats", handlers.GetStatsHandler(gormDB))
|
||||
// Auth công khai (management)
|
||||
api.Post("/auth/provision", handlers.ProvisionStaffHandler(gormDB, mailer))
|
||||
api.Post("/auth/login", handlers.LoginStaffHandler(gormDB))
|
||||
api.Post("/auth/forgot-password", handlers.ForgotPasswordHandler(gormDB, mailer))
|
||||
api.Post("/auth/reset-password", handlers.ResetPasswordHandler(gormDB))
|
||||
|
||||
// Classes endpoints
|
||||
api.Get("/classes", handlers.ListClassesHandler(gormDB))
|
||||
api.Get("/classes/schedules", handlers.ListActiveSchedulesHandler(gormDB))
|
||||
api.Get("/classes/:rkId", handlers.GetClassHandler(gormDB))
|
||||
api.Patch("/classes/:rkId/studying", handlers.PatchClassStudyingHandler(gormDB))
|
||||
api.Get("/classes/:rkId/students", handlers.ListClassStudentsHandler(gormDB))
|
||||
|
||||
// Students endpoints
|
||||
api.Get("/students", handlers.ListAllStudentsHandler(gormDB))
|
||||
|
||||
// Sync endpoints
|
||||
api.Post("/sync/classes/start", handlers.StartClassesSyncHandler(gormDB, qldtClient, classesJob, qldtToken))
|
||||
api.Get("/sync/classes/status", handlers.GetClassesSyncStatusHandler(classesJob))
|
||||
api.Post("/sync/students/start", handlers.StartStudentsSyncHandler(gormDB, qldtClient, studentsJob, qldtToken))
|
||||
api.Get("/sync/students/status", handlers.GetStudentsSyncStatusHandler(studentsJob))
|
||||
|
||||
// Learning Management endpoints
|
||||
api.Get("/classes/:rkId/schedule", handlers.GetClassScheduleHandler(gormDB))
|
||||
api.Post("/classes/:rkId/schedule", handlers.SaveClassScheduleHandler(gormDB))
|
||||
api.Delete("/classes/:rkId/schedule", handlers.DeleteClassScheduleHandler(gormDB))
|
||||
api.Get("/classes/:rkId/allowed-apps", handlers.GetAllowedAppsHandler(gormDB))
|
||||
api.Post("/classes/:rkId/allowed-apps", handlers.SaveAllowedAppsHandler(gormDB))
|
||||
api.Get("/app-pool", handlers.ListAppPoolHandler(gormDB))
|
||||
|
||||
api.Get("/wifi-pool", handlers.ListWifiPoolHandler(gormDB))
|
||||
api.Get("/network/accepted-wifis", handlers.ListAcceptedWifisHandler(gormDB))
|
||||
api.Post("/network/accepted-wifis", handlers.SaveAcceptedWifisHandler(gormDB))
|
||||
api.Post("/network/accepted-wifis/add", handlers.AddAcceptedWifiHandler(gormDB))
|
||||
api.Delete("/network/accepted-wifis/:id", handlers.DeleteAcceptedWifiHandler(gormDB))
|
||||
api.Get("/classes/:rkId/session-logs", handlers.ListClassSessionLogsHandler(gormDB))
|
||||
api.Get("/classes/:rkId/online-students", handlers.GetOnlineStudentsHandler(gormDB))
|
||||
|
||||
api.Get("/classes/:rkId/courses", handlers.GetClassCoursesHandler(gormDB, qldtClient, qldtToken))
|
||||
api.Post("/classes/:rkId/schedule/apply-template", handlers.ApplyScheduleTemplateHandler(gormDB))
|
||||
api.Get("/classes/:rkId/attendance", handlers.GetClassAttendanceHandler(gormDB))
|
||||
api.Get("/classes/:rkId/attendance/shifts", handlers.ListAttendanceShiftsHandler(gormDB))
|
||||
api.Put("/classes/:rkId/attendance/status", handlers.UpdateAttendanceStatusHandler(gormDB))
|
||||
api.Post("/classes/:rkId/attendance/push-qldt", handlers.PushAttendanceToQLDTHandler(gormDB, qldtClient, qldtToken))
|
||||
|
||||
// Student syncing offline-resilient log
|
||||
// Student client (không cần JWT staff)
|
||||
api.Post("/student/sync-log", handlers.SyncStudentSessionLogHandler(gormDB))
|
||||
api.Get("/student/status", handlers.GetStudentStatusHandler(gormDB))
|
||||
api.Get("/student/wifi-policy", handlers.GetStudentWifiPolicyHandler(gormDB))
|
||||
api.Post("/student/report-wifi", handlers.ReportWifiHandler(gormDB))
|
||||
api.Post("/student/report-blocked-app", handlers.ReportBlockedAppHandler(gormDB))
|
||||
api.Get("/student/chat/messages", handlers.StudentListChatHandler(gormDB))
|
||||
api.Get("/student/chat/conversations", handlers.StudentListChatConversationsHandler(gormDB))
|
||||
api.Post("/student/chat/messages", handlers.StudentSendChatHandler(gormDB))
|
||||
|
||||
// Health check (public)
|
||||
api.Get("/health", func(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
})
|
||||
|
||||
// Management — yêu cầu đăng nhập staff
|
||||
staff := api.Group("", middleware.RequireStaff())
|
||||
staff.Get("/auth/me", handlers.MeStaffHandler(gormDB))
|
||||
staff.Post("/auth/change-password", handlers.ChangePasswordHandler(gormDB))
|
||||
|
||||
staff.Get("/stats", handlers.GetStatsHandler(gormDB))
|
||||
|
||||
// Classes endpoints
|
||||
staff.Get("/classes", handlers.ListClassesHandler(gormDB))
|
||||
staff.Get("/classes/schedules", handlers.ListActiveSchedulesHandler(gormDB))
|
||||
staff.Get("/classes/:rkId", handlers.GetClassHandler(gormDB))
|
||||
staff.Patch("/classes/:rkId/studying", handlers.PatchClassStudyingHandler(gormDB))
|
||||
staff.Get("/classes/:rkId/students", handlers.ListClassStudentsHandler(gormDB))
|
||||
|
||||
// Students endpoints
|
||||
staff.Get("/students", handlers.ListAllStudentsHandler(gormDB))
|
||||
|
||||
// Sync endpoints
|
||||
staff.Post("/sync/classes/start", handlers.StartClassesSyncHandler(gormDB, qldtClient, classesJob, qldtToken))
|
||||
staff.Get("/sync/classes/status", handlers.GetClassesSyncStatusHandler(classesJob))
|
||||
staff.Post("/sync/students/start", handlers.StartStudentsSyncHandler(gormDB, qldtClient, studentsJob, qldtToken))
|
||||
staff.Get("/sync/students/status", handlers.GetStudentsSyncStatusHandler(studentsJob))
|
||||
|
||||
// Learning Management endpoints
|
||||
staff.Get("/classes/:rkId/schedule", handlers.GetClassScheduleHandler(gormDB))
|
||||
staff.Post("/classes/:rkId/schedule", handlers.SaveClassScheduleHandler(gormDB))
|
||||
staff.Delete("/classes/:rkId/schedule", handlers.DeleteClassScheduleHandler(gormDB))
|
||||
staff.Get("/classes/:rkId/allowed-apps", handlers.GetAllowedAppsHandler(gormDB))
|
||||
staff.Post("/classes/:rkId/allowed-apps", handlers.SaveAllowedAppsHandler(gormDB))
|
||||
staff.Get("/app-pool", handlers.ListAppPoolHandler(gormDB))
|
||||
|
||||
staff.Get("/wifi-pool", handlers.ListWifiPoolHandler(gormDB))
|
||||
staff.Get("/network/accepted-wifis", handlers.ListAcceptedWifisHandler(gormDB))
|
||||
staff.Post("/network/accepted-wifis", handlers.SaveAcceptedWifisHandler(gormDB))
|
||||
staff.Post("/network/accepted-wifis/add", handlers.AddAcceptedWifiHandler(gormDB))
|
||||
staff.Delete("/network/accepted-wifis/:id", handlers.DeleteAcceptedWifiHandler(gormDB))
|
||||
staff.Get("/classes/:rkId/session-logs", handlers.ListClassSessionLogsHandler(gormDB))
|
||||
staff.Get("/classes/:rkId/online-students", handlers.GetOnlineStudentsHandler(gormDB))
|
||||
|
||||
staff.Get("/classes/:rkId/courses", handlers.GetClassCoursesHandler(gormDB, qldtClient, qldtToken))
|
||||
staff.Post("/classes/:rkId/schedule/apply-template", handlers.ApplyScheduleTemplateHandler(gormDB))
|
||||
staff.Get("/classes/:rkId/attendance", handlers.GetClassAttendanceHandler(gormDB))
|
||||
staff.Get("/classes/:rkId/attendance/shifts", handlers.ListAttendanceShiftsHandler(gormDB))
|
||||
staff.Put("/classes/:rkId/attendance/status", handlers.UpdateAttendanceStatusHandler(gormDB))
|
||||
staff.Post("/classes/:rkId/attendance/push-qldt", handlers.PushAttendanceToQLDTHandler(gormDB, qldtClient, qldtToken))
|
||||
|
||||
// Chat & quản lý tài khoản
|
||||
staff.Get("/chat/students", handlers.SearchChatStudentsHandler(gormDB))
|
||||
staff.Get("/chat/conversations", handlers.ListChatConversationsHandler(gormDB))
|
||||
staff.Get("/chat/messages/:studentRkId", handlers.ListChatMessagesHandler(gormDB))
|
||||
staff.Post("/chat/messages", handlers.SendChatMessageHandler(gormDB))
|
||||
staff.Get("/admin/email-domains", handlers.ListEmailDomainsHandler(gormDB))
|
||||
staff.Post("/admin/email-domains", handlers.AddEmailDomainHandler(gormDB))
|
||||
staff.Delete("/admin/email-domains/:id", handlers.DeleteEmailDomainHandler(gormDB))
|
||||
staff.Get("/admin/staff", handlers.ListStaffHandler(gormDB))
|
||||
|
||||
port := getEnv("PORT", "8080")
|
||||
log.Printf("Server starting on port %s...", port)
|
||||
|
||||
Reference in New Issue
Block a user