From d9c8e3930b5f897b28f34fb3f6077226f4f490e4 Mon Sep 17 00:00:00 2001 From: PhuocNTB Date: Tue, 30 Jun 2026 10:39:29 +0700 Subject: [PATCH] tam chat --- client/app.go | 181 +++++++- client/frontend/src/app.css | 302 +++++++++++-- client/frontend/src/main.js | 203 ++++++++- client/frontend/wailsjs/go/main/App.d.ts | 10 + client/frontend/wailsjs/go/main/App.js | 20 + client/internal/blocker/blocker.go | 64 ++- management/src/App.tsx | 32 +- management/src/api.ts | 222 ++++++++-- management/src/auth/AuthContext.tsx | 114 +++++ management/src/components/AccountsTab.tsx | 167 ++++++++ management/src/components/ChatWidget.tsx | 231 ++++++++++ management/src/components/ClassWorkspace.tsx | 19 +- management/src/components/LoginPage.tsx | 114 +++++ management/src/index.css | 424 +++++++++++++++++++ management/src/main.tsx | 33 +- management/src/navigation.ts | 5 +- server/.env | 10 +- server/go.mod | 6 +- server/go.sum | 8 + server/internal/auth/auth.go | 105 +++++ server/internal/db/db.go | 4 + server/internal/handlers/handlers_auth.go | 289 +++++++++++++ server/internal/handlers/handlers_chat.go | 282 ++++++++++++ server/internal/mail/mail.go | 91 ++++ server/internal/middleware/staff.go | 33 ++ server/internal/models/staff.go | 52 +++ server/internal/websocket/websocket.go | 71 +++- server/main.go | 115 +++-- 28 files changed, 3040 insertions(+), 167 deletions(-) create mode 100644 management/src/auth/AuthContext.tsx create mode 100644 management/src/components/AccountsTab.tsx create mode 100644 management/src/components/ChatWidget.tsx create mode 100644 management/src/components/LoginPage.tsx create mode 100644 server/internal/auth/auth.go create mode 100644 server/internal/handlers/handlers_auth.go create mode 100644 server/internal/handlers/handlers_chat.go create mode 100644 server/internal/mail/mail.go create mode 100644 server/internal/middleware/staff.go create mode 100644 server/internal/models/staff.go diff --git a/client/app.go b/client/app.go index 49e6d82..79471f5 100644 --- a/client/app.go +++ b/client/app.go @@ -104,6 +104,8 @@ type App struct { wifiRejected bool quitDialogShown bool monitoringTornDown bool + chatUnread int + replyStaffID uint } type LocalStats struct { @@ -274,10 +276,7 @@ func (a *App) startLocalServer() { blocker.Instance.Start() // Kiểm tra điều kiện app được phép và giờ học trước khi tải giao diện - go a.fetchAllowedApps(st.SystemID) - go a.fetchStudentStatus(st.StudentID) - go a.fetchWifiPolicy() - go a.refreshNetworkStatus() + go a.refreshStudentData() // Chuyển hướng WebView về trang dashboard của app bằng cách reload app assets runtime.WindowReloadApp(a.ctx) @@ -315,10 +314,8 @@ func (a *App) loadSession() { log.Printf("[APP] Loaded local session: %s (%s)", s.FullName, s.StudentCode) blocker.Instance.Start() - go a.fetchAllowedApps(s.SystemID) - go a.fetchStudentStatus(s.StudentID) + go a.refreshStudentData() go a.fetchWifiPolicy() - go a.refreshNetworkStatus() go a.syncProfileToServer(&s) } } @@ -589,6 +586,33 @@ func (a *App) runMonitorTick() { } } +// refreshNetworkStatus — đọc WiFi + ping server ngay (không cộng thời gian online/offline). +func (a *App) resolveClassRkID() int64 { + a.mu.Lock() + defer a.mu.Unlock() + if a.dashboard.ClassRkID > 0 { + return a.dashboard.ClassRkID + } + return 0 +} + +func (a *App) refreshStudentData() { + a.mu.Lock() + student := a.student + a.mu.Unlock() + if student == nil { + return + } + a.fetchStudentStatus(student.StudentID) + classID := a.resolveClassRkID() + if classID > 0 { + a.fetchAllowedApps(classID) + } else { + a.fetchAllowedApps(0) + } + a.refreshNetworkStatus() +} + // refreshNetworkStatus — đọc WiFi + ping server ngay (không cộng thời gian online/offline). func (a *App) refreshNetworkStatus() { if !a.isMonitoringActive() || !a.CheckLoginStatus() { @@ -601,11 +625,14 @@ func (a *App) refreshNetworkStatus() { a.wifiBSSID = wifi.BSSID a.backendOnline = backendOnline a.mu.Unlock() + if backendOnline { + a.connectWS() + } } func (a *App) pingBackend() bool { client := http.Client{Timeout: 3 * time.Second} - resp, err := client.Get("http://127.0.0.1:8080/api/stats") + resp, err := client.Get("http://127.0.0.1:8080/api/health") if err != nil { return false } @@ -628,12 +655,18 @@ func (a *App) syncLogsToServer() { return } - go a.fetchStudentStatus(student.StudentID) - go a.fetchAllowedApps(student.SystemID) + go func() { + a.fetchStudentStatus(student.StudentID) + classID := a.resolveClassRkID() + if classID <= 0 { + classID = student.SystemID + } + a.fetchAllowedApps(classID) + }() payload := map[string]any{ "studentRkId": student.StudentID, - "classRkId": student.SystemID, + "classRkId": a.resolveClassRkID(), "sessionDate": time.Now().Format("2006-01-02"), "addOnlineSeconds": unsyncedOn, "addOfflineSeconds": unsyncedOff, @@ -809,8 +842,9 @@ func (a *App) fetchAllowedApps(classId int64) { } a.mu.Unlock() + url := fmt.Sprintf("http://127.0.0.1:8080/api/classes/%d/allowed-apps?studentId=%d", classId, studentID) client := http.Client{Timeout: 4 * time.Second} - resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:8080/api/classes/%d/allowed-apps?studentId=%d", classId, studentID)) + resp, err := client.Get(url) if err != nil { return } @@ -888,7 +922,7 @@ func (a *App) fetchStudentStatus(studentID int64) { } func (a *App) connectWS() { - if !a.isMonitoringActive() { + if !a.CheckLoginStatus() { return } a.mu.Lock() @@ -897,9 +931,13 @@ func (a *App) connectWS() { return } student := a.student + classID := a.dashboard.ClassRkID + if classID <= 0 { + classID = student.SystemID + } a.mu.Unlock() - wsUrl := fmt.Sprintf("ws://127.0.0.1:8080/ws?role=student&studentId=%d&classId=%d", student.StudentID, student.SystemID) + wsUrl := fmt.Sprintf("ws://127.0.0.1:8080/ws?role=student&studentId=%d&classId=%d", student.StudentID, classID) dialer := websocket.Dialer{HandshakeTimeout: 4 * time.Second} conn, _, err := dialer.Dial(wsUrl, nil) if err != nil { @@ -934,6 +972,20 @@ func (a *App) connectWS() { runtime.EventsEmit(a.ctx, "start_webcam_stream") case "stop_webcam_stream": runtime.EventsEmit(a.ctx, "stop_webcam_stream") + case "chat:message": + if staffVal, ok := msg.Data["staffId"].(float64); ok { + a.mu.Lock() + a.replyStaffID = uint(staffVal) + a.chatUnread++ + unread := a.chatUnread + a.mu.Unlock() + runtime.EventsEmit(a.ctx, "chat:notify", map[string]any{ + "unread": unread, + "preview": msg.Data["body"], + "from": msg.Data["staffName"], + }) + } + runtime.EventsEmit(a.ctx, "chat:message", msg.Data) } } }() @@ -1003,3 +1055,104 @@ func (a *App) stopScreenshotStream() { close(a.streamScStop) log.Println("[WS] Screenshot screen-streaming stopped.") } + +func (a *App) GetChatUnread() int { + a.mu.Lock() + defer a.mu.Unlock() + return a.chatUnread +} + +func (a *App) ClearChatUnread() { + a.mu.Lock() + a.chatUnread = 0 + a.mu.Unlock() +} + +func (a *App) GetChatConversations() ([]map[string]any, error) { + a.mu.Lock() + student := a.student + a.mu.Unlock() + if student == nil { + return nil, errors.New("chưa đăng nhập") + } + url := fmt.Sprintf("http://127.0.0.1:8080/api/student/chat/conversations?studentRkId=%d", student.StudentID) + resp, err := http.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + var payload struct { + Data []map[string]any `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, err + } + return payload.Data, nil +} + +func (a *App) GetChatMessages(staffID uint) ([]map[string]any, error) { + a.mu.Lock() + student := a.student + a.mu.Unlock() + if student == nil { + return nil, errors.New("chưa đăng nhập") + } + url := fmt.Sprintf("http://127.0.0.1:8080/api/student/chat/messages?studentRkId=%d&staffId=%d", student.StudentID, staffID) + resp, err := http.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + var payload struct { + Data []map[string]any `json:"data"` + ReplyStaffID uint `json:"replyStaffId"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, err + } + if staffID > 0 { + a.mu.Lock() + a.replyStaffID = staffID + a.mu.Unlock() + } else if payload.ReplyStaffID > 0 { + a.mu.Lock() + a.replyStaffID = payload.ReplyStaffID + a.mu.Unlock() + } + a.ClearChatUnread() + return payload.Data, nil +} + +func (a *App) SendChatMessage(body string) error { + body = strings.TrimSpace(body) + if body == "" { + return errors.New("tin nhắn trống") + } + a.mu.Lock() + student := a.student + staffID := a.replyStaffID + a.mu.Unlock() + if student == nil { + return errors.New("chưa đăng nhập") + } + payload := map[string]any{ + "studentRkId": student.StudentID, + "staffId": staffID, + "body": body, + } + b, _ := json.Marshal(payload) + resp, err := http.Post("http://127.0.0.1:8080/api/student/chat/messages", "application/json", bytes.NewReader(b)) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + var errBody map[string]any + _ = json.NewDecoder(resp.Body).Decode(&errBody) + if msg, ok := errBody["error"].(string); ok { + return errors.New(msg) + } + return fmt.Errorf("gửi tin thất bại (%d)", resp.StatusCode) + } + return nil +} diff --git a/client/frontend/src/app.css b/client/frontend/src/app.css index 40cf21d..1e30cf4 100644 --- a/client/frontend/src/app.css +++ b/client/frontend/src/app.css @@ -437,45 +437,291 @@ body { font-family: monospace; } -.clock-display-summary { - display: flex; - flex-wrap: wrap; - gap: 0.45rem; - margin-bottom: 0.45rem; - padding-bottom: 0.45rem; - border-bottom: 1px dashed var(--border-color); - flex: 0 0 auto; +.card-title-row--shifts { + margin-bottom: 0.2rem; + flex-wrap: nowrap; + gap: 0.35rem 0.5rem; + align-items: center; } -.clock-chip { +.clock-inline { display: inline-flex; align-items: center; gap: 0.35rem; - padding: 0.2rem 0.5rem; - border-radius: 6px; - border: 1px solid var(--border-color); - background: var(--bg-subtle); - font-size: 0.72rem; -} - -.clock-chip em { - font-style: normal; + flex-wrap: wrap; + margin-left: auto; + font-size: 0.68rem; color: var(--text-muted); - font-weight: 600; } -.clock-chip strong { +.clock-inline-item { + display: inline-flex; + align-items: center; + gap: 0.2rem; +} + +.clock-inline-item strong { font-family: 'Share Tech Mono', monospace; - font-size: 0.78rem; + font-size: 0.7rem; font-weight: 700; } -.clock-chip--online strong { - color: var(--online-color); +.clock-inline-item strong.online { color: var(--online-color); } +.clock-inline-item strong.offline { color: var(--offline-color); } + +.clock-inline-dot { + width: 5px; + height: 5px; + border-radius: 50%; + flex-shrink: 0; } -.clock-chip--offline strong { - color: var(--offline-color); +.clock-inline-dot.online { background: var(--online-color); } +.clock-inline-dot.offline { background: var(--offline-color); } + +.clock-inline-sep { + opacity: 0.35; + font-size: 0.65rem; +} + +.card-title-row--shifts .card-title-sub { + font-size: 0.62rem; + margin-left: 0.35rem; + white-space: nowrap; +} + +/* Student chat messenger */ +.student-chat-dock { + position: fixed; + right: 12px; + bottom: 12px; + z-index: 1000; +} + +.student-chat-toast { + position: absolute; + right: 0; + bottom: 64px; + width: 260px; + padding: 0.65rem 0.75rem; + border-radius: 10px; + background: #1e293b; + color: #fff; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28); + font-size: 0.78rem; + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.student-chat-fab { + position: relative; + width: 48px; + height: 48px; + border-radius: 50%; + border: none; + background: var(--accent); + color: #fff; + font-size: 1.15rem; + cursor: pointer; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.25); +} + +.student-chat-badge { + position: absolute; + top: -2px; + right: -2px; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 8px; + background: #ef4444; + color: #fff; + font-size: 0.62rem; + font-weight: 700; + line-height: 16px; +} + +.student-chat-messenger { + position: absolute; + right: 0; + bottom: 58px; + width: min(520px, calc(100vw - 24px)); + height: min(400px, calc(100vh - 80px)); + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 12px; + box-shadow: 0 12px 36px rgba(0, 0, 0, 0.3); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.student-chat-messenger-head { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.55rem 0.7rem; + border-bottom: 1px solid var(--border-color); + font-size: 0.82rem; + flex-shrink: 0; +} + +.student-chat-close { + border: none; + background: none; + color: var(--text-muted); + font-size: 1.1rem; + cursor: pointer; +} + +.student-chat-messenger-body { + display: flex; + flex: 1; + min-height: 0; +} + +.student-chat-sidebar { + width: 150px; + flex-shrink: 0; + border-right: 1px solid var(--border-color); + overflow-y: auto; + background: rgba(255, 255, 255, 0.02); + display: flex; + flex-direction: column; +} + +.student-chat-conv-btn { + width: 100%; + text-align: left; + border: none; + background: transparent; + padding: 0.55rem 0.5rem; + cursor: pointer; + border-bottom: 1px solid var(--border-color); + color: var(--text-primary); +} + +.student-chat-conv-btn.active { + background: rgba(187, 33, 38, 0.12); + border-left: 2px solid var(--accent); +} + +.student-chat-conv-name { + display: flex; + justify-content: space-between; + font-size: 0.72rem; + font-weight: 600; +} + +.student-chat-conv-unread { + font-style: normal; + background: var(--accent); + color: #fff; + border-radius: 8px; + padding: 0 5px; + font-size: 0.6rem; +} + +.student-chat-conv-preview { + display: block; + font-size: 0.65rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 0.15rem; +} + +.student-chat-thread { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.student-chat-thread-head { + padding: 0.45rem 0.65rem; + border-bottom: 1px solid var(--border-color); + font-size: 0.75rem; + font-weight: 600; + flex-shrink: 0; +} + +.student-chat-messages { + flex: 1; + overflow-y: auto; + padding: 0.55rem; + display: flex; + flex-direction: column; + gap: 0.4rem; + background: rgba(0, 0, 0, 0.12); +} + +.student-chat-empty { + font-size: 0.72rem; + color: var(--text-muted); + text-align: center; + padding: 1rem 0.5rem; +} + +.student-chat-bubble { + max-width: 88%; + font-size: 0.76rem; +} + +.student-chat-bubble--staff { align-self: flex-start; } +.student-chat-bubble--student { align-self: flex-end; } + +.student-chat-label { + font-size: 0.6rem; + color: var(--text-muted); + margin-bottom: 0.1rem; +} + +.student-chat-body { + padding: 0.4rem 0.55rem; + border-radius: 10px; + line-height: 1.35; + word-break: break-word; +} + +.student-chat-bubble--staff .student-chat-body { + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--border-color); +} + +.student-chat-bubble--student .student-chat-body { + background: var(--accent); + color: #fff; +} + +.student-chat-time { + font-size: 0.58rem; + color: var(--text-muted); + margin-top: 0.1rem; +} + +.student-chat-compose { + display: flex; + gap: 0.35rem; + padding: 0.45rem 0.55rem; + border-top: 1px solid var(--border-color); + flex-shrink: 0; +} + +.student-chat-compose input { + flex: 1; + font-size: 0.74rem; + padding: 0.4rem 0.5rem; + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-base); + color: var(--text-primary); +} + +.student-chat-compose input:disabled { + opacity: 0.5; } .shifts-wrap { @@ -622,15 +868,15 @@ body { .clocks-card { display: flex; flex-direction: column; - gap: 0.4rem; + gap: 0.3rem; flex: 1; min-height: 0; overflow: hidden; - padding: 0.75rem 0.85rem; + padding: 0.5rem 0.65rem; } .card-title { - font-size: 0.85rem; + font-size: 0.78rem; font-weight: 700; color: var(--text-primary); letter-spacing: -0.2px; diff --git a/client/frontend/src/main.js b/client/frontend/src/main.js index f77ead9..5cd42f7 100644 --- a/client/frontend/src/main.js +++ b/client/frontend/src/main.js @@ -23,6 +23,11 @@ let stats = { shifts: [] }; +let chatOpen = false; +let chatMessages = []; +let chatConversations = []; +let activeStaffId = 0; + // Quản lý webcam let webcamStream = null; let webcamInterval = null; @@ -46,6 +51,15 @@ function init() { window.runtime.EventsOn('start_webcam_stream', startWebcam); window.runtime.EventsOn('stop_webcam_stream', stopWebcam); + window.runtime.EventsOn('chat:message', () => { + updateChatBadge(); + loadChatConversations().catch(console.error); + if (chatOpen && activeStaffId) loadChatMessages(activeStaffId).catch(console.error); + }); + window.runtime.EventsOn('chat:notify', (data) => { + updateChatBadge(); + showChatToast(data); + }); checkLogin(); } @@ -57,6 +71,8 @@ async function checkLogin() { studentInfo = await window.go.main.App.GetStudentInfo(); renderDashboard(); startStatsTicker(); + ensureChatWidget(); + updateChatBadge(); } else { loggedIn = false; renderLoginPrompt(); @@ -226,19 +242,14 @@ function renderDashboard() {
-
+
Hôm nay — theo ca
-
${stats.sessionDate || ''}
-
-
- - Trực tuyến - 00:00:00 - - - Ngoại tuyến - 00:00:00 - +
+ 00:00:00 + · + 00:00:00 + ${stats.sessionDate || ''} +
${renderShiftsTable(stats.shifts)} @@ -254,11 +265,177 @@ function renderDashboard() { loggedIn = false; studentInfo = null; stopWebcam(); + const w = document.getElementById('student-chat-widget'); + if (w) w.remove(); await window.go.main.App.Logout(); } }); } +function ensureChatWidget() { + if (document.getElementById('student-chat-widget')) return; + const wrap = document.createElement('div'); + wrap.id = 'student-chat-widget'; + wrap.className = 'student-chat-dock'; + wrap.innerHTML = ` + + + + `; + document.body.appendChild(wrap); + document.getElementById('student-chat-fab').addEventListener('click', toggleChat); + document.getElementById('student-chat-close').addEventListener('click', () => setChatOpen(false)); + document.getElementById('student-chat-send').addEventListener('click', sendStudentChat); + document.getElementById('student-chat-input').addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); sendStudentChat(); } + }); +} + +function showChatToast(data) { + const toast = document.getElementById('student-chat-toast'); + if (!toast) return; + const from = data?.from || 'Giảng viên'; + const preview = data?.preview || 'Bạn có tin nhắn mới'; + toast.innerHTML = `${escapeHtml(from)}${escapeHtml(preview)}`; + toast.hidden = false; + clearTimeout(showChatToast._t); + showChatToast._t = setTimeout(() => { toast.hidden = true; }, 5000); +} + +function setChatOpen(open) { + chatOpen = open; + const panel = document.getElementById('student-chat-messenger'); + if (panel) panel.hidden = !open; + if (open) { + loadChatConversations().catch(console.error); + if (activeStaffId) loadChatMessages(activeStaffId).catch(console.error); + } +} + +function toggleChat() { + setChatOpen(!chatOpen); +} + +async function loadChatConversations() { + const rows = await window.go.main.App.GetChatConversations(); + chatConversations = Array.isArray(rows) ? rows : []; + renderChatConversations(); + updateChatBadge(); +} + +function renderChatConversations() { + const list = document.getElementById('student-chat-conv-list'); + if (!list) return; + if (!chatConversations.length) { + list.innerHTML = '
Chưa có tin nhắn
'; + return; + } + list.innerHTML = chatConversations.map((c) => { + const sid = Number(c.staffId || 0); + const unread = Number(c.unread || 0); + const active = sid === activeStaffId ? ' active' : ''; + const name = escapeHtml(c.staffName || c.staffEmail || 'Giảng viên'); + const preview = escapeHtml(c.lastMessage || ''); + return ``; + }).join(''); + list.querySelectorAll('.student-chat-conv-btn').forEach((btn) => { + btn.addEventListener('click', () => { + const sid = Number(btn.getAttribute('data-staff-id')); + pickStaffChat(sid); + }); + }); +} + +async function pickStaffChat(staffId) { + activeStaffId = staffId; + const head = document.getElementById('student-chat-thread-head'); + const conv = chatConversations.find((c) => Number(c.staffId) === staffId); + if (head) head.textContent = conv?.staffName || conv?.staffEmail || 'Giảng viên'; + const input = document.getElementById('student-chat-input'); + const sendBtn = document.getElementById('student-chat-send'); + if (input) input.disabled = !staffId; + if (sendBtn) sendBtn.disabled = !staffId; + renderChatConversations(); + if (staffId) await loadChatMessages(staffId); +} + +async function loadChatMessages(staffId) { + const msgs = await window.go.main.App.GetChatMessages(staffId); + chatMessages = Array.isArray(msgs) ? msgs : []; + renderChatMessages(); + updateChatBadge(); + loadChatConversations().catch(console.error); +} + +function renderChatMessages() { + const box = document.getElementById('student-chat-messages'); + if (!box) return; + if (!chatMessages.length) { + box.innerHTML = '
Chưa có tin nhắn
'; + return; + } + box.innerHTML = chatMessages.map((m) => { + const role = m.senderRole === 'student' ? 'student' : 'staff'; + const time = m.createdAt ? new Date(m.createdAt).toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit' }) : ''; + const label = role === 'staff' ? (m.staffName || 'Giảng viên') : 'Bạn'; + return `
${label}
${escapeHtml(m.body || '')}
${time}
`; + }).join(''); + box.scrollTop = box.scrollHeight; +} + +function escapeHtml(s) { + return String(s).replace(/&/g, '&').replace(//g, '>'); +} + +async function sendStudentChat() { + const input = document.getElementById('student-chat-input'); + if (!input || !input.value.trim() || !activeStaffId) return; + try { + await window.go.main.App.SendChatMessage(input.value.trim()); + input.value = ''; + await loadChatMessages(activeStaffId); + } catch (err) { + alert(err?.message || err || 'Gửi tin thất bại'); + } +} + +async function updateChatBadge() { + const badge = document.getElementById('student-chat-badge'); + if (!badge || !window.go?.main?.App?.GetChatUnread) return; + try { + const n = await window.go.main.App.GetChatUnread(); + if (n > 0) { + badge.hidden = false; + badge.textContent = n > 9 ? '9+' : String(n); + } else { + badge.hidden = true; + } + } catch { + /* ignore */ + } +} + function formatDuration(totalSeconds) { const hrs = Math.floor(totalSeconds / 3600); const mins = Math.floor((totalSeconds % 3600) / 60); @@ -330,6 +507,8 @@ function startStatsTicker() { if (indicator) { indicator.className = `status-indicator ${stats.serverReachable ? 'online' : 'offline'}`; } + + updateChatBadge(); } catch (err) { console.error('Error fetching stats:', err); } diff --git a/client/frontend/wailsjs/go/main/App.d.ts b/client/frontend/wailsjs/go/main/App.d.ts index f5b9cba..da304b0 100644 --- a/client/frontend/wailsjs/go/main/App.d.ts +++ b/client/frontend/wailsjs/go/main/App.d.ts @@ -3,6 +3,14 @@ export function CheckLoginStatus():Promise; +export function ClearChatUnread():Promise; + +export function GetChatConversations():Promise>>; + +export function GetChatMessages(arg1:number):Promise>>; + +export function GetChatUnread():Promise; + export function GetStats():Promise>; export function GetStudentInfo():Promise>; @@ -11,4 +19,6 @@ export function Logout():Promise; export function NavigateToLogin():Promise; +export function SendChatMessage(arg1:string):Promise; + export function SendWebcamFrame(arg1:string):Promise; diff --git a/client/frontend/wailsjs/go/main/App.js b/client/frontend/wailsjs/go/main/App.js index 0f9cd8f..a576798 100644 --- a/client/frontend/wailsjs/go/main/App.js +++ b/client/frontend/wailsjs/go/main/App.js @@ -6,6 +6,22 @@ export function CheckLoginStatus() { return window['go']['main']['App']['CheckLoginStatus'](); } +export function ClearChatUnread() { + return window['go']['main']['App']['ClearChatUnread'](); +} + +export function GetChatConversations() { + return window['go']['main']['App']['GetChatConversations'](); +} + +export function GetChatMessages(arg1) { + return window['go']['main']['App']['GetChatMessages'](arg1); +} + +export function GetChatUnread() { + return window['go']['main']['App']['GetChatUnread'](); +} + export function GetStats() { return window['go']['main']['App']['GetStats'](); } @@ -22,6 +38,10 @@ export function NavigateToLogin() { return window['go']['main']['App']['NavigateToLogin'](); } +export function SendChatMessage(arg1) { + return window['go']['main']['App']['SendChatMessage'](arg1); +} + export function SendWebcamFrame(arg1) { return window['go']['main']['App']['SendWebcamFrame'](arg1); } diff --git a/client/internal/blocker/blocker.go b/client/internal/blocker/blocker.go index 1b42d4b..f58aa67 100644 --- a/client/internal/blocker/blocker.go +++ b/client/internal/blocker/blocker.go @@ -182,19 +182,63 @@ func EnumerateGUIWindows() ([]WindowInfo, error) { return res, nil } +func parseKeywordList(keywords string) []string { + keywords = strings.ReplaceAll(keywords, "\r\n", "\n") + parts := strings.FieldsFunc(keywords, func(r rune) bool { + return r == ',' || r == '\n' || r == ';' || r == '|' + }) + seen := map[string]bool{} + var list []string + for _, p := range parts { + trimmed := strings.TrimSpace(strings.ToLower(p)) + trimmed = strings.TrimSuffix(trimmed, ".exe") + if trimmed == "" || seen[trimmed] { + continue + } + seen[trimmed] = true + list = append(list, trimmed) + } + return list +} + +var keywordAliases = map[string][]string{ + "lark": {"lark", "feishu", "larkshell", "larkhelper"}, + "feishu": {"lark", "feishu", "larkshell", "larkhelper"}, + "teams": {"teams", "ms-teams", "msteams"}, + "zalo": {"zalo", "zalopcb"}, +} + +func expandKeyword(kw string) []string { + base := []string{kw} + if aliases, ok := keywordAliases[kw]; ok { + base = append(base, aliases...) + } + seen := map[string]bool{} + var out []string + for _, a := range base { + a = strings.TrimSpace(strings.ToLower(a)) + if a != "" && !seen[a] { + seen[a] = true + out = append(out, a) + } + } + return out +} + +func matchesAllowedKeyword(kw, pNameLower, wTitleLower string) bool { + for _, variant := range expandKeyword(kw) { + if strings.Contains(pNameLower, variant) || strings.Contains(wTitleLower, variant) { + return true + } + } + return false +} + func (b *Blocker) SetKeywords(keywords string) { b.mu.Lock() defer b.mu.Unlock() - parts := strings.Split(keywords, ",") - var list []string - for _, p := range parts { - trimmed := strings.TrimSpace(strings.ToLower(p)) - if trimmed != "" { - list = append(list, trimmed) - } - } - b.allowedKeywords = list + b.allowedKeywords = parseKeywordList(keywords) log.Printf("[BLOCKER] Keywords updated: %v", b.allowedKeywords) } @@ -254,7 +298,7 @@ func (b *Blocker) checkAndKill() { // 2. Kiểm tra xem có chứa bất kỳ từ khóa nào được cho phép không allowed := false for _, kw := range keywords { - if strings.Contains(pNameLower, kw) || strings.Contains(wTitleLower, kw) { + if matchesAllowedKeyword(kw, pNameLower, wTitleLower) { allowed = true break } diff --git a/management/src/App.tsx b/management/src/App.tsx index e599717..487a83b 100644 --- a/management/src/App.tsx +++ b/management/src/App.tsx @@ -4,9 +4,12 @@ import { ClassesTab } from './components/ClassesTab'; import { StudentsTab } from './components/StudentsTab'; import { LearningTab } from './components/LearningTab'; import { NetworkTab } from './components/NetworkTab'; +import { AccountsTab } from './components/AccountsTab'; +import { ChatWidget } from './components/ChatWidget'; import { ClassWorkspace } from './components/ClassWorkspace'; import { NavHistoryBar, useRoute } from './components/NavHistoryBar'; import { goBack, navigate, parseRoute, pushNav, TAB_LABELS, type TabId } from './navigation'; +import { useAuth } from './auth/AuthContext'; const IconDashboard = () => ( @@ -46,8 +49,16 @@ const IconNetwork = () => ( ); +const IconAccounts = () => ( + + + + +); + function App() { const route = useRoute(); + const { staff, logout } = useAuth(); useEffect(() => { const initial = parseRoute(); @@ -128,14 +139,27 @@ function App() { Quản lý mạng + +
Hệ thống
+
  • + +
  • -
    Kết nối hệ thống
    -
    - QLDT_TOKEN đã kết nối +
    + {staff?.email}
    +
    @@ -157,10 +181,12 @@ function App() { {route.tab === 'students' && } {route.tab === 'learning' && } {route.tab === 'network' && } + {route.tab === 'accounts' && } )}
    + ); } diff --git a/management/src/api.ts b/management/src/api.ts index a9e32c0..282f437 100644 --- a/management/src/api.ts +++ b/management/src/api.ts @@ -1,5 +1,161 @@ const API_BASE = 'http://127.0.0.1:8080/api'; +function getToken(): string | null { + return localStorage.getItem('sc_staff_token'); +} + +async function staffFetch(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + const token = getToken(); + if (token) headers.set('Authorization', `Bearer ${token}`); + if (init.body && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + return fetch(`${API_BASE}${path}`, { ...init, headers }); +} + +async function parseError(res: Response, fallback: string): Promise { + const errData = await res.json().catch(() => ({})); + throw new Error((errData as { error?: string }).error || fallback); +} + +export interface StaffUser { + id: number; + email: string; + fullName: string; + mustChangePassword: boolean; +} + +export const apiAuth = { + provision: async (email: string): Promise<{ ok: boolean; message: string }> => { + const res = await fetch(`${API_BASE}/auth/provision`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }); + if (!res.ok) await parseError(res, 'Provision failed'); + return res.json(); + }, + login: async (email: string, password: string): Promise<{ token: string; staff: StaffUser; mustChangePassword: boolean }> => { + const res = await fetch(`${API_BASE}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + if (!res.ok) await parseError(res, 'Đăng nhập thất bại'); + return res.json(); + }, + me: async (): Promise<{ staff: StaffUser }> => { + const res = await staffFetch('/auth/me'); + if (!res.ok) await parseError(res, 'Unauthorized'); + return res.json(); + }, + changePassword: async (oldPassword: string, newPassword: string): Promise<{ ok: boolean; token: string; staff: StaffUser }> => { + const res = await staffFetch('/auth/change-password', { + method: 'POST', + body: JSON.stringify({ oldPassword, newPassword }), + }); + if (!res.ok) await parseError(res, 'Đổi mật khẩu thất bại'); + return res.json(); + }, + forgotPassword: async (email: string): Promise<{ ok: boolean; message: string }> => { + const res = await fetch(`${API_BASE}/auth/forgot-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }); + if (!res.ok) await parseError(res, 'Gửi mail thất bại'); + return res.json(); + }, + resetPassword: async (token: string, newPassword: string): Promise<{ ok: boolean; message: string }> => { + const res = await fetch(`${API_BASE}/auth/reset-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, newPassword }), + }); + if (!res.ok) await parseError(res, 'Đặt lại mật khẩu thất bại'); + return res.json(); + }, +}; + +export interface EmailDomainItem { + id: number; + domain: string; + isActive: boolean; +} + +export const apiAdmin = { + listEmailDomains: async (): Promise<{ data: EmailDomainItem[] }> => { + const res = await staffFetch('/admin/email-domains'); + if (!res.ok) await parseError(res, 'Failed'); + return res.json(); + }, + addEmailDomain: async (domain: string) => { + const res = await staffFetch('/admin/email-domains', { method: 'POST', body: JSON.stringify({ domain }) }); + if (!res.ok) await parseError(res, 'Failed'); + return res.json(); + }, + deleteEmailDomain: async (id: number) => { + const res = await staffFetch(`/admin/email-domains/${id}`, { method: 'DELETE' }); + if (!res.ok) await parseError(res, 'Failed'); + return res.json(); + }, +}; + +export interface ChatStudent { + studentRkId: number; + fullName: string; + studentCode: string; + email: string; + online: boolean; +} + +export interface ChatMessage { + id: number; + staffId: number; + studentRkId: number; + senderRole: 'staff' | 'student'; + body: string; + createdAt: string; + staffName?: string; +} + +export interface ChatConversation { + studentRkId: number; + fullName: string; + studentCode: string; + lastMessage: string; + lastAt: string; + unread: number; +} + +export const apiChat = { + listConversations: async (): Promise<{ data: ChatConversation[] }> => { + const res = await staffFetch('/chat/conversations'); + if (!res.ok) await parseError(res, 'Failed'); + return res.json(); + }, + searchStudents: async (q = ''): Promise<{ data: ChatStudent[] }> => { + const params = q ? `?q=${encodeURIComponent(q)}` : ''; + const res = await staffFetch(`/chat/students${params}`); + if (!res.ok) await parseError(res, 'Failed'); + return res.json(); + }, + listMessages: async (studentRkId: number): Promise<{ data: ChatMessage[] }> => { + const res = await staffFetch(`/chat/messages/${studentRkId}`); + if (!res.ok) await parseError(res, 'Failed'); + return res.json(); + }, + sendMessage: async (studentRkId: number, body: string): Promise<{ ok: boolean; data: ChatMessage }> => { + const res = await staffFetch('/chat/messages', { + method: 'POST', + body: JSON.stringify({ studentRkId, body }), + }); + if (!res.ok) await parseError(res, 'Failed'); + return res.json(); + }, +}; + export interface ClassItem { rkId: number; name: string; @@ -125,7 +281,7 @@ export interface StudentSessionLogItem { export const api = { getStats: async (): Promise => { - const res = await fetch(`${API_BASE}/stats`); + const res = await staffFetch('/stats'); if (!res.ok) throw new Error('Failed to fetch dashboard stats'); return res.json(); }, @@ -145,15 +301,14 @@ export const api = { if (params.systemRkId) query.set('systemRkId', String(params.systemRkId)); if (params.studyingOnly) query.set('studyingOnly', 'true'); - const res = await fetch(`${API_BASE}/classes?${query}`); + const res = await staffFetch(`/classes?${query}`); if (!res.ok) throw new Error('Failed to fetch classes'); return res.json(); }, updateClassStudying: async (rkId: number, isStudying: boolean): Promise => { - const res = await fetch(`${API_BASE}/classes/${rkId}/studying`, { + const res = await staffFetch(`/classes/${rkId}/studying`, { method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ isStudying }), }); if (!res.ok) { @@ -164,11 +319,17 @@ export const api = { }, getClassStudents: async (rkId: number): Promise<{ data: StudentItem[]; total: number }> => { - const res = await fetch(`${API_BASE}/classes/${rkId}/students`); + const res = await staffFetch(`/classes/${rkId}/students`); if (!res.ok) throw new Error('Failed to fetch class students roster'); return res.json(); }, + getClass: async (rkId: number): Promise => { + const res = await staffFetch(`/classes/${rkId}`); + if (!res.ok) throw new Error('Failed to fetch class'); + return res.json(); + }, + getStudents: async (params: { page: number; pageSize: number; @@ -180,13 +341,13 @@ export const api = { }); if (params.q) query.set('q', params.q); - const res = await fetch(`${API_BASE}/students?${query}`); + const res = await staffFetch(`/students?${query}`); if (!res.ok) throw new Error('Failed to fetch students'); return res.json(); }, startClassesSync: async (): Promise<{ ok: boolean; message: string }> => { - const res = await fetch(`${API_BASE}/sync/classes/start`, { method: 'POST' }); + const res = await staffFetch('/sync/classes/start', { method: 'POST' }); if (!res.ok) { const errData = await res.json().catch(() => ({})); throw new Error(errData.error || 'Failed to start classes sync'); @@ -195,13 +356,13 @@ export const api = { }, getClassesSyncStatus: async (): Promise => { - const res = await fetch(`${API_BASE}/sync/classes/status`); + const res = await staffFetch('/sync/classes/status'); if (!res.ok) throw new Error('Failed to get classes sync status'); return res.json(); }, startStudentsSync: async (): Promise<{ ok: boolean; message: string }> => { - const res = await fetch(`${API_BASE}/sync/students/start`, { method: 'POST' }); + const res = await staffFetch('/sync/students/start', { method: 'POST' }); if (!res.ok) { const errData = await res.json().catch(() => ({})); throw new Error(errData.error || 'Failed to start students sync'); @@ -210,7 +371,7 @@ export const api = { }, getStudentsSyncStatus: async (): Promise => { - const res = await fetch(`${API_BASE}/sync/students/status`); + const res = await staffFetch('/sync/students/status'); if (!res.ok) throw new Error('Failed to get students sync status'); return res.json(); }, @@ -218,21 +379,20 @@ export const api = { // Export individual learning functions to simplify imports in components export const apiFetchActiveSchedules = async (): Promise<{ data: any[] }> => { - const res = await fetch(`${API_BASE}/classes/schedules`); + const res = await staffFetch('/classes/schedules'); if (!res.ok) throw new Error('Failed to fetch active schedules'); return res.json(); }; export const apiFetchClassSchedule = async (rkId: number): Promise<{ data: ClassScheduleItem[] }> => { - const res = await fetch(`${API_BASE}/classes/${rkId}/schedule`); + const res = await staffFetch(`/classes/${rkId}/schedule`); if (!res.ok) throw new Error('Failed to fetch class schedule'); return res.json(); }; export const apiSaveClassSchedule = async (rkId: number, schedules: ClassScheduleItem[]): Promise => { - const res = await fetch(`${API_BASE}/classes/${rkId}/schedule`, { + const res = await staffFetch(`/classes/${rkId}/schedule`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ schedules }), }); if (!res.ok) throw new Error('Failed to save class schedule'); @@ -240,21 +400,20 @@ export const apiSaveClassSchedule = async (rkId: number, schedules: ClassSchedul }; export const apiDeleteClassSchedule = async (rkId: number): Promise => { - const res = await fetch(`${API_BASE}/classes/${rkId}/schedule`, { method: 'DELETE' }); + const res = await staffFetch(`/classes/${rkId}/schedule`, { method: 'DELETE' }); if (!res.ok) throw new Error('Failed to delete class schedule'); return res.json(); }; export const apiFetchAllowedApps = async (rkId: number): Promise<{ classRkId: number; keywords: string }> => { - const res = await fetch(`${API_BASE}/classes/${rkId}/allowed-apps`); + const res = await staffFetch(`/classes/${rkId}/allowed-apps`); if (!res.ok) throw new Error('Failed to fetch allowed apps keywords'); return res.json(); }; export const apiSaveAllowedApps = async (rkId: number, keywords: string): Promise => { - const res = await fetch(`${API_BASE}/classes/${rkId}/allowed-apps`, { + const res = await staffFetch(`/classes/${rkId}/allowed-apps`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keywords }), }); if (!res.ok) throw new Error('Failed to save allowed apps keywords'); @@ -277,7 +436,7 @@ export const apiFetchAppPool = async (q = '', limit = 50): Promise<{ data: AppPo if (q.trim()) params.set('q', q.trim()); params.set('limit', String(limit)); const query = params.toString() ? `?${params.toString()}` : ''; - const res = await fetch(`${API_BASE}/app-pool${query}`); + const res = await staffFetch(`/app-pool${query}`); if (!res.ok) throw new Error('Failed to fetch app pool'); return res.json(); }; @@ -305,21 +464,20 @@ export const apiFetchWifiPool = async (q = '', limit = 50): Promise<{ data: Wifi if (q.trim()) params.set('q', q.trim()); params.set('limit', String(limit)); const query = params.toString() ? `?${params.toString()}` : ''; - const res = await fetch(`${API_BASE}/wifi-pool${query}`); + const res = await staffFetch(`/wifi-pool${query}`); if (!res.ok) throw new Error('Failed to fetch wifi pool'); return res.json(); }; export const apiFetchAcceptedWifis = async (): Promise<{ data: AcceptedWifiItem[] }> => { - const res = await fetch(`${API_BASE}/network/accepted-wifis`); + const res = await staffFetch('/network/accepted-wifis'); if (!res.ok) throw new Error('Failed to fetch accepted wifis'); return res.json(); }; export const apiSaveAcceptedWifis = async (items: WifiAcceptItem[]): Promise => { - const res = await fetch(`${API_BASE}/network/accepted-wifis`, { + const res = await staffFetch('/network/accepted-wifis', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items }), }); if (!res.ok) throw new Error('Failed to save accepted wifis'); @@ -338,13 +496,13 @@ export const apiFetchClassSessionLogs = async ( if (date) params.set('date', date); if (period) params.set('period', String(period)); const query = params.toString() ? `?${params.toString()}` : ''; - const res = await fetch(`${API_BASE}/classes/${rkId}/session-logs${query}`); + const res = await staffFetch(`/classes/${rkId}/session-logs${query}`); if (!res.ok) throw new Error('Failed to fetch class session logs'); return res.json(); }; export const apiFetchOnlineStudents = async (rkId: number): Promise<{ onlineStudentIds: number[] }> => { - const res = await fetch(`${API_BASE}/classes/${rkId}/online-students`); + const res = await staffFetch(`/classes/${rkId}/online-students`); if (!res.ok) throw new Error('Failed to fetch online students list'); return res.json(); }; @@ -356,7 +514,7 @@ export const apiFetchClassCourses = async (rkId: number): Promise<{ warning?: string; hint?: string; }> => { - const res = await fetch(`${API_BASE}/classes/${rkId}/courses`); + const res = await staffFetch(`/classes/${rkId}/courses`); const body = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(body.error || body.hint || 'Không tải được danh sách môn học từ QLĐT'); @@ -365,20 +523,20 @@ export const apiFetchClassCourses = async (rkId: number): Promise<{ }; export const apiApplyScheduleTemplate = async (rkId: number): Promise<{ created: number; skipped: number }> => { - const res = await fetch(`${API_BASE}/classes/${rkId}/schedule/apply-template`, { method: 'POST' }); + const res = await staffFetch(`/classes/${rkId}/schedule/apply-template`, { method: 'POST' }); if (!res.ok) throw new Error('Failed to apply schedule template'); return res.json(); }; export const apiFetchAttendanceShifts = async (rkId: number, date?: string) => { const q = date ? `?date=${date}` : ''; - const res = await fetch(`${API_BASE}/classes/${rkId}/attendance/shifts${q}`); + const res = await staffFetch(`/classes/${rkId}/attendance/shifts${q}`); if (!res.ok) throw new Error('Failed to fetch attendance shifts'); return res.json(); }; export const apiFetchAttendance = async (rkId: number, date: string, period: number) => { - const res = await fetch(`${API_BASE}/classes/${rkId}/attendance?date=${date}&period=${period}`); + const res = await staffFetch(`/classes/${rkId}/attendance?date=${date}&period=${period}`); if (!res.ok) throw new Error('Failed to fetch attendance'); return res.json(); }; @@ -387,9 +545,8 @@ export const apiUpdateAttendanceStatus = async ( rkId: number, payload: { date: string; period: number; studentRkId: number; status: number } ) => { - const res = await fetch(`${API_BASE}/classes/${rkId}/attendance/status`, { + const res = await staffFetch(`/classes/${rkId}/attendance/status`, { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!res.ok) { @@ -400,9 +557,8 @@ export const apiUpdateAttendanceStatus = async ( }; export const apiPushAttendanceQLDT = async (rkId: number, date: string, period: number) => { - const res = await fetch(`${API_BASE}/classes/${rkId}/attendance/push-qldt`, { + const res = await staffFetch(`/classes/${rkId}/attendance/push-qldt`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ date, period }), }); if (!res.ok) { diff --git a/management/src/auth/AuthContext.tsx b/management/src/auth/AuthContext.tsx new file mode 100644 index 0000000..5b40935 --- /dev/null +++ b/management/src/auth/AuthContext.tsx @@ -0,0 +1,114 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'; +import { apiAuth } from '../api'; + +export interface StaffUser { + id: number; + email: string; + fullName: string; + mustChangePassword: boolean; +} + +interface AuthState { + token: string | null; + staff: StaffUser | null; + loading: boolean; + login: (email: string, password: string) => Promise; + provision: (email: string) => Promise; + logout: () => void; + changePassword: (oldPassword: string, newPassword: string) => Promise; + forgotPassword: (email: string) => Promise; + resetPassword: (token: string, newPassword: string) => Promise; + refreshMe: () => Promise; +} + +const TOKEN_KEY = 'sc_staff_token'; +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY)); + const [staff, setStaff] = useState(null); + const [loading, setLoading] = useState(true); + + const applySession = useCallback((nextToken: string | null, nextStaff: StaffUser | null) => { + setToken(nextToken); + setStaff(nextStaff); + if (nextToken) { + localStorage.setItem(TOKEN_KEY, nextToken); + } else { + localStorage.removeItem(TOKEN_KEY); + } + }, []); + + const refreshMe = useCallback(async () => { + if (!token) { + setStaff(null); + setLoading(false); + return; + } + try { + const res = await apiAuth.me(); + setStaff(res.staff); + } catch { + applySession(null, null); + } finally { + setLoading(false); + } + }, [token, applySession]); + + useEffect(() => { + refreshMe(); + }, [refreshMe]); + + const login = useCallback(async (email: string, password: string) => { + const res = await apiAuth.login(email, password); + applySession(res.token, res.staff); + }, [applySession]); + + const provision = useCallback(async (email: string) => { + const res = await apiAuth.provision(email); + return res.message; + }, []); + + const logout = useCallback(() => { + applySession(null, null); + }, [applySession]); + + const changePassword = useCallback(async (oldPassword: string, newPassword: string) => { + const res = await apiAuth.changePassword(oldPassword, newPassword); + applySession(res.token, res.staff); + }, [applySession]); + + const forgotPassword = useCallback(async (email: string) => { + const res = await apiAuth.forgotPassword(email); + return res.message; + }, []); + + const resetPassword = useCallback(async (resetToken: string, newPassword: string) => { + await apiAuth.resetPassword(resetToken, newPassword); + }, []); + + const value = useMemo(() => ({ + token, + staff, + loading, + login, + provision, + logout, + changePassword, + forgotPassword, + resetPassword, + refreshMe, + }), [token, staff, loading, login, provision, logout, changePassword, forgotPassword, resetPassword, refreshMe]); + + return {children}; +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used within AuthProvider'); + return ctx; +} + +export function getStoredToken(): string | null { + return localStorage.getItem(TOKEN_KEY); +} diff --git a/management/src/components/AccountsTab.tsx b/management/src/components/AccountsTab.tsx new file mode 100644 index 0000000..1b7eb4a --- /dev/null +++ b/management/src/components/AccountsTab.tsx @@ -0,0 +1,167 @@ +import { useEffect, useState } from 'react'; +import { useAuth } from '../auth/AuthContext'; +import { apiAdmin, type EmailDomainItem } from '../api'; + +export function ChangePasswordPage({ forced }: { forced?: boolean }) { + const { changePassword, logout } = useAuth(); + const [oldPassword, setOldPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + if (newPassword.length < 8) { + setError('Mật khẩu mới tối thiểu 8 ký tự'); + return; + } + if (newPassword !== confirm) { + setError('Xác nhận mật khẩu không khớp'); + return; + } + setBusy(true); + try { + await changePassword(oldPassword, newPassword); + } catch (err: any) { + setError(err?.message || 'Đổi mật khẩu thất bại'); + } finally { + setBusy(false); + } + }; + + return ( +
    +
    +

    {forced ? 'Đổi mật khẩu bắt buộc' : 'Đổi mật khẩu'}

    + {forced &&

    Lần đăng nhập đầu tiên — vui lòng đặt mật khẩu mới trước khi tiếp tục.

    } +
    + + + + {error &&
    {error}
    } + + {!forced && ( + + )} +
    +
    +
    + ); +} + +export function AccountsTab() { + const { staff, changePassword } = useAuth(); + const [domains, setDomains] = useState([]); + const [newDomain, setNewDomain] = useState(''); + const [oldPw, setOldPw] = useState(''); + const [newPw, setNewPw] = useState(''); + const [msg, setMsg] = useState(''); + const [err, setErr] = useState(''); + + const load = async () => { + const res = await apiAdmin.listEmailDomains(); + setDomains(res.data); + }; + + useEffect(() => { + load().catch(console.error); + }, []); + + const addDomain = async () => { + if (!newDomain.trim()) return; + await apiAdmin.addEmailDomain(newDomain.trim()); + setNewDomain(''); + await load(); + }; + + const removeDomain = async (id: number) => { + if (!confirm('Xóa đuôi email này?')) return; + await apiAdmin.deleteEmailDomain(id); + await load(); + }; + + const submitPw = async (e: React.FormEvent) => { + e.preventDefault(); + setErr(''); + setMsg(''); + try { + await changePassword(oldPw, newPw); + setMsg('Đã đổi mật khẩu'); + setOldPw(''); + setNewPw(''); + } catch (e: any) { + setErr(e?.message || 'Lỗi'); + } + }; + + return ( +
    +
    +

    Tài khoản & bảo mật

    +

    Quản lý đuôi email được phép và đổi mật khẩu.

    +
    + +
    +

    Tài khoản hiện tại

    +

    {staff?.email}

    +
    + +
    +

    Đuôi email được phép

    +

    + Nếu chưa có bản ghi nào, hệ thống chấp nhận mọi đuôi email. Thêm đuôi để giới hạn truy cập. +

    +
    + setNewDomain(e.target.value)} + style={{ flex: 1 }} + /> + +
    +
      + {domains.length === 0 &&
    • Chưa cấu hình — chấp nhận tất cả đuôi email
    • } + {domains.map((d) => ( +
    • + @{d.domain} + +
    • + ))} +
    +
    + +
    +

    Đổi mật khẩu

    +
    + + + {err &&
    {err}
    } + {msg &&
    {msg}
    } + +
    +
    +
    + ); +} diff --git a/management/src/components/ChatWidget.tsx b/management/src/components/ChatWidget.tsx new file mode 100644 index 0000000..75863fd --- /dev/null +++ b/management/src/components/ChatWidget.tsx @@ -0,0 +1,231 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { apiChat, type ChatConversation, type ChatMessage, type ChatStudent } from '../api'; +import { useAuth } from '../auth/AuthContext'; + +export function ChatWidget() { + const { staff } = useAuth(); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const [students, setStudents] = useState([]); + const [conversations, setConversations] = useState([]); + const [active, setActive] = useState(null); + const [messages, setMessages] = useState([]); + const [draft, setDraft] = useState(''); + const [sending, setSending] = useState(false); + const [pickerOpen, setPickerOpen] = useState(false); + const listRef = useRef(null); + const activeRef = useRef(null); + + useEffect(() => { + activeRef.current = active; + }, [active]); + + const scrollBottom = () => { + requestAnimationFrame(() => { + if (listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight; + }); + }; + + const totalUnread = conversations.reduce((n, c) => n + (c.unread || 0), 0); + + const loadConversations = useCallback(async () => { + const res = await apiChat.listConversations(); + setConversations(res.data); + }, []); + + const loadMessages = useCallback(async (studentRkId: number) => { + const res = await apiChat.listMessages(studentRkId); + setMessages(res.data); + scrollBottom(); + await loadConversations(); + }, [loadConversations]); + + const searchStudents = useCallback(async (q: string) => { + const res = await apiChat.searchStudents(q); + setStudents(res.data); + }, []); + + const pickStudent = async (s: ChatStudent) => { + setActive(s); + setPickerOpen(false); + setQuery(''); + await loadMessages(s.studentRkId); + }; + + const pickFromConversation = async (c: ChatConversation) => { + await pickStudent({ + studentRkId: c.studentRkId, + fullName: c.fullName, + studentCode: c.studentCode, + email: '', + online: false, + }); + }; + + useEffect(() => { + if (!open || !staff?.id) return; + loadConversations().catch(console.error); + searchStudents('').catch(console.error); + }, [open, staff?.id, loadConversations, searchStudents]); + + useEffect(() => { + if (!staff?.id) return; + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.hostname}:8080/ws?role=teacher&staffId=${staff.id}`; + const ws = new WebSocket(wsUrl); + ws.onmessage = (ev) => { + try { + const payload = JSON.parse(ev.data); + if (payload.event !== 'chat:message') return; + const msg = payload.data as ChatMessage; + loadConversations().catch(console.error); + const current = activeRef.current; + if (current && msg.studentRkId === current.studentRkId) { + setMessages((prev) => { + if (prev.some((m) => m.id === msg.id)) return prev; + return [...prev, msg]; + }); + scrollBottom(); + } + } catch { + /* ignore */ + } + }; + return () => ws.close(); + }, [staff?.id, loadConversations]); + + const send = async () => { + if (!active || !draft.trim()) return; + setSending(true); + try { + const res = await apiChat.sendMessage(active.studentRkId, draft.trim()); + setMessages((prev) => [...prev, res.data]); + setDraft(''); + scrollBottom(); + await loadConversations(); + } finally { + setSending(false); + } + }; + + if (!staff) return null; + + const dock = ( +
    + {!open ? ( + + ) : ( +
    +
    + Tin nhắn +
    + + +
    +
    + +
    + + +
    + {!active ? ( +
    +

    Chọn hội thoại bên trái hoặc tìm sinh viên để bắt đầu chat

    +
    + ) : ( + <> +
    +
    + {active.fullName} +
    {active.studentCode}
    +
    +
    +
    + {messages.map((m) => ( +
    +
    {m.body}
    +
    + {new Date(m.createdAt).toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })} +
    +
    + ))} + {messages.length === 0 &&
    Chưa có tin nhắn
    } +
    +
    + setDraft(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }} + /> + +
    + + )} +
    +
    +
    + )} +
    + ); + + return createPortal(dock, document.body); +} diff --git a/management/src/components/ClassWorkspace.tsx b/management/src/components/ClassWorkspace.tsx index 879aa14..c7f0f20 100644 --- a/management/src/components/ClassWorkspace.tsx +++ b/management/src/components/ClassWorkspace.tsx @@ -58,17 +58,14 @@ export const ClassWorkspace: React.FC = ({ classId, sourceT setLoading(true); // Fetch specific class detail directly - const targetClassRes = await fetch(`http://127.0.0.1:8080/api/classes/${classId}`); - if (targetClassRes.ok) { - const classData = await targetClassRes.json(); - setClassInfo(classData); - pushNav({ - kind: 'class', - tab: sourceTab, - classId, - label: classData.name || `Lớp ${classId}`, - }); - } + const classData = await api.getClass(classId); + setClassInfo(classData); + pushNav({ + kind: 'class', + tab: sourceTab, + classId, + label: classData.name || `Lớp ${classId}`, + }); // Fetch students roster const studentsRes = await apiFetchClassStudents(classId); diff --git a/management/src/components/LoginPage.tsx b/management/src/components/LoginPage.tsx new file mode 100644 index 0000000..350e18a --- /dev/null +++ b/management/src/components/LoginPage.tsx @@ -0,0 +1,114 @@ +import { useState } from 'react'; +import { useAuth } from '../auth/AuthContext'; + +type Mode = 'login' | 'provision' | 'forgot' | 'reset'; + +export function LoginPage() { + const { login, provision, forgotPassword, resetPassword } = useAuth(); + const [mode, setMode] = useState('login'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [resetToken, setResetToken] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [message, setMessage] = useState(''); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setMessage(''); + setBusy(true); + try { + if (mode === 'login') { + await login(email, password); + } else if (mode === 'provision') { + const msg = await provision(email); + setMessage(msg); + setMode('login'); + } else if (mode === 'forgot') { + const msg = await forgotPassword(email); + setMessage(msg); + setMode('reset'); + } else if (mode === 'reset') { + await resetPassword(resetToken, newPassword); + setMessage('Đã đặt lại mật khẩu. Vui lòng đăng nhập.'); + setMode('login'); + } + } catch (err: any) { + setError(err?.message || 'Có lỗi xảy ra'); + } finally { + setBusy(false); + } + }; + + return ( +
    +
    +
    + Simple Care +
    +
    Simple Care
    +
    Quản lý giám sát — Rikkei Education
    +
    +
    + +

    + {mode === 'login' && 'Đăng nhập'} + {mode === 'provision' && 'Truy cập lần đầu'} + {mode === 'forgot' && 'Quên mật khẩu'} + {mode === 'reset' && 'Đặt lại mật khẩu'} +

    +

    + {mode === 'provision' + ? 'Nhập email tổ chức. Hệ thống gửi mật khẩu tạm nếu đuôi email được phép.' + : 'Chỉ email có đuôi tổ chức được phép truy cập.'} +

    + +
    + {(mode === 'login' || mode === 'provision' || mode === 'forgot') && ( + + )} + {mode === 'login' && ( + + )} + {mode === 'reset' && ( + <> + + + + )} + {error &&
    {error}
    } + {message &&
    {message}
    } + +
    + +
    + {mode === 'login' && ( + <> + + + + )} + {mode !== 'login' && ( + + )} +
    +
    +
    + ); +} diff --git a/management/src/index.css b/management/src/index.css index e366454..9c2ebed 100644 --- a/management/src/index.css +++ b/management/src/index.css @@ -3233,3 +3233,427 @@ input:checked + .slider:before { flex: 1; } } + +/* ── Login ── */ +.auth-shell { + width: 100%; + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + background: var(--bg-app); +} + +.login-screen { + width: 100%; + max-width: 440px; + margin: 0 auto; +} + +.login-card { + width: 100%; + max-width: 420px; + padding: 1.75rem; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); +} + +.login-brand { + display: flex; + gap: 0.85rem; + align-items: center; + margin-bottom: 1.25rem; +} + +.login-brand-title { + font-weight: 800; + font-size: 1.1rem; +} + +.login-brand-sub { + font-size: 0.75rem; + color: var(--text-muted); +} + +.login-heading { + margin: 0 0 0.35rem; + font-size: 1.35rem; +} + +.login-hint { + margin: 0 0 1rem; + font-size: 0.85rem; + color: var(--text-muted); +} + +.login-form { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.login-field { + display: flex; + flex-direction: column; + gap: 0.3rem; + font-size: 0.8rem; + color: var(--text-muted); +} + +.login-field input { + padding: 0.55rem 0.65rem; + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-elevated); + color: var(--text-primary); +} + +.login-submit { + width: 100%; + margin-top: 0.25rem; +} + +.login-error { + color: #ef4444; + font-size: 0.82rem; +} + +.login-success { + color: #22c55e; + font-size: 0.82rem; +} + +.login-links { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1rem; +} + +.link-btn { + background: none; + border: none; + color: var(--accent); + cursor: pointer; + font-size: 0.82rem; + padding: 0; +} + +.domain-list { + list-style: none; + margin: 0; + padding: 0; +} + +.domain-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0; + border-bottom: 1px solid var(--border-color); +} + +.domain-empty { + color: var(--text-muted); + font-size: 0.85rem; + padding: 0.5rem 0; +} + +/* ── Chat messenger dock ── */ +.chat-dock { + position: fixed; + right: 20px; + bottom: 20px; + z-index: 10000; + font-family: var(--font-sans); +} + +.chat-fab { + position: relative; + width: 56px; + height: 56px; + border-radius: 50%; + border: none; + background: var(--accent); + color: #fff; + font-size: 1.4rem; + cursor: pointer; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.22); +} + +.chat-fab-badge { + position: absolute; + top: -4px; + right: -4px; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + background: #ef4444; + color: #fff; + font-size: 0.65rem; + font-weight: 700; + line-height: 18px; +} + +.chat-messenger { + width: min(720px, calc(100vw - 40px)); + height: min(520px, calc(100vh - 100px)); + background: #fff; + border: 1px solid var(--border-color); + border-radius: 14px; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.chat-messenger-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border-color); + background: #fff; + flex-shrink: 0; +} + +.chat-messenger-body { + display: flex; + flex: 1; + min-height: 0; + background: #fff; +} + +.chat-sidebar { + width: 260px; + flex-shrink: 0; + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + min-height: 0; + background: #f8fafc; +} + +.chat-sidebar-search { + padding: 0.6rem; + border-bottom: 1px solid var(--border-color); + background: #fff; +} + +.chat-sidebar-search input { + width: 100%; + padding: 0.5rem 0.65rem; + border-radius: 8px; + border: 1px solid var(--border-color); + font-size: 0.82rem; +} + +.chat-picker-list, +.chat-conv-list { + list-style: none; + margin: 0; + padding: 0; + overflow-y: auto; +} + +.chat-picker-list { + max-height: 160px; + border-bottom: 1px solid var(--border-color); + background: #fff; +} + +.chat-conv-list { + flex: 1; +} + +.chat-picker-list button, +.chat-conv-list button { + width: 100%; + text-align: left; + border: none; + background: transparent; + padding: 0.65rem 0.75rem; + cursor: pointer; + border-bottom: 1px solid var(--border-light); + color: var(--text-primary); +} + +.chat-conv-list button:hover, +.chat-picker-list button:hover { + background: #eef2f7; +} + +.chat-conv-list button.active { + background: var(--accent-light); + border-left: 3px solid var(--accent); +} + +.chat-conv-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; +} + +.chat-conv-name { + font-size: 0.84rem; + font-weight: 600; + display: block; +} + +.chat-conv-preview { + display: block; + font-size: 0.75rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 0.15rem; +} + +.chat-conv-meta { + font-size: 0.68rem; + color: var(--text-muted); +} + +.chat-conv-unread { + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + background: var(--accent); + color: #fff; + font-size: 0.62rem; + font-weight: 700; + line-height: 18px; + text-align: center; +} + +.chat-empty-hint { + padding: 1rem 0.75rem; + font-size: 0.78rem; + color: var(--text-muted); + text-align: center; +} + +.chat-thread { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + background: #fff; +} + +.chat-thread-empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + color: var(--text-muted); + font-size: 0.85rem; + text-align: center; +} + +.chat-thread-head { + padding: 0.65rem 1rem; + border-bottom: 1px solid var(--border-color); + background: #fff; + flex-shrink: 0; +} + +.chat-sub { + font-size: 0.72rem; + color: var(--text-muted); +} + +.chat-head-actions { + display: flex; + gap: 0.25rem; +} + +.chat-icon-btn { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 1rem; + width: 30px; + height: 30px; + border-radius: 6px; +} + +.chat-icon-btn:hover { + background: var(--bg-subtle); +} + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 0.75rem 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + background: #f5f7fa; +} + +.chat-bubble { + max-width: 78%; + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.chat-bubble--staff { + align-self: flex-start; +} + +.chat-bubble--student { + align-self: flex-end; +} + +.chat-bubble-body { + padding: 0.5rem 0.7rem; + border-radius: 14px; + font-size: 0.84rem; + line-height: 1.4; + word-break: break-word; +} + +.chat-bubble--staff .chat-bubble-body { + background: #fff; + border: 1px solid var(--border-color); +} + +.chat-bubble--student .chat-bubble-body { + background: var(--accent); + color: #fff; +} + +.chat-bubble-time { + font-size: 0.62rem; + color: var(--text-muted); + padding: 0 0.25rem; +} + +.chat-compose { + display: flex; + gap: 0.5rem; + padding: 0.65rem 0.85rem; + border-top: 1px solid var(--border-color); + background: #fff; + flex-shrink: 0; +} + +.chat-compose input { + flex: 1; + padding: 0.55rem 0.7rem; + border-radius: 8px; + border: 1px solid var(--border-color); + font-size: 0.84rem; +} diff --git a/management/src/main.tsx b/management/src/main.tsx index bef5202..ca71cbb 100644 --- a/management/src/main.tsx +++ b/management/src/main.tsx @@ -2,9 +2,40 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' +import { AuthProvider, useAuth } from './auth/AuthContext' +import { LoginPage } from './components/LoginPage' +import { ChangePasswordPage } from './components/AccountsTab' + +function AppGate() { + const { token, staff, loading } = useAuth(); + if (loading) { + return ( +
    +
    Đang tải...
    +
    + ); + } + if (!token) { + return ( +
    + +
    + ); + } + if (staff?.mustChangePassword) { + return ( +
    + +
    + ); + } + return ; +} createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/management/src/navigation.ts b/management/src/navigation.ts index 8c0b0f9..0915b00 100644 --- a/management/src/navigation.ts +++ b/management/src/navigation.ts @@ -1,4 +1,4 @@ -export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'network'; +export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'network' | 'accounts'; export interface NavEntry { id: string; @@ -15,13 +15,14 @@ export const TAB_LABELS: Record = { students: 'Sinh viên', learning: 'Giám sát & Lịch học', network: 'Quản lý mạng', + accounts: 'Tài khoản', }; const HISTORY_KEY = 'sc_nav_history'; const MAX_HISTORY = 10; function isTabId(value: string | null): value is TabId { - return value === 'dashboard' || value === 'classes' || value === 'students' || value === 'learning' || value === 'network'; + return value === 'dashboard' || value === 'classes' || value === 'students' || value === 'learning' || value === 'network' || value === 'accounts'; } export function parseRoute(): { tab: TabId; classId: number | null } { diff --git a/server/.env b/server/.env index dd387d3..e010a0b 100644 --- a/server/.env +++ b/server/.env @@ -1 +1,9 @@ -QLDT_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InBodW9jbnRiQHJpa2tlaWFjYWRlbXkuY29tIiwibmFtZSI6Ik5ndXnhu4VuIFRoYW5oIELDrG5oIFBoxrDhu5tjIiwiaWQiOjI0LCJyb2xlIjpbeyJpZCI6MSwibmFtZSI6IkFETUlOIn0seyJpZCI6MywibmFtZSI6IlRFQUNIRVIifV0sInR5cGUiOiJ1c2VyIiwiaWF0IjoxNzgyNzc3NzEzLCJleHAiOjE3ODI4NjQxMTN9.bj4nMPYZWVIsuiuA3XHLtc8yFFwAC9MDlPx-FnIM6z4 \ No newline at end of file +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" \ No newline at end of file diff --git a/server/go.mod b/server/go.mod index ac93b16..603f54a 100644 --- a/server/go.mod +++ b/server/go.mod @@ -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 ) diff --git a/server/go.sum b/server/go.sum index 9089d06..d253053 100644 --- a/server/go.sum +++ b/server/go.sum @@ -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= diff --git a/server/internal/auth/auth.go b/server/internal/auth/auth.go new file mode 100644 index 0000000..82cc803 --- /dev/null +++ b/server/internal/auth/auth.go @@ -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 +} diff --git a/server/internal/db/db.go b/server/internal/db/db.go index 2fd6ffc..beec0a8 100644 --- a/server/internal/db/db.go +++ b/server/internal/db/db.go @@ -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 } diff --git a/server/internal/handlers/handlers_auth.go b/server/internal/handlers/handlers_auth.go new file mode 100644 index 0000000..38646e7 --- /dev/null +++ b/server/internal/handlers/handlers_auth.go @@ -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(`

    Xin chào,

    Tài khoản Simple Care Management đã được tạo.

    +

    Email: %s
    Mật khẩu tạm: %s

    +

    Đăng nhập và đổi mật khẩu ngay lần đầu.

    `, 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(`

    Đặt lại mật khẩu Simple Care.

    +

    Mã đặt lại (hiệu lực 2 giờ): %s

    +

    Nhập mã này trên màn hình đặt lại mật khẩu.

    `, 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}) + } +} diff --git a/server/internal/handlers/handlers_chat.go b/server/internal/handlers/handlers_chat.go new file mode 100644 index 0000000..da03a76 --- /dev/null +++ b/server/internal/handlers/handlers_chat.go @@ -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}) + } +} diff --git a/server/internal/mail/mail.go b/server/internal/mail/mail.go new file mode 100644 index 0000000..f4c14da --- /dev/null +++ b/server/internal/mail/mail.go @@ -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)) +} diff --git a/server/internal/middleware/staff.go b/server/internal/middleware/staff.go new file mode 100644 index 0000000..8d4eb1e --- /dev/null +++ b/server/internal/middleware/staff.go @@ -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 +} diff --git a/server/internal/models/staff.go b/server/internal/models/staff.go new file mode 100644 index 0000000..3a5c2c6 --- /dev/null +++ b/server/internal/models/staff.go @@ -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" } diff --git a/server/internal/websocket/websocket.go b/server/internal/websocket/websocket.go index 8425c2d..e0f2c71 100644 --- a/server/internal/websocket/websocket.go +++ b/server/internal/websocket/websocket.go @@ -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, } diff --git a/server/main.go b/server/main.go index a0e3c1d..7ecc90e 100644 --- a/server/main.go +++ b/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)