Compare commits

..
550 Commits
Author SHA1 Message Date
ssbg04 b5402f34b5 feat: in-app updater and responsive csv enrollment UI 2026-10-05 15:16:29 +08:00
ssbg04 d126ecaef1 rework the entire docs 2026-10-05 09:45:38 +08:00
ssbg04 a8473c23b7 feat(students): add single-student multi-enrollment CSV import modal
- Add StudentCsvEnrollmentModal for bulk-adding multiple enrollment
  records across school years for one specific student via CSV input
- CSV format: School Year, Grade Level, Section, Track/Strand
- Features: editable monospace text area, Fill Example, Copy Template,
  file upload (.csv/.txt), and live backend progression verification
- Show row-by-row verification results with valid/error status chips
  and actionable error messages (section suggestions, available years)
- Admin-only: hidden from teacher role via _isTeacher guard
- Wire Bulk Enroll (CSV) button in edit_student_modal.dart to
  StudentCsvEnrollmentModal passing studentId, studentName, studentLrn
- Add Bulk CSV shortcut button in edit_enrollment_modal.dart Current
  Enrollments header for admins
- Backend: POST /api/students/:id/verify-enrollments for live per-row
  progression and section existence validation
- Backend: POST /api/students/:id/bulk-enrollments for transactional
  batch enrollment creation with activity logging and socket events
- Add verifyStudentEnrollments and bulkAddStudentEnrollments repository
  methods in student_repository.dart
2026-10-04 13:48:53 +08:00
ssbg04 0058decaef feat: enhance teacher permissions, folder filtering, and bulk enrollment verification 2026-10-04 11:24:07 +08:00
ssbg04 3e71448add fix minor words 2026-10-02 21:48:46 +08:00
ssbg04 d4d80bf9e6 docs: update user documentation with consumer-friendly language and revised feature scopes 2026-10-02 21:37:29 +08:00
ssbg04 35f56f925a fix(reports): eliminate students per year overflow and align compliance card heights to 480px
- Prevent RenderFlex bottom overflow in yearly comparison chart with dynamic height clamping
- Harmonize Missing Documents, Grade Compliance, Status Donut, and Yearly Comparison cards to 480px on desktop
- Optimize logo assets to webp across login, splash, and layout navbars
- Enable resource shrinking and minification in Android release build
- Remove obsolete frontend Excel templates from assets and pubspec
2026-10-02 21:20:21 +08:00
ssbg04 e6e8188312 feat(reports): refine filters and export layout, add backend Excel export, optimize logo to webp 2026-10-02 20:30:15 +08:00
ssbg04 3a3e0dc7b8 feat: enhance deped reports pdf/excel, dashboard rotation, user creation, and history search shortcut
- Reports: migrate compliance export to xlsx, fix android chart tooltip headroom, add deped collapsible section descriptions, and complete 3-page DepEd PDF export

- Dashboard: continuous document breakdown rotation on mobile, persistent setup banner

- Users: automated background password generation, standardized action button dimensions

- History: add Ctrl+F search shortcut with autofocus cursor in search dialog
2026-10-02 17:02:04 +08:00
ssbg04 a74e0dc7fe feat(docs): redesign documentation to clean minimalist standard format and update system features 2026-10-02 10:34:04 +08:00
ssbg04 79f4504694 fix(audit-trail): prevent timestamp and badge overflow on printed history cards 2026-10-02 10:24:35 +08:00
ssbg04 b04981d7fb feat(system): implement socket.io real-time sync, admin passwords, ocr adjustments, and shortcut fixes 2026-10-02 10:17:44 +08:00
ssbg04 f30faeecff feat(students): adapt filter dialog for enrolled vs all tabs
- Hide school year and status filters in student filter dialog on Enrolled tab\n- Remove 'Enrolled' status filter option on All Students tab\n- Sync studentViewTabProvider and studentActiveFilterCountProvider across desktop and mobile layouts
2026-10-01 23:49:48 +08:00
ssbg04 5952de21b2 fix(android): resolve permission denied on PDF export and make settings responsive
- Fix Android PDF and report export permission denied by adding adaptive storage permission handling (MANAGE_EXTERNAL_STORAGE on Android 11+ and legacy storage on Android 10) and multi-tier fallback to app-specific external storage in DownloadService\n- Update DepEd Transparency Board PDF export and reports export to use DownloadService.saveBytes()\n- Make Settings screen About, Support, and System Information responsive across screen widths\n- Update Student and User screen top headers to segmented tabs design and tighten mobile drawer edge touch area
2026-10-01 23:18:51 +08:00
ssbg04 bcfa1a0919 perf(frontend): optimize build size and update students view tab design
- Purge heavy and unused packages (syncfusion_flutter_pdfviewer, excel, sheetifye, cupertino_icons, image_picker, lottie, flutter_holo_date_picker, flutter_speed_dial)
- Delegate PDF and spreadsheet preview to system default viewers via open_filex
- Replace excel report generation with pure Dart UTF-8 BOM CSV generator
- Configure NDK debug symbol stripping in build.gradle.kts
- Align Students screen Enrolled/All Students tab switcher with Archives segmented tab design
2026-10-01 22:01:07 +08:00
ssbg04 3e2a52770c fix(students): ensure accurate active-year student loading and navigate to all tab on search 2026-09-29 05:24:01 +08:00
ssbg04 4f20a67fda fix: resolve multi-account session conflict, remove developer account, add student view tabs 2026-09-29 05:19:45 +08:00
ssbg04 c085154b8a feat(demo): update Android mobile demo to match actual app screenshots with drawer navigation and full screens 2026-09-28 22:38:55 +08:00
ssbg04 e533b60769 feat(demo): build authentic web-based static app matching screenshots 2026-09-28 22:31:29 +08:00
ssbg04 fca3a1f9d7 feat(demo): update prototype to showcase authentic Windows and Android app screens 2026-09-28 22:23:12 +08:00
ssbg04 18abb9a6d8 feat(demo): add interactive static prototype with Windows and Android viewports 2026-09-28 22:19:35 +08:00
ssbg04 d75d441441 fix(students): hide bulk import button on windows app 2026-09-28 21:52:41 +08:00
ssbg04 6f95fd265b feat(update): detect Android architecture and launch architecture-matched APK download URL 2026-09-28 21:45:11 +08:00
ssbg04 09c0e600e9 feat(settings): add about section with browser update check and remove splash popup 2026-09-28 21:29:05 +08:00
ssbg04 c6d5607811 perf(android): enable R8 shrinking, ProGuard rules, and downscale image assets 2026-09-28 20:58:22 +08:00
ssbg04 8fcd875f38 feat(mobile): enable sliding drawer from hamburger icon and wide left-edge gesture 2026-09-28 20:48:07 +08:00
ssbg04 3a1f872f54 feat(capstone): redesign members screen with photo cards and updated institutional branding 2026-09-28 20:41:15 +08:00
ssbg04 30e260a357 refactor(login): remove localhost and vps server config options 2026-09-28 19:32:14 +08:00
ssbg04 58bf10b4ec fix(reports): make transparency board section titles responsive on android, refine bulk ocr, and update downloads directory 2026-09-27 20:13:45 +08:00
ssbg04 bd2a8acbfe feat(settings): clean academic setup buttons and rename screen to Academic & Class Management
- Update screen, settings card, and dashboard titles to 'Academic & Class Management'

- Clean and balance Academic Structure toolbar for mobile and desktop

- Remove redundant buttons on section cards in favor of interactive adviser chip

- Modernize teacher action button to OutlinedButton.icon 'Sections'

- Add responsive bulk academic structure import support across backend and frontend
2026-09-27 02:05:47 +08:00
ssbg04 a9c2ab0da5 fix(setup): make bulk academic import responsive and fix runtime string indexing error 2026-09-27 01:30:57 +08:00
ssbg04 13bbdf9e26 feat(bulk-import): add live CSV input, format guides, and validation preview for students and academic structure 2026-09-27 01:05:59 +08:00
ssbg04 32cff2431a feat(settings): add teacher due date reminders card and automated notification service 2026-09-27 00:43:22 +08:00
ssbg04 ae5246f271 feat(settings): match scroll fade overlays with dashboard screen 2026-09-26 23:55:04 +08:00
ssbg04 42d9a60805 fix font color auto grad 2026-09-26 18:47:17 +08:00
ssbg04 ecfb98ac86 commented tasks done 2026-09-26 18:46:42 +08:00
ssbg04 7faade70a9 feat(reports,settings,docs): server-side PDF export, cutoff date picker, and UI refinements
- Reports: Migrate DepEd Transparency Board PDF export to backend Puppeteer service with enlarged layout and per-section downloads

- Settings: Replace dynamic grace period days input with cutoff date picker and calculated days preview; separate schedule date pickers

- Documents: Default requirements section to collapsed/closed state in upload OCR modal with pulsing border container

- Fix contrast on Auto-Graduation trigger button and ensure desktop responsiveness across report buttons
2026-09-26 18:32:44 +08:00
ssbg04 2376e99217 feat(auth,capstone): validate email on password reset, convert images to webp and add team carousel
- Add email deliverability verification (MEV) to forgot password flow (lookupResetOptions & sendEmailOtp)
- Validate recipient email format and verify deliverability before sending reset password link in user management
- Convert all backdrops, group photos, and solo avatars to WebP format, drastically reducing asset footprint from >60MB to ~4.6MB
- Add auto-advancing slide preview carousel with 2s cooldown, navigation buttons, and wrapping to Capstone Members screen
- Update Capstone member emails and add individual solo avatar photos
2026-09-25 21:18:02 +08:00
ssbg04 99be1e8d79 feat(build): split Android APKs, refine settings, fix student loading and PDF layout
- Split Android release into TIS_RMS_32bit, TIS_RMS_64, and TIS_RMS_Universal APKs in GitHub Actions and INSTALL.md
- Prioritize universal APK in AppUpdateService and update fallback labels
- Refactor settings academic collapse to 'Academic & Graduation' with automatic active school year card and relocated grace period
- Fix persistent loading indicators on student edit/add modals and list background
- Fix DepEd Transparency Board PDF report subheader layout to prevent title and period collision
- Update AGENTS.md backlog with document scanner and PDF processing items
2026-09-25 20:07:14 +08:00
ssbg04 71526fe61e fix(reports): resolve Windows layout overflows and refine UI
- Fix 1px bottom overflow on Overall Compliance KPI card by adjusting height and padding
- Fix text truncation for Pending Docs KPI card
- Fix 49px bottom overflow on Students Per Year card by adjusting container and chart heights
- Fix LayoutBuilder inside IntrinsicHeight issue on desktop report charts
- Refine security settings, audit trail platform tracking, and document handling
- Clean up unused conversion providers and LibreOffice service
2026-09-25 19:04:23 +08:00
ssbg04 13abad6563 feat(security,audit): fix platform tracking for history and settings, collapse logged-in devices in security tab 2026-09-25 09:34:14 +08:00
ssbg04 689dbc48e7 fix: self-deactivation, UI overflow, Open With, archive nav, schedule modal
Settings - Delete Account -> Self Deactivate:
- backend: add POST /api/auth/self-deactivate (immediate, no email link)
  Guards against last-admin and hidden super-admin. Revokes FCM tokens,
  sends notification email, logs activity
- auth.js route: add /self-deactivate route
- auth_repository.dart: add requestSelfDeactivation()
- auth_provider.dart: add requestSelfDeactivation() (calls logout() after)
- settings_screen.dart: 2-step _handleDeactivateAccount: Step 1 confirm,
  Step 2 type DEACTIVATE. Danger zone card updated to deactivation theme

Report screen - compliance filter button label overflow:
- Wrap label Text in Flexible to ellipsize instead of clip

Settings - Edit Schedule Dates modal button overflow:
- Column layout on narrow screens so buttons are never squished

Document screen - Open With not working on Windows:
- Use openwith.exe (Windows 8+) with fallback to rundll32 OpenAs_RunDLL

Windows sidebar - Archive tab:
- Move Archives from ACCOUNT group to OVERVIEW group
2026-09-21 17:51:33 +08:00
ssbg04 1a20994749 feat: document versioning, templates, ZIP backup/restore, and Open/Edit workflow
Backend:
- schema.js: add document_templates, template_versions, document_versions tables;
  migrate documents with template_id/template_version_id columns;
  auto-seed Version 1 for all 190 existing documents
- templateController.js (NEW): full CRUD + file upload/download for templates
  and template versions
- templates.js route (NEW): /api/templates endpoints
- documentController.js: add getDocumentVersions, uploadDocumentVersion,
  downloadDocumentVersion; uploadDocument now seeds Version 1 on creation
- documents.js: wire GET /:id/versions, POST /:id/upload-version
- backupController.js: upgrade backup to ZIP archive (DB + student files +
  template files + SHA-256 manifest); restore handles ZIP and legacy .db;
  safety backup taken before every restore
- package.json: add archiver + unzipper dependencies

Frontend:
- template_model.dart (NEW): DocumentTemplateModel + TemplateVersionModel entities
- document_version_model.dart (NEW): DocumentVersionModel entity
- template_repository.dart (NEW): Dio-based template API client
- document_repository.dart: add getDocumentVersions, uploadDocumentVersion,
  downloadDocumentVersion methods
- document_preview_modal.dart: add Open / Open With (Windows rundll32
  OpenAs_RunDLL / Android chooser) / Edit (download-temp -> open -> upload
  prompt) / Upload New Version (FilePicker) / Version History actions
- document_version_history_sheet.dart (NEW): draggable bottom sheet listing
  all document versions with per-version Preview + Download
- file_folder_card.dart: add Open With, Edit, Upload New Version, Version
  History items to context menu
- documents_screen.dart: wire open_with, edit, upload_version, version_history,
  restore action cases; import version history sheet

Bugfix:
- student_profile_modal.dart: remove enrollment.status access that caused
  NoSuchMethodError (enrollments table has no status column)
2026-09-21 12:55:58 +08:00
ssbg04 e2f10f6260 feat(ui,audit): redesign folder cards and polish student actions, fix audit trail crash and count queries
- Fix audit trail AppPagination infinite width layout crash on Windows and Android
- Fix Android audit trail header 41px horizontal overflow with responsive layout
- Add missing user and history table joins to dashboard and user activity count queries
- Implement StyledFolderIcon matching modern tabbed folder design with internal JHS/SHS chips
- Align bulk operations bar margin to match list and grid widget containers
- Make student screen Add Student button elevated with emerald gradient and shadow
- Move edit student modal close button from leading to actions bar
- Polish Edit Details and Edit Enrollment actions in student profile modal
2026-09-19 22:56:42 +08:00
ssbg04 69b4879ac2 feat(dashboard,ui): overhaul dashboard KPIs, audit trail tab, responsive controls, and settings
- Remove recent activities and user history from dashboard; relocate to dedicated History tab
- Fix Document Breakdown overflow and rebalance Bento KPI grid layout
- Fix Android overflow on student profile Edit Details button
- Simplify grid view cards in documents and archives screens with minimal folder styling
- Fix transparency board filter contrast and export button styling in reports
- Default auto-update enrollment to off, add grace period input, and fix settings danger zone
2026-09-19 02:26:49 +08:00
ssbg04 2dc8ee4242 feat(ui,api): update action buttons, password modal button, and dashboard user count
- Exclude hidden super administrator from dashboard active users count

- Rename UPDATE PASSWORD button to UPDATE in change password modal

- Use single-word labels and uniform dimensions for user profile action buttons
2026-09-18 21:43:39 +08:00
ssbg04 59e94192aa feat(frontend): enhance student profile actions and report transparency board
- Add separate Edit Details and Edit Enrollment buttons in student profile modal

- Change delete button to Inactive in student profile modal

- Remove Inactive status from edit student modal dropdown

- Remove Set Inactive action from student multi-select bar and context menu

- Require long-press to activate multi-select on mobile student avatar

- Add clean tab switcher and collapsible info boxes to DepEd Transparency Board
2026-09-18 19:37:27 +08:00
ssbg04 48fcf181af refactor(reports): use standalone section cards and clean minimal header for Transparency Board 2026-09-18 19:05:13 +08:00
ssbg04 793bd340f1 fix(windows): auto-discover and include Visual Studio ATL/MFC paths in CMake to resolve atlstr.h 2026-09-18 18:41:36 +08:00
ssbg04 029629c349 feat(reports,splash): separate transparency board data/graphs and add release update popup 2026-09-18 18:22:04 +08:00
ssbg04 9a47a61c55 ci: add .NET setup and bundle VC++ CRT DLLs for Windows client 2026-09-18 16:56:25 +08:00
ssbg04 78035685a3 fix(desktop): fix Windows 10 crash vectors across screens, dialogs, and audio 2026-09-18 12:03:00 +08:00
ssbg04 7db2ad88b3 ci: add version tag to apk and exe artifact names 2026-09-18 09:13:08 +08:00
ssbg04 f6765887f1 fix(converter): set default long bond paper and fit-to-page, remove MS Excel COM 2026-09-18 02:04:06 +08:00
ssbg04 4d0ea25506 feat(docs): upload modal requirements strip with accurate status, collapsible list, and JHS/SHS tags
- Fix requirements strip to use missingRequirementsProvider (per-student) instead
  of global documentRequirementsProvider, so Completed/Archived/Needed states
  are accurately reflected for each student
- Fix dropdown 'Select Document Type' status labels (was always [Completed]);
  now correctly shows [Needed], [Archived], or [Completed] using ID-based lookup
  from missingRequirementsProvider
- Add collapsible/expandable requirements strip with AnimatedCrossFade and
  chevron toggle; shows mandatory-needed badge count in collapsed header
- Split needed chips into Mandatory (red) and Optional (orange) sections;
  add Archived (blueGrey) section distinct from Completed (green)
- Add JHS/SHS category tag on each chip (muted grey badge)
- Simplify chip styling to neutral background/border; color only on status icon
- Remove tap-to-assign chip interaction; chips are now display-only
- Clean up unused _isRequirementMissing, _isRequirementArchived, and
  _assignRequirementToEntry methods
- Update AGENTS.md: move completed Document Screen tasks into commented section
2026-09-18 01:02:57 +08:00
ssbg04 42cec52e21 fix(documents): fix thumbnail generation by selecting doc id and clean cache on delete 2026-09-17 22:28:17 +08:00
ssbg04 64b58537bd feat(ui): update student, document preview, document filter, and archive screens
- Add elevated Add Student CTA button to Android header and AddStudentModal.show helper
- Make document preview AppBar actions responsive with copy and delete always in more menu
- Switch document filter to direct document type dropdown
- Align archives screen layout, mobile search, and filter dropdown with documents screen
2026-09-17 22:12:06 +08:00
ssbg04 0af3c726eb feat(ui): update mobile headers, selection animation, and folder lrn detection 2026-09-17 21:10:45 +08:00
ssbg04 6fa533d530 feat(ui): redesign student cards, responsive pagination, and fix doc status sheet button 2026-09-17 20:19:06 +08:00
ssbg04 b737aede64 fix(auth): parse urlencoded form and query fallback for account deletion confirmation 2026-09-17 18:38:08 +08:00
ssbg04 df60ee9c39 feat(ui): add mini checkbox to grid view and disable multi-select on preview tap 2026-09-17 16:06:55 +08:00
ssbg04 632c0a4a87 fix(ci): fix yaml indentation in release workflow key.properties task 2026-09-17 13:19:22 +08:00
ssbg04 4bb18d128f ci: restore android release keystore from secrets and bump version to 1.0.13+13 2026-09-17 13:14:59 +08:00
ssbg04 9cd8ac1783 build(android): configure release signing config with key.properties 2026-09-17 10:46:10 +08:00
ssbg04 05e11068f4 feat: add account self-deletion, hidden super admin, and selection morph animations 2026-09-17 09:26:56 +08:00
ssbg04 78717e76fe added ms excel COM 2026-09-16 04:59:02 +08:00
ssbg04 e11a6375a2 update: README.md 2026-09-16 01:03:56 +08:00
ssbg04 d662c72d14 chore: remove Pictures, plans, and .agents from git tracking 2026-09-15 23:19:48 +08:00
ssbg04 49af400c89 fix(release): auto-increment patch and build number for Android and Windows releases 2026-09-15 23:07:10 +08:00
ssbg04 14c6193367 fix(search): fix history overlay interaction and premature dismissal on Windows 2026-09-15 21:30:11 +08:00
ssbg04 1144b43d49 ci(release): auto-increment version and build number via run_number 2026-09-14 20:15:40 +08:00
ssbg04 28f1dfb1b0 fix(search): fix history overlay flicker and clear/delete behavior 2026-09-14 20:07:55 +08:00
Cris Charles 20d5d4d694 Refactor release.yml for improved readability 2026-09-14 13:33:20 +08:00
Cris Charles ba045acb74 Update release.yml 2026-09-14 13:30:00 +08:00
ssbg04 0e26fcea35 ci: update release workflow for Windows installer and Android universal apk 2026-09-14 12:53:21 +08:00
ssbg04 8e1cf43b14 ci: add GitHub Actions workflow for Windows and Android releases 2026-09-14 12:38:59 +08:00
ssbg04 1051967eb0 add: workflow for release apps' 2026-09-14 12:36:01 +08:00
ssbg04 9d30d86386 feat(settings): refactor collapsible cards, add feedback controls, and auto-collapse on leave 2026-09-14 12:26:08 +08:00
ssbg04 d2b3a2b5d6 feat(settings,students): add academic year activation dropdown and in-modal enrollment management 2026-09-14 11:44:27 +08:00
ssbg04 7907c244b1 feat(ui): polish user, settings, report screens
Fix user reset modal overflow and user search sync. Make settings cards collapsible and clean top banners.
2026-09-13 23:38:39 +08:00
ssbg04 470a8999a6 Redesign and optimize Documents and Archives screens header and list/grid views 2026-09-12 01:07:20 +08:00
ssbg04 bf33ac6544 feat(ui): hide desktop multi-select on Windows and harmonize filter icon colors 2026-09-11 23:13:04 +08:00
ssbg04 8d04d71925 feat(students): make profile circle clickable for multi-select on Android and restore hold-to-view status 2026-09-11 22:35:24 +08:00
ssbg04 b56f0b2f11 feat(users): make table column header fixed on scroll in user management 2026-09-11 22:03:58 +08:00
ssbg04 5d78742fef optimize(students): improve cards for low-end Android phones and mobile devices 2026-09-11 21:59:45 +08:00
ssbg04 d0164c0db3 fix(notif): fix Android background push notification delivery and deduplicate alerts 2026-09-11 19:42:58 +08:00
ssbg04 0f8cf0a5f9 feat(users): add status email alerts and update management UI 2026-09-11 06:06:49 +08:00
ssbg04 d8dd86ba04 feat: dynamic file upload limit per document requirement (frontend & backend) 2026-09-10 17:29:01 +08:00
ssbg04 01d636ea19 feat: implement roadmap updates, archive purge removal, and backend notifications/email resilience 2026-09-10 17:09:13 +08:00
ssbg04 1af5ada292 optimize(dashboard): smooth UI for Android and low-end devices, fixed history height, pause button border 2026-09-10 16:14:47 +08:00
ssbg04 fb5ba24571 fix(ui): adjust grid card sizing, add modal padding, and format pagination 2026-09-08 22:57:57 +08:00
ssbg04 287fe831e6 feat(ui): password modal in settings, compliance export relocation, and minimized grid cards 2026-09-08 22:35:50 +08:00
ssbg04 440dbae906 fix(ui): wrap file folder cards in local Material to prevent selection color bleed 2026-09-08 22:16:22 +08:00
ssbg04 7141f51e08 feat(ui): unify multi-select CAB design, card opacity, and padding across screens 2026-09-08 22:01:17 +08:00
ssbg04 fa0df9ed40 feat(dashboard): show document status distribution count over total requirements 2026-09-08 20:36:17 +08:00
ssbg04 e3452099cd fix(tunnel): separate health states, remove fake inferred-connected logic, add origin polling
- Remove INFERRED_CONNECT_MS: tunnelConnected is now only set true when
  cloudflared logs 'Registered tunnel connection' — never inferred from
  process uptime.

- Add four independent health dimensions:
    cloudflaredRunning  — child process alive/exited
    tunnelConnected     — actual cloudflared connection registered
    originHealthy       — http://127.0.0.1:18484/ reachable (polled every 20s)
    publicEndpointStatus — diagnostic only, never triggers restart

- Add checkOriginHealth(): polls 127.0.0.1:18484 with explicit IPv4
  (family:4) to avoid Windows localhost/IPv6 ambiguity.

- Restart cloudflared ONLY on process exit. A public 502/503/504/530
  is never a restart trigger.

- Add 90s startup connection timeout: logs 'degraded' warning with full
  diagnostics if no connection registered, but does NOT kill the process.

- Improve structured [Tunnel] logging: clearly distinguishes origin,
  process, connection, and public endpoint health in every log line.

- Token: never log the full value; log first 6 chars only as proof of
  configuration. Fail fast with clear error if token is missing.

- Duplicate process guard: explicit log when skipping a second spawn.

- start_tunnel.bat: add prominent warning not to run while backend is
  active; suppress token echo; add --no-autoupdate flag.
2026-09-01 16:49:10 +08:00
ssbg04 fb99ed0fb8 refactor(backend): clean cloudflare tunnel service and sanitize batch runner 2026-09-01 06:14:43 +08:00
ssbg04 7ffd5e2ae0 fix: embed email logo as CID attachment to fix broken icons in Gmail/Outlook 2026-09-01 01:26:51 +08:00
ssbg04 8fa366a61d feat: auto-detect 502 Bad Gateway on tunnel URL and self-heal 2026-09-01 01:18:07 +08:00
ssbg04 91a6383da7 fix: nodemon restart causes zombie cloudflared processes on Windows 2026-09-01 01:07:23 +08:00
ssbg04 e6a0a69271 feat: redesign email templates with school logo, digit OTP boxes, table layout 2026-09-01 00:58:35 +08:00
ssbg04 3dcad8878b fix: tunnel stability, NSSM service installer, cleanup old service scripts 2026-09-01 00:49:23 +08:00
ssbg04 906df823b5 fix(tunnel): add fix_conflicting_tunnel_service.bat to remove conflicting background service 2026-09-01 00:15:29 +08:00
ssbg04 c4bd6b0f4e feat(users): prioritize public domain and tunnel for email reset links over LAN IP 2026-09-01 00:12:13 +08:00
ssbg04 02645de474 fix(tunnel): synchronize valid tunnel token in start_tunnel.bat 2026-09-01 00:07:07 +08:00
ssbg04 0f0c73f8f4 fix(tunnel): ensure immediate tunnel activation and restore connected status log 2026-08-31 23:59:14 +08:00
ssbg04 7c4bff2191 fix(tunnel): stabilize cloudflared on Windows with QUIC protocol, non-blocking process management, and resilient connectivity checks 2026-08-31 23:54:57 +08:00
ssbg04 8c82db71e7 fix(tunnel): resolve Windows tunnel protocol delay and sync start_tunnel token 2026-08-31 23:48:22 +08:00
ssbg04 76a3bc6916 fix(tunnel): fix tunneling stalling/failing when connected to LAN 2026-08-31 23:44:41 +08:00
ssbg04 4f28947ca2 feat(users): prioritize public tunnel URL for reset email links 2026-08-31 23:34:35 +08:00
ssbg04 351b50f3cf feat(users): auto-detect Cloudflare Tunnel vs LAN IP for password reset link 2026-08-31 23:28:55 +08:00
ssbg04 661ba275c2 feat(users): implement admin email password reset with expiration, web reset form, and push notifications 2026-08-31 23:25:46 +08:00
ssbg04 21af7198ae feat(students): add guidance notes on enrollment downgrading in edit student modal 2026-08-31 23:14:31 +08:00
ssbg04 12209a79a6 style(dashboard): add theme-responsive border to Document Breakdown pie card 2026-08-31 23:07:05 +08:00
ssbg04 d779fc02db style(dashboard): add theme-responsive borders to cards, lists, and charts 2026-08-31 23:05:39 +08:00
ssbg04 69933f60bc feat(navigation): integrate search, notifications, and profile menu into Android AppBar for Dashboard 2026-08-31 22:54:33 +08:00
ssbg04 8ae1ab6f0b feat(navigation): integrate student search, multi-select, and filter actions into Android AppBar header 2026-08-31 22:51:06 +08:00
ssbg04 3073790f56 style(documents): make StudentArchivesModal file details and DocumentPropertiesDialog fully responsive for all screen sizes 2026-08-31 22:42:06 +08:00
ssbg04 8b677a9ec0 feat(students): move previous/next navigation to footer and use flat borderless text buttons for Edit/Delete in StudentProfileModal 2026-08-31 22:36:18 +08:00
ssbg04 611c78c3a7 feat(students): enable FAB on student screen and replace modal FAB with pinned footer action buttons in StudentProfileModal 2026-08-31 22:34:01 +08:00
ssbg04 2a54abc349 style(reports): increase spacing around Section 3 4Ps Beneficiaries and standardize table dashes in PDF export 2026-08-31 22:29:45 +08:00
ssbg04 ce2a1c4464 feat(reports): embed official Seal_DepEd.png, DepEd_Quezon.png, and QA.png in Transparency Board PDF 2026-08-31 22:22:27 +08:00
ssbg04 8b487b6248 feat(students): standardize on standard dash '-' for non-4Ps, grade-section, and profile fallbacks 2026-08-31 22:16:13 +08:00
ssbg04 b8294fe576 feat(reports): apply official DepEd Quezon template header and Talisay Integrated School, Tiaong, Quezon footer to PDF export 2026-08-31 22:07:52 +08:00
ssbg04 934bdae8a1 feat(reports): implement DepEd Transparency Board PDF export with live in-app preview 2026-08-31 21:56:13 +08:00
ssbg04 c41ecb0a40 feat(filters): sort academic year filters descending with latest year first 2026-08-31 21:49:43 +08:00
ssbg04 7eb02e3044 feat(filters): sort academic year filters ascending chronologically 2026-08-31 21:45:53 +08:00
ssbg04 2360fcddc4 fix(reports): remove icon clutter, streamline remark badges and 4Ps subtitle responsiveness 2026-08-31 21:41:00 +08:00
ssbg04 4236986d4e feat(reports): convert 4Ps beneficiaries to comparative previous vs active year table with JHS/SHS separation and remarks 2026-08-31 21:38:47 +08:00
ssbg04 36bd41dea2 feat(reports): remove Access/Equity tags, add Data on Enrollment comparison table with JHS/SHS separation and remarks 2026-08-31 21:31:05 +08:00
ssbg04 da5f5a7b54 feat(frontend): apply dark theme to file save & report export preview dialog 2026-08-31 20:45:47 +08:00
ssbg04 c58e64550a fix(frontend): resolve RenderFlex unbounded constraints on mobile in reports segmented pill nav 2026-08-31 20:41:52 +08:00
ssbg04 5430dbcb63 feat: resolve highest grade level, prevent enrollment downgrades, format academic year XXXX-XXXX, and add Windows form margins 2026-08-31 20:35:09 +08:00
ssbg04 707082c6dd feat(reports): add inactive status to yearly comparison, fix Windows layout overflow, and remove storage chip 2026-08-30 20:45:33 +08:00
ssbg04 74919a1ab1 feat: convert student table headers to click-to-sort and keep status dropdown 2026-08-30 20:29:47 +08:00
ssbg04 4405a45fe9 refactor: clean up and restructure compliance and analytics report screen 2026-08-30 20:24:02 +08:00
ssbg04 3dc10845e0 feat: add academic year filter to DepEd transparency board and update dialog title 2026-08-30 20:16:47 +08:00
ssbg04 a0ae156733 feat(reports): make horizontal graph scrollbars permanently visible, hide vertical scrollbar, and allow teacher document uploads 2026-08-30 15:21:01 +08:00
ssbg04 175b959eac fix(system): enhance student folders scoping, modal responsive sizing, reports scrollbar and clean password reset requests 2026-08-30 15:06:18 +08:00
ssbg04 f2c259891d feat: show missing requirements on dashboard student hover, fix student grade/sec resolution, and scope teacher stats to assigned sections 2026-08-30 14:05:10 +08:00
ssbg04 42d97dd32a feat: add animated LRN copy button, restore teacher upload, single-word dialog titles, and system-wide admin stats 2026-08-30 13:45:22 +08:00
ssbg04 a012bda370 feat: align dashboard metrics and scope student roster to active academic year 2026-08-30 13:21:53 +08:00
ssbg04 3d0708dc7a feat: improve activity logs readability, hide deactivated teachers in setup, fix modal sizing and tabs 2026-08-30 11:26:47 +08:00
ssbg04 b04433019c feat: make document breakdown responsive with sequential auto-rotation and add student rankings modal 2026-08-30 10:58:46 +08:00
ssbg04 472714aa2b fix(dashboard): calculate digitalization progress based on all mandatory requirements completed 2026-08-30 10:01:50 +08:00
ssbg04 cedf7f3d00 feat(dashboard): active academic year KPI overhaul, document duplicate prevention, suffix cleanup, and JHS/SHS filter separation 2026-08-30 02:18:43 +08:00
ssbg04 3a91bfa750 feat(network): prioritize LAN connection with Tunnel fallback, add Disconnected dialog and Still Not Connected retry flow 2026-08-30 01:10:42 +08:00
ssbg04 95671e39b7 fix(auth): revoke JWT on user deactivation, unregister FCM token on logout, and purge tokens on password reset 2026-08-30 00:49:08 +08:00
ssbg04 ee47158e74 feat(auth): add self-service email OTP password reset with Nodemailer and Gmail SMTP 2026-08-30 00:29:36 +08:00
ssbg04 4b552dbdbd feat(ui): add thin horizontal scrollbar to users screen filter and update scrollbar palette to soft charcoal/off-white 2026-08-29 23:16:08 +08:00
ssbg04 1ac86de43d feat(reports,students): theme-responsive tooltips, thin scrollbars and scroll hints for mobile 2026-08-29 23:09:20 +08:00
ssbg04 3da79ec92f feat(reports): enhance compliance table tooltips, yearly comparison filtering, and horizontal scrolling 2026-08-29 22:44:04 +08:00
ssbg04 ae2cfe1495 feat: hide archived documents from active lists and exclude from requirement counts 2026-08-29 10:42:06 +08:00
ssbg04 2edca09efb feat: teacher dashboard KPI filtering and dark theme colors for no assigned sections 2026-08-27 22:36:31 +08:00
ssbg04 4d585f3a6d feat: student profile FAB, recycle bin requirements count fix, and persistent sticky table header 2026-08-27 22:28:16 +08:00
ssbg04 3e0af305e2 feat: user management action buttons, activate/deactivate confirmation, and custom dialog sound effects 2026-08-27 22:11:58 +08:00
ssbg04 b3290b43a0 fix bug enrollment in bulk add 2026-08-24 03:33:24 +08:00
ssbg04 8483e9c31d feat(notifications): scope teacher notifications to assigned sections 2026-08-24 03:07:11 +08:00
ssbg04 559e5f440a fix tunnel crash in ubuntu 2026-08-24 00:25:49 +08:00
ssbg04 2465e04f28 update env path 2026-08-24 00:16:25 +08:00
ssbg04 243a431966 new tunnel token 2026-08-24 00:01:46 +08:00
ssbg04 ff03b30c02 fix: force cloudflared to use http2 to resolve UDP/QUIC connectivity issues 2026-08-23 23:37:55 +08:00
ssbg04 79cc152f33 fix: ensure cloudflared binary is executable on Linux/macOS 2026-08-23 23:32:53 +08:00
ssbg04 bbfd02216e chore: update VPS IP address to 198.252.101.35 2026-08-23 23:16:11 +08:00
ssbg04 1133c8fdf3 chore: update Android launcher icon and remove native splash screen 2026-08-23 22:48:04 +08:00
ssbg04 e933e9a8f4 fix: auto-refresh settings, academic year, and auto-graduation on screen access 2026-08-23 03:28:26 +08:00
ssbg04 c81e664cbf feat: add bottom padding to users mobile list for FAB clearance 2026-08-23 03:12:00 +08:00
ssbg04 60f92b1c7a feat: reorder student filter icon and update doc status filter options 2026-08-23 01:38:01 +08:00
ssbg04 0806a78b3b fix: server-side doc status attention sorting and update labels 2026-08-23 01:11:14 +08:00
ssbg04 89d55da737 feat: add adaptive system navigation bar colors for Android app 2026-08-23 00:47:21 +08:00
ssbg04 c6384642c2 refactor: update student folders UI in documents and archives screen 2026-08-23 00:35:39 +08:00
ssbg04 1361fe753f fix(mobile): restrict multi-select icon and more options to desktop only, keeping FAB on Android 2026-08-23 00:24:40 +08:00
ssbg04 61a1135750 feat(documents): add accurate file size handling and uploader metadata to properties dialog 2026-08-23 00:18:14 +08:00
ssbg04 7f069e0663 feat(documents): enhance document and archive screens ui and add properties modal 2026-08-23 00:08:04 +08:00
ssbg04 9129a3c0ff fix(ui): resolve search history hover RenderFollowerLayer transform exception on Windows desktop 2026-08-22 23:26:25 +08:00
ssbg04 631134f822 feat(students): update bulk add DOB & LRN inputs from Add Student screen and restyle multi-select icon 2026-08-22 23:17:32 +08:00
ssbg04 6e1f001fd3 fix(ui): adjust bottom spacing dynamically for opened folder tables and student screen based on pagination presence 2026-08-22 22:58:10 +08:00
ssbg04 09fcd479f4 feat(ui): implement vertical Speed Dial expandable FAB menu for Documents and Archives screens on Android 2026-08-22 22:49:11 +08:00
ssbg04 c6bd932c43 fix(ui): unify table bottom spacing and pagination across Documents and Archives screens from Student screen 2026-08-22 22:23:43 +08:00
ssbg04 5c9f959a2f fix(ui): adjust bottom spacing dynamically for opened student folder table based on pagination presence 2026-08-22 21:53:08 +08:00
ssbg04 a66a5bd210 fix(ui): eliminate edge blur bleed in abstract background for edge-to-edge coverage 2026-08-22 21:42:11 +08:00
ssbg04 2e25b78c52 fix(ui): add backdrop overlay mini blur, search bar margins, and lock backdrop positioning on search active 2026-08-22 20:22:40 +08:00
ssbg04 6b35dae427 feat(ui): add LRN preset, format selection dialog for scanning, and equalize button sizes 2026-08-22 10:33:51 +08:00
ssbg04 8ec1b68789 feat: make student enrollment selection realtime and improve enrollment modal button layout 2026-08-22 10:18:14 +08:00
ssbg04 a141b67612 feat: enhance document file icons by extension and update academic setup 2026-08-22 09:32:14 +08:00
ssbg04 c97eb0e2c1 feat(frontend): apply dark theme responsive & multi-select actions to recycle bin modal 2026-08-22 09:02:37 +08:00
ssbg04 27ec086389 feat: align table designs, place all pagination outside tables, and add bottom spacing for opened student folders 2026-08-21 16:37:33 +08:00
ssbg04 77d3af8aee feat: show convert to pdf menu item for excel files in documents mobile row, context menu and archives list 2026-08-21 16:12:03 +08:00
ssbg04 e100d6bdf4 feat: add headless LibreOffice Excel to PDF conversion for Windows and Android 2026-08-21 15:58:43 +08:00
ssbg04 7517d81489 fix(frontend): responsive name fields, fix edit student layout crash, and enhance themed pagination 2026-08-19 20:24:10 +08:00
ssbg04 b6f202e72f feat: overhaul dashboard logs, student screen name layouts, scanner cancellation, and unified error states 2026-08-19 19:54:28 +08:00
ssbg04 dff12b31b9 chore(assets): remove unused and obsolete image files 2026-08-19 01:25:23 +08:00
ssbg04 5740ca170e docs: update INSTALL.md with universal APK and 16.7MB Windows setup; clean pubspec assets 2026-08-19 01:25:01 +08:00
ssbg04 eddb5da323 feat(installer): optimize Windows installer size to 20.6MB and include automated .NET silent installer 2026-08-19 01:18:47 +08:00
ssbg04 d9ce190ead docs: update INSTALL.md and TIS_RMS_Client.iss for Windows release and Inno Setup configuration 2026-08-19 01:06:27 +08:00
ssbg04 4d969ba0a5 chore: sync documentation and splash screen improvements 2026-08-19 00:59:35 +08:00
ssbg04 e60e671171 fix(ui): apply universal AppButtonLoader with zero-padding to eliminate oblong stretching and 2px overflow 2026-08-19 00:56:38 +08:00
ssbg04 8eadef3f92 fix(ui): ensure all button loading indicators render as perfect circles with SizedBox.square and Center 2026-08-19 00:45:29 +08:00
ssbg04 d863b18f3f feat(login): add frosted glass blur and translucent animated backdrop to login section 2026-08-19 00:34:55 +08:00
ssbg04 6b3152a50c feat(splash): add adaptive backdrop and entertaining loading words to splash animation 2026-08-19 00:31:10 +08:00
ssbg04 acdc9c04a2 feat(ui): apply Talisay Integrated School custom backdrop-2 for landscape/portrait and light/dark modes 2026-08-19 00:20:13 +08:00
ssbg04 67ea480a14 feat(ui): add adaptive backdrop assets and support for light/dark mode and landscape/portrait orientation 2026-08-19 00:09:19 +08:00
ssbg04 9d7609cf36 feat(reports): make secondary status cards (active students to 4Ps) render 6 horizontal in windows desktop app 2026-08-18 23:49:55 +08:00
ssbg04 03bcde4805 fix(students): initialize default grade level to 7 and ensure immediate section selection in add student modal 2026-08-18 23:37:21 +08:00
ssbg04 bec1e4fba7 feat(ui): update recent activities, student modals, documents filters, and settings theme button 2026-08-18 23:13:08 +08:00
ssbg04 41d5fb3459 feat(release): add flutter build rule, INSTALL.md, and Inno Setup Windows installer script 2026-08-18 21:54:37 +08:00
ssbg04 5b0b2db0cb fix(dashboard): fix 1px card overflow on Android and optimize stat card flex layout 2026-08-18 21:27:03 +08:00
ssbg04 e87a771a02 feat(network): scan LAN first with tunnel domain fallback (https://tis-rms.cc.cd/api) and preserve credentials on failure 2026-08-18 21:13:58 +08:00
ssbg04 c8f215eaaf fix(transparency): add extra top margin, top titles padding, and expanded leftTitles for enrollment bar chart 2026-08-18 21:02:21 +08:00
ssbg04 8aaefb2aa4 feat(reports): add inactive card, update student status breakdown donut, and fix summary banner width 2026-08-18 21:00:19 +08:00
ssbg04 2e370a5faf fix(reports): enhance bar chart margin, touch tooltips, and make card rates bigger and responsive 2026-08-18 20:49:30 +08:00
ssbg04 305ce1a64f fix: reset compliance pagination rows per page to 10 on reopen and view switch 2026-08-18 20:41:26 +08:00
ssbg04 27a92e1614 fix: type casting on enrollment reduce in edit student modal 2026-08-18 20:34:19 +08:00
ssbg04 8eda711bce fix: hide graduated status for ineligible students and avoid false update success message 2026-08-18 20:32:05 +08:00
ssbg04 967d37161d fix: graduation status validation check across enrollment history and explicit rejection handling 2026-08-18 20:28:34 +08:00
ssbg04 6070ffce0d fix: single OCR modal popup on add student, sync status revert on invalid graduation, clean transparency titles 1-3, and student list rows per page dropdown with clean legend 2026-08-18 20:20:37 +08:00
ssbg04 e538710a34 fix(dashboard): only count active users (is_active = 1) in admin dashboard stats 2026-08-18 20:08:02 +08:00
ssbg04 171afbd72b fix(reports): require all mandatory documents across all enrolled school tiers (JHS/SHS) for student compliance 2026-08-18 20:03:45 +08:00
ssbg04 255fe61811 fix(reports): enforce mandatory docs filter and teacher scoping in stats API; add color guide tips banner on top of KPI cards 2026-08-18 19:58:56 +08:00
ssbg04 7084d5ab65 fix(syntax): add missing closing brace in updateStudent 2026-08-18 19:49:47 +08:00
ssbg04 e707842a5f fix: single enrollment on OCR student add, strict Graduated status validation, transparency bar graph spacing/mobile scroll, and desaturated dark mode status colors 2026-08-18 19:48:03 +08:00
ssbg04 bc02d86a17 fix(enrollment): strictly prevent duplicate enrollments in the same academic year 2026-08-18 19:23:55 +08:00
ssbg04 4df929e1df fix(students): use array-style params for better-sqlite3 compatibility 2026-08-18 19:16:28 +08:00
ssbg04 497a38d405 fix: use COUNT DISTINCT for enrollment/4Ps counts, fix student year filter param ordering 2026-08-18 19:12:30 +08:00
ssbg04 1220b79222 fix(students): year filter now joins on enrollment IN that year, not globally latest — fixes missing students when filtering by school year 2026-08-18 19:04:07 +08:00
ssbg04 a0ea82d7db reports: remove tabs from transparency board, stack sections vertically, make tables responsive, simplify compliance & analytics language 2026-08-18 18:56:21 +08:00
ssbg04 7ff1d2aaa4 docs: mark android app push notification task as completed 2026-08-18 18:40:22 +08:00
ssbg04 b4b4df9d1f feat(tunnel): configure Cloudflare tunnel runner script with token 2026-08-18 18:24:37 +08:00
ssbg04 14951a31a7 feat(tunnel): replace localtunnel with Cloudflare Tunnel (zero sign-in, fast edge routing) 2026-08-18 17:18:17 +08:00
ssbg04 5f1a2789a4 feat(backend): add localtunnel dependency and start_tunnel script 2026-08-18 17:05:17 +08:00
ssbg04 e8af7c6a2a feat(network): add Bypass-Tunnel-Reminder header to support localtunnel / tunnel proxy endpoints 2026-08-18 17:03:26 +08:00
ssbg04 dd2d51e1ce fix(notifications): prevent duplicate pop-up banner when launching app from background notification 2026-08-18 16:50:17 +08:00
ssbg04 33fd2d32a9 fix(windows): guard NotificationService against flutter_local_notifications LateInitializationError on Windows 2026-08-18 16:26:32 +08:00
ssbg04 94ef3ddb9f fix(flutter): guard FirebaseMessaging against LateInitializationError with Firebase.apps.isEmpty check 2026-08-18 16:23:56 +08:00
ssbg04 f5f29c95d8 fix(flutter): lazy-access FirebaseMessaging only on mobile to prevent LateInitializationError on Windows desktop 2026-08-18 16:21:34 +08:00
ssbg04 93c737e526 chore(windows): silence CMake deprecation warning by setting CMAKE_POLICY_VERSION_MINIMUM to 3.10 2026-08-18 16:19:31 +08:00
ssbg04 26a5d4be60 fix(notifications): remove redundant foreground alerts, remove persistent sync notification, and synchronize LAN/FCM deduplication 2026-08-18 16:17:09 +08:00
ssbg04 1a063ab5c2 chore: remove backend/uploads/ from git tracking 2026-08-18 16:07:11 +08:00
ssbg04 be8a58b178 chore: remove backend/data/ from git tracking and ignore entire folder 2026-08-18 16:05:16 +08:00
ssbg04 030322d496 chore: untrack google-services.json from git and add to gitignore 2026-08-18 16:03:55 +08:00
ssbg04 5ba89a5fc1 docs: update AGENTS.md and gitignore 2026-08-18 16:02:07 +08:00
ssbg04 746f65338b fix(windows): resolve CMake compatibility and PDB locking errors for Windows desktop build 2026-08-18 15:47:02 +08:00
ssbg04 0b121dd984 feat(fcm): real-time audience-targeted high priority push notifications 2026-08-18 15:37:29 +08:00
ssbg04 0c859d7142 feat(fcm): support firebase-admin v14 modular API and google-services integration 2026-08-18 15:35:47 +08:00
ssbg04 0725f4ecf9 feat(flutter): FCM real-time push notifications
- firebase_core + firebase_messaging added to pubspec.yaml
- google-services gradle plugin wired in settings.gradle.kts + app/build.gradle.kts
- fcm_service.dart: initialize FCM, handle foreground messages, register token with backend
- main.dart: Firebase.initializeApp (graceful no-op until google-services.json added)
- auth_provider.dart: FcmService.registerToken() on login and auto-login
- AndroidManifest.xml: FCM default notification channel metadata
2026-08-18 13:56:29 +08:00
ssbg04 cb484051e3 feat(backend): FCM push notifications via firebase-admin
- fcmService.js: Firebase Admin SDK wrapper with sendToUser/sendToAll
- notificationController: hook FCM fire-and-forget in createNotification
- registerFcmToken endpoint: POST /api/notifications/fcm-token
- schema.js: add fcm_tokens table
- routes/notifications.js: register /fcm-token route
2026-08-18 13:53:00 +08:00
ssbg04 e94eaa63bf fix(android): remove automatic battery optimization ignore request 2026-08-18 13:45:46 +08:00
ssbg04 fa8b101b34 fix(android): guard setExactAndAllowWhileIdle with canScheduleExactAlarms() check
SecurityException crash on Android 12+ when SCHEDULE_EXACT_ALARM special
access not user-granted. Now checks canScheduleExactAlarms() and falls back
to setAndAllowWhileIdle() (inexact, no special permission needed). Both
schedule() and cancel() wrapped in try/catch to prevent future alarm crashes.
2026-08-18 13:37:05 +08:00
ssbg04 281a782b93 fix(android): remove startForegroundService from BootReceiver (crash on boot)
ForegroundService requires a live Flutter engine and cannot be cold-started
from a BroadcastReceiver. flutter_foreground_task handles boot-restart
internally via autoRunOnBoot=true. BootReceiver now only reschedules
the AlarmManager chain.
2026-08-18 13:25:49 +08:00
ssbg04 648280ab45 fix(android): add AlarmManager 2-min background notification polling
- AlarmScheduler.kt: schedules setExactAndAllowWhileIdle every 2 min
- AlarmReceiver.kt: reads FlutterSharedPreferences, polls LAN backend, posts native notifications, self-chains next alarm
- MainActivity.kt: schedule alarm on app open
- BootReceiver.kt: schedule alarm on device reboot
- AndroidManifest.xml: register AlarmReceiver
2026-08-18 13:19:11 +08:00
ssbg04 4306a19d7b fix(android): add BootReceiver.kt (force-add past gitignore) 2026-08-18 13:03:02 +08:00
ssbg04 1f02eb1f26 fix(android): enable push notifications in background/killed state 2026-08-18 13:02:37 +08:00
ssbg04 9aa6e6df2b feat: add dynamic hardware vibration patterns and fix realtime reports compliance data 2026-08-18 12:32:36 +08:00
ssbg04 4487f2872f feat: implement Android Foreground Service for real-time LAN background notifications 2026-08-18 11:29:24 +08:00
ssbg04 f4cb43b29f style: remove background and border from Archives filter button 2026-08-18 10:45:34 +08:00
ssbg04 078c395126 feat: enhance Settings suffix dropdown, Users modal stepper and email autocomplete, and Archives header options 2026-08-18 10:43:09 +08:00
ssbg04 db42cf3050 fix: resolve Sheetifye Excel viewer integration, Riverpod autoDispose type bounds, and Dashboard initState error 2026-08-18 10:30:34 +08:00
ssbg04 93f419f6d1 feat(archives): add context menus, multi-select operations, print list controls, and fix grid view 2026-08-18 10:08:48 +08:00
ssbg04 9e4ed42806 feat(ui): fullscreen preview modal, button refinements, and notification slide fix 2026-08-18 09:46:43 +08:00
ssbg04 006587cb23 feat(archives): convert status dropdown to chips and modernize filter modal 2026-08-17 00:10:03 +08:00
ssbg04 74f4dfff33 fix(documents): responsive filter modal with scrollable section and fixed buttons 2026-08-16 23:23:08 +08:00
ssbg04 14607c15c4 fix(students): dynamic database filter options with cascading resets and clean dropdowns 2026-08-16 23:16:12 +08:00
ssbg04 a7f0b6a66a feat(students): dynamic database filter options with cascading resets and clean dropdowns 2026-08-16 02:16:44 +08:00
ssbg04 57e0c201f8 feat: scope dashboard KPIs to teacher students and restrict teacher actions on documents screen 2026-08-16 02:05:01 +08:00
ssbg04 b57bcffa41 update: 4Ps default color 2026-08-16 01:44:00 +08:00
ssbg04 65c989d747 fix: enrollments tab in student screen dropdowns 2026-08-16 01:05:03 +08:00
ssbg04 314337c443 fix: add ctrl+f search shortcut and format search tooltips with italicized hotkeys across dashboard, students, documents, archives, and users screens 2026-08-14 00:14:33 +08:00
ssbg04 77ee87a6f8 fix(ui): isolate drag and drop bulk import to students screen and update users screen filter header style 2026-08-13 14:32:45 +08:00
ssbg04 98495e91eb fix(ocr): improve SF9 OCR parser with orientation fallback and update settings screen edge fade 2026-08-13 12:14:50 +08:00
ssbg04 e5b02614cc fix: resolve Excel extraction for JHS and SHS SF10 form parsing 2026-08-12 17:19:57 +08:00
ssbg04 acba1b4eb0 fix: resolve OCR parser middle name and extension extraction for bulk add and add student 2026-08-12 16:35:25 +08:00
ssbg04 01605bc8fd fix: remove duplicate variable declaration in documents_screen.dart 2026-08-12 14:03:39 +08:00
ssbg04 1169347893 style: enhance chip text colors contrast and saturation in dark theme 2026-08-12 14:02:23 +08:00
ssbg04 38a0920c05 fix(backend): enhance OCR name parser for compound first names and identical first/last names 2026-08-12 10:10:03 +08:00
ssbg04 214b25092c fix(backend): replace xlsx with exceljs and fix security audit vulnerabilities 2026-08-12 09:24:44 +08:00
ssbg04 e268569eac fix: dark theme color palatte 2026-08-08 23:46:26 +08:00
ssbg04 b5e651dfb2 feat(frontend): complete phase 5 UX polish, skeleton loaders, and accessibility labels 2026-08-08 23:22:39 +08:00
ssbg04 ff00bc4e1b fix(frontend): resolve bracket matching error in documents_screen RefreshIndicator 2026-08-08 23:08:22 +08:00
ssbg04 5a0eb25ad2 feat(frontend): complete phase 4 platform-specific optimizations and accessibility updates 2026-08-08 23:06:48 +08:00
ssbg04 c98fbb6a6e feat(frontend): complete phase 3 screen decomposition and phase 4 platform optimizations 2026-08-08 22:42:40 +08:00
ssbg04 6f2d8865c9 feat(frontend): extract shared UI components and complete SnackBar to dialog migration 2026-08-08 21:51:03 +08:00
ssbg04 2eeb1a153f feat(frontend): implement phase 1 foundation design improvements and theme extensions 2026-08-08 21:25:09 +08:00
ssbg04 911c03d61e feat(frontend): multi/single OCR enrollment review and upload for add student screen 2026-08-08 01:45:28 +08:00
ssbg04 88c470d924 feat(frontend): apply dark theme colors across all reports screen widgets 2026-08-08 01:32:21 +08:00
ssbg04 a723eed805 fix(frontend): drag and drop now works on students and documents screens
- documents: drops accepted on all tabs and inside student folders (was gated to All Documents tab), overlay hint shows anywhere, route guard prevents double modals
- students: added drop zone that opens Bulk OCR Import with the dropped files preloaded
- bulk import dialog: accepts preloadedFiles to seed the queue on open
2026-08-08 01:21:06 +08:00
ssbg04 c5998468b7 feat: archives file menu, copy (1) naming, excel to pdf, and OCR enrollment validation
- archives: per-file menu (preview/print/download) via icon, right-click, long-press
- documents: copy renames to "name (1).ext" with incrementing collision handling
- documents: convert xls/xlsx/csv to multi-sheet PDF saved in student folder (excel kept)
- students: OCR enrollment scan is review-only with 7-12 grade gate and manual validation modal
- add student: linear OCR progress, SF9/SF10 grade 7-12 validation with auto-fill enrollment
2026-08-08 00:57:04 +08:00
ssbg04 2ac3a791ce fix(frontend): improve custom back-button navigation for documents, students, and archives screens 2026-08-07 21:31:54 +08:00
ssbg04 2e182f8301 feat(frontend): add back button prevention and update student multi-select menu design 2026-08-07 20:59:00 +08:00
ssbg04 ec50f31b21 feat(students): update multi select menu design to match document screen 2026-08-07 20:47:02 +08:00
ssbg04 e68ae49f28 feat(frontend): update dashboard, document, student, archives, and users screens per AGENTS.md 2026-08-07 20:36:55 +08:00
ssbg04 8e32bbe5e1 feat(frontend): green window title bar, login pre-fill, monochrome multi-select, responsive enrollments, and 15 limit archive pagination 2026-08-07 19:57:30 +08:00
ssbg04 a03f85604e feat(students/documents): add ocr scan, enrollment log format, select all, and dark theme updates 2026-08-07 18:15:48 +08:00
ssbg04 4c69b0f063 feat(frontend): apply dark theme to documents, archives, and reports screens 2026-08-07 16:25:01 +08:00
ssbg04 db463ddb6d fix and add student screen dark theme 2026-08-06 21:22:53 +08:00
ssbg04 82114783f5 fix(students): restore container child and missing parenthesis in mobile list view 2026-08-06 20:07:23 +08:00
ssbg04 5578572709 fix(settings): remove obsolete showJhs and showShs references from requirements settings 2026-08-06 20:06:16 +08:00
ssbg04 9b8da635f8 feat(archives): apply dark mode styling to archives screen 2026-08-06 20:04:48 +08:00
ssbg04 2930f49a34 feat(documents): apply dark mode styling to documents screen and cards 2026-08-06 20:03:50 +08:00
ssbg04 28a19f13fa feat(students): apply dark mode styling to student screen, modal and import dialog 2026-08-06 20:02:11 +08:00
ssbg04 e7fa72eaec settings: remove icon from teachers title header and make tabs label only 2026-08-06 19:58:28 +08:00
ssbg04 919c99f9a9 settings: document requirements tabs, remove filter, and move multi-select 2026-08-06 19:57:47 +08:00
ssbg04 98e25c42ce dashboard: apply dark mode to search, recent activities, and details modal 2026-08-06 19:55:59 +08:00
ssbg04 4a228c2cef fix(frontend): add missing dart:ui import in settings_screen.dart 2026-08-05 19:45:36 +08:00
ssbg04 87a711be95 fix(frontend): add progressive top and bottom glass blur overlays to Users screen list content 2026-08-05 19:35:06 +08:00
ssbg04 e96174fd3f fix(frontend): make Android AppBar title and hamburger icon color theme-mode responsive 2026-08-05 19:30:24 +08:00
ssbg04 f52485043b fix(frontend): update search feature on Users screen to pop dialog on submit, turn search icon into X, and improve dark mode font readability 2026-08-05 19:26:54 +08:00
ssbg04 0ea61177aa fix(frontend): implement dark mode support on Users screen, including details dialog, add/edit modal, search icon, and pagination 2026-08-05 19:14:26 +08:00
ssbg04 246194ef86 fix(frontend): replace hamburger menu inside active android sidebar drawer with a fading left-arrow icon 2026-08-05 18:56:02 +08:00
ssbg04 8597f8e1ec fix(frontend): make skeleton loading colors theme-responsive and align windows loading delay to 100ms 2026-08-05 18:52:36 +08:00
ssbg04 7e4641182a fix(frontend): update login screen layout animation triggers and top-align form to prevent huge top padding 2026-08-05 18:46:56 +08:00
ssbg04 6af6c58405 fix(frontend): restore dark green background on outer section of login screen 2026-08-05 18:42:01 +08:00
ssbg04 c59cc5d1f7 fix(frontend): implement server IP modal activation via 3 logo taps and dark mode support on login screen 2026-08-05 18:38:59 +08:00
ssbg04 be2f223439 fix(frontend): smooth out sharp edges of top and bottom blur overlays on dashboard using ShaderMask 2026-08-05 18:34:17 +08:00
ssbg04 e443f8d338 fix(frontend): implement top and bottom progressive gradient blur overlays on dashboard 2026-08-05 18:30:56 +08:00
ssbg04 9637100811 fix(frontend): update windows and android layout theme colors and sticky blur topbar in dashboard 2026-08-05 18:26:39 +08:00
ssbg04 5391488d1d fix(frontend): resolve import and const TextStyle compile errors in dashboard widgets 2026-08-05 17:38:11 +08:00
ssbg04 78ef355ebc fix(frontend): improve readability of Android side navigation header and footer in dark mode 2026-08-05 17:28:16 +08:00
ssbg04 c458d097f5 fix(frontend): adjust settings screen layout styling and refine theme provider 2026-08-05 17:14:43 +08:00
ssbg04 f97100b96c fix(frontend): improve dark mode colors, backdrop and navigation layout support 2026-08-05 17:01:22 +08:00
ssbg04 5f60defa21 change theme mode into light 2026-08-05 00:45:48 +08:00
ssbg04 833bfd98f0 fix(settings): support dark mode surface card colors and dynamic readable text colors 2026-08-05 00:42:15 +08:00
ssbg04 5dd32d9177 feat(theme): add dark mode support with SharedPreferences persistence and settings toggle 2026-08-05 00:37:18 +08:00
ssbg04 d2e9a3a0ed refactor(documents): remove title tab icons and update search icon to clear & refresh on active query 2026-08-05 00:23:53 +08:00
ssbg04 59e1c32b99 fix(students): set width double.infinity on bottom action bar container for full screen responsive background 2026-08-05 00:03:36 +08:00
ssbg04 fba82da792 fix(students): remove duplicate manual input button, simplify keyboard active bottom bar hiding 2026-08-04 23:46:22 +08:00
ssbg04 017675d633 fix(students): suffix number disallow, pure color line stepper, manual input button, and android keyboard hide/unfocus 2026-08-04 23:43:59 +08:00
ssbg04 6ca1552075 fix(students): restore NEXT/BACK navigation and add interactive stepper tapping on add student screen 2026-08-04 23:38:34 +08:00
ssbg04 549f584ce2 fix(students): update modal flows, remove Skip OCR button, fix tab icons 2026-08-04 23:14:58 +08:00
ssbg04 91882683f6 fix: update student modals for responsiveness and details loading 2026-08-03 18:18:03 +08:00
ssbg04 ad95aece48 Refactor: separate add and edit student screens, remove stepper numbers, add reliable OCR next buttons 2026-08-03 15:15:13 +08:00
ssbg04 ab4b020fef fix(students): resolve back button hit test, align next button inline with back, and convert line stepper labels to tooltip hints 2026-08-03 14:56:25 +08:00
ssbg04 36383f3455 fix(students): fix mouse tracker assertion crash on Windows by replacing dynamic ValueKeys and InkWell render box bounds 2026-08-03 14:44:14 +08:00
ssbg04 7e667324b1 fix(students): resolve Windows focus trapping in PopScope and add explicit validation feedback for NEXT button 2026-08-03 14:39:26 +08:00
ssbg04 416a141497 fix(students): align back and next buttons cleanly in bottom action bar after OCR scan 2026-08-03 14:33:24 +08:00
ssbg04 3a9be0537b fix(students): pair BACK and NEXT buttons in line and ensure BACK button is always visible and functional 2026-08-03 12:51:27 +08:00
ssbg04 458b37a8d3 fix(students): add direct proceed to enrollment button on student details step 2026-08-03 12:48:39 +08:00
ssbg04 03dc81259d fix(students): enhance line stepper with titles and navigation and resolve lag in add student modal 2026-08-03 12:46:47 +08:00
ssbg04 eb6382f345 fix(students): fix mouse tracker assertion freeze on Windows in add student modal 2026-08-03 12:44:26 +08:00
ssbg04 dd86db0c30 fix(students): resolve syntax errors and clean up widget structure in add student modal 2026-08-03 12:39:00 +08:00
ssbg04 c4b1f479e0 fix(students): fix manual validation freeze in add student modal and add margins for text fields and buttons 2026-08-03 12:32:16 +08:00
ssbg04 5579352c38 fix(students): fix closing parenthesis syntax in table and card list 2026-08-03 12:06:33 +08:00
ssbg04 b4b101a866 feat(students): implement Windows right-click context menu and remove drag-select 2026-08-03 11:58:26 +08:00
ssbg04 d69763c47e refactor: convert BulkOcrImport and AddEditStudent to full-screen Scaffold
- BulkOcrImportDialog: replaced Dialog with Scaffold+AppBar, added under-header line stepper (3 segments, no icons/labels), LinearProgressIndicator during processing, auto-uppercase for all name fields (OCR result + manual edit)
- AddEditStudentModal: replaced CustomModal with Scaffold+AppBar+PopScope, added line stepper (2 steps for edit, 3 steps for add), replaced old Stepper widget with manual step switching (fixes Windows freeze), added bottom action bar with BACK/NEXT/SAVE/RE-SCAN/VIEW DOC
- students_screen: changed _openModal and _openBulkOcrImport to use Navigator.push(MaterialPageRoute) instead of showDialog
2026-08-03 01:06:17 +08:00
ssbg04 2958d872bb feat(backend): implement name-based anti-duplication for student add and bulk create 2026-08-03 00:20:49 +08:00
ssbg04 4ec0fecb4e Refactor Add Student and Bulk Add into standalone screens with under-header line steppers 2026-08-02 23:50:09 +08:00
ssbg04 3a04e3b6b0 Remove zip and unzip dependencies and logic from backend backup/restore 2026-08-02 23:08:13 +08:00
ssbg04 99168cab9a Complete AGENT.md tasks for backend backup and restore 2026-08-02 22:57:21 +08:00
ssbg04 86fe0c45fc Complete AGENT.md tasks for reports screen 2026-08-02 22:49:48 +08:00
ssbg04 0fb4dfd1d3 Add chips for status filter in document modal and align print button size with upload button on Windows 2026-08-02 01:18:41 +08:00
ssbg04 0e6437d9be Fix dynamic header title on Android when switching tabs in document screen 2026-08-01 23:48:33 +08:00
ssbg04 6693837ab3 Remove inline multi select button and keep it in more menu 2026-08-01 23:44:06 +08:00
ssbg04 58373c3a68 Complete AGENT.md tasks for documents screen 2026-08-01 23:29:01 +08:00
ssbg04 345de6ca4d feat: replace top header inline with multi-select menu when active and restrict drag-and-drop to upload modal 2026-08-01 20:08:48 +08:00
ssbg04 ef40cde503 feat: enhance Print Modal with history tab, student details, spreadsheet error dialog, and refine UI buttons/menus 2026-08-01 17:02:58 +08:00
ssbg04 8cb593cf1f fix: resolve Excel DOB OCR parsing and allow flexible date formats in student modal 2026-08-01 16:03:46 +08:00
ssbg04 f4d5c68481 feat(students): add bulk OCR import for SF9/SF10 and remove DOB suggestion dropdown in add/edit modal 2026-08-01 15:51:03 +08:00
ssbg04 3f1bf688c5 feat(ui): implement tabbed modal for update student record with dedicated enrollment management actions 2026-08-01 14:59:21 +08:00
ssbg04 a9cbd5f23e feat(ui): upgrade AcademicYearFormModal to CustomModal for mobile and Android responsiveness 2026-08-01 14:30:37 +08:00
ssbg04 206f6edb79 feat(ui): fix android doc status sorting, convert filter modal 3-5 items to chips, make academic year modal responsive, and add auto-graduation confirmation dialog 2026-08-01 14:15:03 +08:00
ssbg04 dea1f53a7b fix(ui): make student profile modal and settings screen responsive for Android and narrow screens 2026-08-01 13:44:18 +08:00
ssbg04 7a2cacee15 feat(students): add LRN sort and Doc Status attention selectors in filter modal, uppercase LRN header menu labels, and remove LRN text input from filter modal 2026-08-01 02:57:22 +08:00
ssbg04 c10b87584b feat(students): add LRN filter modal and column title sort dropdowns for LRN, Name, Grade & Sec, and Doc Status 2026-08-01 02:45:04 +08:00
ssbg04 bf1e27426a feat: implement active academic year schedule, auto-graduation service, and settings screen UI updates 2026-08-01 02:29:32 +08:00
ssbg04 f8a4500029 feat: implement enrollment activity log formatting and enrollment auto-update schedule settings 2026-08-01 01:45:10 +08:00
ssbg04 03eda282bf feat: add OCR and Excel SF9/SF10 enrollment auto-update with teacher validation and semester deduplication 2026-08-01 00:25:03 +08:00
ssbg04 f7b3b072a1 fix(frontend): make student filter modal scrollable and compact student profile header on Android 2026-07-31 23:20:00 +08:00
ssbg04 9834ffbec1 fix(frontend): make Transferred status chip fit column width in student screen 2026-07-31 21:59:21 +08:00
ssbg04 bc2c0a79f4 feat: bundle nssm 64-bit binary for automated Windows Service management 2026-07-29 22:53:59 +08:00
ssbg04 cc315f59e1 feat: configure Node.js backend Windows Service scripts (NSSM/SC) 2026-07-29 22:47:01 +08:00
ssbg04 c56ecce50f feat: make DepEd Transparency Board responsive for Android mobile devices 2026-07-29 21:28:32 +08:00
ssbg04 fa887df47a feat: implement DepEd Transparency and School Performance Board 2026-07-29 21:23:50 +08:00
ssbg04 5c9f81f0c9 fix: gate frontend polling timers and intercept unauthenticated requests to prevent 403 errors on login screen 2026-07-28 22:36:16 +08:00
ssbg04 750cfba699 feat: add connected users monitoring and update student modal 2026-07-27 18:52:38 +08:00
ssbg04 34189702d1 feat: implement drag and drop UI for documents upload modal, fix remember me auth persistence on startup, and add robust DOB OCR extraction for SF9 and SF10 2026-07-26 17:21:09 +08:00
ssbg04 63a41b5360 feat: add native Excel/CSV spreadsheet viewer and drag-and-drop OCR support 2026-07-26 17:10:29 +08:00
ssbg04 5d70496138 Fix provider modification during widget build in transfer progress dialog 2026-07-26 16:05:14 +08:00
ssbg04 714c315501 Add transfer progress dialog with ETA, speed, and cancel button, disk space check, and manual restore guide 2026-07-26 16:02:18 +08:00
ssbg04 0c7cf93f03 Fix archiver constructor syntax to new archiver.ZipArchive 2026-07-26 15:39:57 +08:00
ssbg04 cae56e9b16 Use archiver with max DEFLATE compression (level 9) and direct streaming for backups 2026-07-26 15:34:53 +08:00
ssbg04 89827c5558 Restrict Backup & Restore operations to server host machine only (LAN clients blocked) 2026-07-26 15:06:41 +08:00
ssbg04 6e81eb148b Allow up to 2GB backup restore uploads and 2-hour transfer timeout 2026-07-26 14:48:26 +08:00
ssbg04 baec36e84c Fix restore timeout over WAN/VPS and report Nginx 413/timeout status codes 2026-07-26 14:43:52 +08:00
ssbg04 b5a02c6bf0 Fix Windows EPERM permission denied error during backup restore temp folder cleanup 2026-07-26 14:21:51 +08:00
ssbg04 7681aa4c1f Fix stale VPS URL and token expiration when switching servers 2026-07-26 14:10:54 +08:00
ssbg04 9c97d2db8a Fix LoginScreen widget scope and resolve all analyzer errors 2026-07-26 13:51:38 +08:00
ssbg04 5ed9d4c213 Fix local network discovery, add quick VPS/localhost IP switching, and resolve backup & restore issues on all platforms 2026-07-26 13:47:16 +08:00
ssbg04 aebfeca342 feat: enable Windows app internet access and improve server discovery 2026-07-26 13:19:59 +08:00
ssbg04 443a930b57 fix: show drag-drop hint only on All Files tab and inside student folders 2026-07-25 23:17:40 +08:00
ssbg04 4c09591dcb feat: redesign upload modal header with responsive stepper + add drag-drop idle hint banner 2026-07-25 23:15:23 +08:00
ssbg04 516c47fe7a fix: resolve all compile errors in upload_ocr_modal and upload_modal_header 2026-07-25 22:52:09 +08:00
ssbg04 610947d3cd fix: sticky Wolt Modal Header for Upload Documents and restore desktop_drop 2026-07-25 22:48:59 +08:00
ssbg04 793b0b371a fix: drag-and-drop on Windows using super_drag_and_drop and sticky Wolt Modal header 2026-07-25 22:42:17 +08:00
ssbg04 5bf1254148 fix: add Inactive status to students table header dropdown filter 2026-07-25 22:29:53 +08:00
ssbg04 e4843606c1 fix: Windows drag-and-drop via desktop_drop, sticky upload modal header, and Inactive student status 2026-07-25 22:11:12 +08:00
ssbg04 652716de43 feat: implement dashboard KPIs, drag-and-drop upload for documents, and teacher management styling 2026-07-25 18:55:30 +08:00
ssbg04 3e85e5738b Enhance OCR modal and document auto-detection 2026-07-25 01:39:35 +08:00
ssbg04 3ed477327c feat: user status toggle, temp-pw change, multi-file upload
User Management:
- Add is_active column to users table (migration-safe)
- Block deactivated users at login with clear error message
- Toggle Active/Inactive button replaces Delete in detail modal
- Delete button and _DeleteConfirmationDialog fully removed
- Status chip added to desktop table and detail modal
- Temp password changed from random to username+123
- Create user note now shows the exact password format
- Credentials dialog updated with security reminder

Documents:
- Complete multi-file upload redesign
- file_picker allows multiple files (Android + Windows)
- Auto-detect document type from filename similarity
- Per-file manual dropdown override
- Sequential upload with per-file progress bar
- Done/error summary banner
2026-07-25 01:02:49 +08:00
ssbg04 4a27cf4853 fix: use human-readable timestamp for duplicate filename collision guard 2026-07-25 00:45:47 +08:00
ssbg04 2653f892f4 feat: group student uploads under JHS/SHS Documents subfolders 2026-07-25 00:39:27 +08:00
ssbg04 7256b5e757 chore: Ignore runtime backup logs 2026-07-25 00:23:10 +08:00
ssbg04 691d1a61ff feat: Include DB WAL/SHM files and students documents in backup 2026-07-25 00:20:59 +08:00
ssbg04 d71ea2e0bf feat: Add Inactive to student status dropdown 2026-07-24 23:57:29 +08:00
ssbg04 7545664a97 fix: Actually fix string interpolation error in add_edit_student_modal.dart 2026-07-24 23:42:09 +08:00
ssbg04 cedd181730 fix: Resolve dart compiler errors regarding nullable birthDate and string interpolation 2026-07-24 23:39:43 +08:00
ssbg04 1c105fd2ed feat: Make Date of Birth optional and implement custom DobPicker 2026-07-24 23:36:05 +08:00
ssbg04 9988eb8337 feat: Add open folder for inactive students in student screen 2026-07-24 23:19:24 +08:00
ssbg04 1675fe6e2a feat: Add Inactive status handling in archives and complete reenrolled documents 2026-07-24 23:16:38 +08:00
ssbg04 8da6c52ad1 feat(settings): complete profile details UI updates and fixes 2026-07-24 23:05:44 +08:00
ssbg04 f67423ac98 Fix forgot password dialog layout, logic, and backend current password validation 2026-07-24 22:31:59 +08:00
ssbg04 06ff0de6a9 Update login screen layout animations and title 2026-07-24 22:13:49 +08:00
ssbg04 01125760c9 Add easter egg capstone screen 2026-07-24 21:58:29 +08:00
ssbg04 b4f8d1bc17 Hide pagination in students and FAB in users on search 2026-07-24 21:51:26 +08:00
ssbg04 9da5699f15 Refactor Archives and Users screen search UI 2026-07-24 14:14:05 +08:00
ssbg04 ebf58f421e Update search UI on Documents Screen to use a modal dialog 2026-07-24 13:27:33 +08:00
ssbg04 69b83992df Update search UI to use a modal dialog 2026-07-24 13:00:32 +08:00
ssbg04 382d8129c7 fix(ui): layout fixes for documents screen header and mobile rendering 2026-07-23 20:41:54 +08:00
ssbg04 9f3220ccd3 Fix compile errors: Timer import and usersProvider 2026-07-22 23:58:09 +08:00
ssbg04 ddc63ab71e UI Refinements: Student confirm, Setup/Report polling, Backup size 2026-07-22 23:48:47 +08:00
ssbg04 f1a66ff20b Add Storage Used Stat Card & Remove Tab Animation 2026-07-22 23:27:17 +08:00
ssbg04 14eefa1062 Implement Background Sync Polling with Workmanager 2026-07-22 23:18:32 +08:00
ssbg04 8c80bc0033 Fix Android 13+ notification permissions 2026-07-22 23:08:48 +08:00
ssbg04 0ec45caf87 Implement Notification Alerts, Bulk Status UX, and Layout Fixes 2026-07-22 23:00:12 +08:00
ssbg04 fcb6c964a5 Fix Android app layout and splash screen 2026-07-22 22:25:54 +08:00
ssbg04 e14be0098d Fix student status bulk update and delete failing due to missing academic year 2026-07-22 22:20:03 +08:00
ssbg04 a8133e2013 Update frontend UI and backend studentController 2026-07-22 22:11:40 +08:00
ssbg04 72fea0d5aa fix: resolve dashboard stat card background and student search bar cursor issues 2026-07-22 16:10:51 +08:00
ssbg04 b69908fb67 Fix UI issues in Dashboard and Student screens 2026-07-22 16:00:54 +08:00
ssbg04 6b8e1da0fc feat(ui): update dashboard stat cards to one row square for Windows and stacked for Android, fix search bar state initialization and input stay on clear 2026-07-22 15:53:57 +08:00
ssbg04 1fc2242672 feat(ui): update abstract background to smooth gradient, adapt stat cards sizes and platform layout, fix search bar clear button glitch 2026-07-22 15:47:51 +08:00
ssbg04 37d2d5b9a7 meow 2026-07-22 01:09:38 +08:00
ssbg04 81336b3f92 feat: add cross-platform Linux & Windows OS detection for Ghostscript and Tesseract OCR setup 2026-07-22 00:28:41 +08:00
ssbg04 f9706eb740 style: limit abstract background to exactly 3 large solid shapes 2026-07-21 23:57:11 +08:00
ssbg04 9baf3d5b15 style: update abstract background to use large solid shapes with no outlines 2026-07-21 23:56:13 +08:00
ssbg04 b47c7f7b04 fix: make dashboard scaffold transparent so abstract background shapes are visible 2026-07-21 23:54:20 +08:00
ssbg04 afb4946fa9 feat: add responsive abstract background shapes to all screens 2026-07-21 23:51:05 +08:00
ssbg04 9a2386142f style: move dashboard overview to body, fix topbar layout 2026-07-21 23:33:09 +08:00
ssbg04 209fc7c40f style: fix android dashboard title, enlarge icons, remove search delay 2026-07-21 23:29:18 +08:00
ssbg04 e25cf990c8 fix: dashboard right space, search layout and remove search bar animation 2026-07-21 23:23:31 +08:00
ssbg04 31d613fc09 fix: dashboard search UI, student screen UI, reset requests modal timezone 2026-07-21 23:13:05 +08:00
ssbg04 e80d9b7c53 fix: dashboard overflow, search state, and UTC time 2026-07-21 23:05:24 +08:00
ssbg04 4aea12840e feat: dashboard, reset request modal, and student screen updates 2026-07-21 22:57:55 +08:00
ssbg04 db6a5d5dd4 Dynamic search bar width and icons hiding, fix history width 2026-07-21 19:06:53 +08:00
ssbg04 7d001b6911 Refactor filter modals to use ChoiceChips 2026-07-21 18:51:08 +08:00
ssbg04 441e06502c Refactor User History and Recent Activities screens, fix modal 2026-07-21 18:48:30 +08:00
ssbg04 e7651bf153 Add global green safe area for edge-to-edge layouts 2026-07-21 17:47:52 +08:00
ssbg04 9f72e31f20 Fix Android build errors and add native edge-to-edge UI colors 2026-07-21 17:34:08 +08:00
ssbg04 a57c408f2f Fix: render capstone members picture uncropped natively 2026-07-20 01:43:19 +08:00
ssbg04 65272df088 Fix: render full capstone members image without cropping 2026-07-20 01:41:59 +08:00
ssbg04 d6c38c0951 Feat: add roles to capstone members modal 2026-07-20 01:40:23 +08:00
ssbg04 f5d0efa544 Fix: register capstone_members.jpeg in pubspec.yaml and correct path 2026-07-20 01:31:34 +08:00
ssbg04 c8220e28bf Feat: enhance Capstone Members easter egg modal with team picture and responsive layout 2026-07-20 01:28:32 +08:00
ssbg04 a7b05d81e9 Feat: apply Windows-style bright aesthetic to Android navigation layout 2026-07-20 01:23:52 +08:00
ssbg04 b34bbfe96f Feat: add frosted glass blur effect to Dashboard top bar 2026-07-20 01:00:41 +08:00
ssbg04 4b4ed5e742 Feat: automatically collapse search bar when focus is lost 2026-07-20 00:54:01 +08:00
ssbg04 6df0a62b32 Fix: revert collapsed search bar icon to magnifying glass to prevent layout confusion 2026-07-20 00:19:11 +08:00
ssbg04 c7406b7fb1 Feat: add focus backdrop scrim to Dashboard search bar 2026-07-20 00:11:27 +08:00
ssbg04 3f6f1f34a0 Feat: add focus backdrop scrim over data table when search is active 2026-07-20 00:09:29 +08:00
ssbg04 ee69ca48c2 Fix: replace collapsed search icon with X when search is active, hide FAB/pagination while searching 2026-07-20 00:01:52 +08:00
ssbg04 7db281547e Fix: correctly set search history overlay width by using widget.maxWidth directly instead of querying render box during animation 2026-07-19 23:58:00 +08:00
ssbg04 888803e9c0 Fix: sync search bar text across screens and fix overlay history width 2026-07-19 23:47:23 +08:00
ssbg04 22726e82ba Fix: correct syntax error and ensure search bar history overlay TapRegion prevents search bar collapse 2026-07-19 23:40:58 +08:00
ssbg04 13a5c0baef Fix: move ValueKey to Flexible to properly maintain AppSearchBar state 2026-07-19 23:19:36 +08:00
ssbg04 852fafe5e8 Fix: Ensure AppSearchBar maintains state across rebuilds in StudentsScreen 2026-07-19 23:14:06 +08:00
ssbg04 6ea0978f53 UI: Refactor student screen layout (Search bar, FAB, multi-select) 2026-07-19 23:12:55 +08:00
ssbg04 2a2b1ed0a8 Feat: implement notifications, search history, and UI enhancements 2026-07-19 23:04:39 +08:00
ssbg04 b6bbb757f2 UI: update search bar design 2026-07-19 22:46:36 +08:00
ssbg04 3633ad7c81 Fix database backup/restore and update password change UI 2026-07-17 22:06:44 +08:00
ssbg04 320fd87b08 Update backup info and dashboard layout 2026-07-16 03:55:03 +08:00
ssbg04 47b1821b78 feat: UI improvements and bug fixes
- Migrated user management to a 3-step stepper

- Redesigned Reports screen with side-by-side charts and KPIs

- Fixed timezone date formatting in Dashboard Activity logs

- Implemented custom native Android splash screen and removed flutter_native_splash
2026-07-15 03:01:38 +08:00
ssbg04 965a3eadb5 fix(users): Fix success dialog z-order and add step top margin
- Fixed an issue where the success/error dialog was hidden behind the user details modal by displaying it sequentially for updates, and correctly popping root navigator routes.
- Added top margin padding (16px) to the steps within AddEditUserModal for better visual separation.
- Made prefixIcon optional in CustomTextField.
2026-07-15 02:18:10 +08:00
ssbg04 c8780c53db feat(frontend): implement print queue and fix document download issues 2026-07-15 01:22:33 +08:00
ssbg04 fe8f9be585 Add close button to upload modal on Windows 2026-07-15 00:47:06 +08:00
ssbg04 5fef5363fe Migrate UploadOcrModal to WoltModalSheet 2026-07-15 00:44:15 +08:00
ssbg04 ace58784a8 Add warning notice when uploading document outside student directory 2026-07-15 00:31:02 +08:00
ssbg04 16c46d9d8c Fix Stepper UI and hide OCR skip button 2026-07-15 00:28:32 +08:00
ssbg04 9a37b8f22e Fix Stepper validation on Enrollment Details step 2026-07-15 00:08:18 +08:00
ssbg04 14809ad09e feat: add document download feature and setup guide 2026-07-14 23:40:23 +08:00
ssbg04 e8e20f8b6b feat: use WoltModalSheet for PrintQueueModal 2026-07-14 21:49:00 +08:00
ssbg04 b15e52d39c feat: Add student profile enrollment modification and name formatting 2026-07-06 19:55:27 +08:00
ssbg04 7e528441ef feat: Add realtime password strength indicator, realtime reset requests fetching, and format recent activities cards date/time 2026-07-06 16:11:25 +08:00
ssbg04 79bb032658 feat: redesign recent activities table and wire up dashboard history clicks 2026-07-06 15:54:46 +08:00
ssbg04 df5426a40a feat: complete dashboard history and android nav icons 2026-07-06 15:49:54 +08:00
ssbg04 1da39c94e3 Fix missing closing parenthesis in list tables 2026-07-06 15:13:43 +08:00
ssbg04 ddb3e106b2 Update Dashboard and Documents Screen UI, add Upload OCR Modal 2026-07-06 15:12:29 +08:00
ssbg04 f383c3b8cc Update FABs to use CircleBorder in students screen 2026-07-06 14:17:46 +08:00
ssbg04 c21c4b6ccc Update student enrollment logic and UI FABs 2026-07-06 14:12:22 +08:00
ssbg04 ba1609a00c feat: Update dashboard, modals, and user management UI 2026-07-06 13:44:24 +08:00
ssbg04 db5e27eb96 feat: implement 4Ps filter and unify pagination design 2026-07-05 22:45:52 +08:00
ssbg04 99e1471889 feat: Add server IP auto-discovery and ML Kit Document Scanner integration
- Added server LAN discovery to auto-detect and persist backend IP.

- Updated health-check route to expose X-TIS-RMS header.

- Added 'Change Server' UI option to login screen.

- Integrated Google ML Kit Document Scanner for edge detection, perspective correction, and auto-enhancement on Android.
2026-07-05 21:38:27 +08:00
ssbg04 0883fc0d8d feat: add 4Ps beneficiary status to students and reports
- Backend: add is_4ps column to students table with auto-migration
- Backend: studentController CRUD now handles is4ps field
- Backend: reportsController getStats returns 4Ps beneficiary count
- Frontend: StudentModel gains is4ps bool field
- Frontend: student_repository and provider pass is4ps parameter
- Frontend: StudentCounts gains fourPs field for reports
- Frontend: Add/Edit modal has Government Aid Status switch
- Frontend: Student profile modal shows 4Ps badge in info card
- Frontend: Reports screen has 5th KPI card for 4Ps Beneficiaries
2026-07-05 20:33:26 +08:00
ssbg04 9f946a88d7 UI: Refactor Teacher Management and Users screen modals 2026-07-04 00:15:22 +08:00
ssbg04 9de353dea4 Fix Add User button padding and add robust capitalization formatter 2026-07-04 00:03:30 +08:00
ssbg04 d35659bb08 Implement User management screen UI updates 2026-07-03 23:58:42 +08:00
ssbg04 2593961935 Redesign pagination in Reports screen to match Student screen 2026-07-03 23:38:19 +08:00
ssbg04 37e755ed13 Fix file status chip width in Archives screen 2026-07-03 23:08:44 +08:00
ssbg04 ab5b236ff9 Fix status chip design by preventing full width expansion 2026-07-03 23:04:11 +08:00
ssbg04 d7ac0c1ec8 Remove reset button and auto-reset filter on open 2026-07-03 22:59:47 +08:00
ssbg04 f20a7078e6 Complete requested UI tasks 2026-07-03 22:56:49 +08:00
ssbg04 f2e52a22e5 Redesign user profile modal using custom_modal 2026-07-03 22:21:19 +08:00
ssbg04 e885ebc538 Redesign edit profile modal using CustomModal 2026-07-03 22:11:08 +08:00
ssbg04 855761be6b paginations 2026-07-02 23:10:30 +08:00
ssbg04 a74c7ca294 Fix file long press & add local image pinch hint 2026-07-02 20:50:17 +08:00
ssbg04 ced6ca57c4 Fix document preview modal UI and gestures 2026-07-02 20:35:27 +08:00
ssbg04 3b7561daaf Fix openCloseDial parameter type 2026-07-02 20:23:00 +08:00
ssbg04 a204275305 Update Documents screen UI (FAB, Long Press, Preview Modal) 2026-07-02 20:21:21 +08:00
ssbg04 5cc99fd810 UI: Animated filters, reorganized user details modal header, and clear settings fields 2026-07-02 15:16:38 +08:00
ssbg04 7bc5de6c96 Users screen responsive tweaks and circular FAB 2026-07-02 14:21:09 +08:00
ssbg04 5ef0843e81 Settings Screen: Validate Confirm Password before dialog pops up 2026-07-02 14:03:45 +08:00
ssbg04 7a6aa5108d UI Redesigns for Users and Settings screens: frosted glass modal, SegmentedButton, FAB, and live password progress bar 2026-07-02 14:02:24 +08:00
ssbg04 91d0ff01b4 UI: Redesign student profile modal into android 17 frosted glass 2026-07-02 13:39:18 +08:00
ssbg04 82349d7d2e UI: Implement frosted glass dashboard banner and transparent pagination 2026-07-02 13:24:38 +08:00
ssbg04 eac009794a UI: Add Windows title bar to Splash and Login screens, fix rounded corners 2026-07-01 20:01:22 +08:00
ssbg04 3e1ba4dbb0 UI refinements: dashboard styling, edge-to-edge layout, responsive modals, windows custom title bar 2026-07-01 19:42:49 +08:00
ssbg04 6bd806b8c4 UI updates: Refactored background color and added skeleton loaders to tabs 2026-06-29 01:50:52 +08:00
ssbg04 86c9e9b8eb UI: Refactor Windows and Android layout structure, fix edge-to-edge rendering and sidebar animations 2026-06-29 01:06:42 +08:00
ssbg04 09bffccb36 UI: Redesign Android Side Drawer for better UI/UX 2026-06-28 22:58:29 +08:00
ssbg04 b427cd7296 UI: Fix filter FAB responsiveness and Student validation modal zoom/drag 2026-06-28 22:17:19 +08:00
ssbg04 2bbed4fd87 Fix bulk enroll UI and update student status to Enrolled on backend 2026-06-28 16:41:26 +08:00
ssbg04 3c18f6be17 Fix statcard overflow, cursor hover, and default to 4 years on comparison 2026-06-28 16:23:54 +08:00
ssbg04 8e97021216 Update reports screen with rates calculation, reordering, and custom pagination 2026-06-28 15:55:14 +08:00
ssbg04 772e120cf6 Fix users screen table click and redesign filter 2026-06-28 15:36:10 +08:00
ssbg04 e1173e513a Redesign Users UI and move actions to modal, secure password reset 2026-06-28 15:28:40 +08:00
ssbg04 74a0bb9211 Fix duplicate floatingActionButton error, move batch action pill to Stack Positioned 2026-06-28 15:11:19 +08:00
ssbg04 afe53c95cd Floating batch actions pill and grouped missing documents hover in students screen 2026-06-28 15:09:31 +08:00
ssbg04 1d7e350dd1 Fix bracket matching syntax error in students_screen.dart 2026-06-28 15:00:41 +08:00
ssbg04 36b66dca43 fix syntax error 2026-06-28 14:43:34 +08:00
ssbg04 f0eb4d22bf Batch actions and multi-select UI updates 2026-06-28 14:42:28 +08:00
ssbg04 66638a2b21 UI Updates 2026-06-28 14:37:09 +08:00
ssbg04 7fa6c1140e feat: add categories to windows sidebar and refresh data on stat card navigation 2026-06-28 13:44:34 +08:00
ssbg04 dae724a41d feat: replace calendar pickers with flutter_holo_date_picker and calendar_date_picker2 2026-06-28 11:23:18 +08:00
ssbg04 871541885c fix: dashboard notification and refresh bugs, student filter and preview updates 2026-06-28 11:14:38 +08:00
ssbg04 fb4f62b528 Fix Windows modal background shadow bug, Dashboard routing to Teacher Management Modal, and Academic setup UI adjustments 2026-06-27 02:49:46 +08:00
ssbg04 86870de4d5 FEATURE: Teachers academic setup - convert to modal, responsive tabs, auto-academic years, teacher detail modal, hierarchical section assign, sections filter 2026-06-26 00:43:22 +08:00
ssbg04 7068c90656 UPDATE: User history and view all recent screen design 2026-06-26 00:27:46 +08:00
ssbg04 1ba92683fc UPDATE: stats card hover in windows, request reset password into notification 2026-06-25 23:21:56 +08:00
ssbg04 352b34e2d5 feat: Update JWT to 75 days and remove session timeout 2026-06-25 02:13:41 +08:00
ssbg04 6ded3a45e4 UI polishing and layout fixes for students, archives, and documents screens 2026-06-23 02:01:33 +08:00
ssbg04 d983c0d2d8 feat: teacher section scoping, session expiry fix, and notification timeline sorting
Backend:
- studentController: scope teacher student list/count by timeline-latest enrollment
  (ORDER BY ay.year_range DESC, e.grade_level DESC, e.id DESC) instead of MAX(id)
- documentController: same timeline-based scoping for teacher document access
- folderController: same timeline-based scoping for teacher folder access
- dashboardController: same timeline-based scoping for teacher dashboard stats
- archiveController: same timeline-based scoping for teacher archive view
- notificationController: replace MAX(id) enrollment subqueries with timeline sort;
  add broadcast notification support (entity_type IS NULL, category IN (student/document/system))
- schema, routes, reports, requirements, users: various supporting updates

Frontend:
- auth_repository: restore FlutterSecureStorage for JWT token storage
  (was broken: migrated to SharedPreferences while all other repos read from FlutterSecureStorage,
  causing all API calls to send null token silently)
- student_model: add latestEnrollment getter computed dynamically from enrollments list;
  expose latestGradeLevel/latestSection as getters with fallback to server-supplied values
- student_provider: invalidate studentDetailProvider on update/bulkEnroll/bulkGraduate
- inactivity_wrapper: fix timeout 3000s -> 300s (5 min); improve _handleInactivity guard
- dashboard, documents, settings, students, users screens: various UI updates

Chore:
- .gitignore: add .env, *.db, student uploads, debug/scratch scripts, flutter build artifacts
- remove tis_rms.db from git tracking (runtime data, should never be committed)
2026-06-23 01:13:00 +08:00
ssbg04 a7460fa14b feat: complete UI fixes, fuzzy search, and UX enhancements
- Implemented fuzzy search across all major screens (dashboard, students, documents, archives, users) and removed search bar sliding animation.
- Migrated AuthRepository token storage to shared_preferences for Windows/Android compatibility.
- Implemented 5-minute inactivity session expiration and added SessionExpired dialog.
- Added seeding scripts (seed.js, seed_docs.js) for robust test data generation.
- Fixed Add/Edit Student modal UI layout bug by replacing MainAxisSize.min with max.
- Restyled pagination to occupy full horizontal width without bottom spacing.
- Moved filter buttons to Floating Action Buttons for better accessibility.
- Hid filter FABs, multi-select, and more options when the search bar is active.
- Added Document Type filter support in the Recycle Bin modal.
- Adjusted popup menu position to PopupMenuPosition.under to prevent off-screen sticking.
- Replaced 'Student' column header with 'Folder Path' and added path prefix in file folder cards.
2026-06-21 00:47:06 +08:00
ssbg04 ad166c860f Updates UI/UX 2026-06-20 18:22:27 +08:00
ssbg04 1fc58339b1 add: search bar sliding, implement: dashbord, students, documents, archive, users search bar 2026-06-20 16:24:10 +08:00
ssbg04 4e5a9c7c74 fix: design of error dialog, design of windows navigation and android navigation 2026-06-20 16:08:05 +08:00
ssbg04 dad079db78 Fix: many 2026-06-20 15:27:27 +08:00
ssbg04 e39fc5593d Regit 2026-06-20 15:27:27 +08:00
ssbg04 f8b5a54e3b add: screenshots of windows app 2026-06-11 19:22:47 +08:00
ssbg04 2b02fbd1d1 add: screenshots of windows app 2026-06-11 19:20:17 +08:00
ssbg04 df06411ba0 Revert "ADD: auto-capitalization, back confirmation, buttons hide, and added side bar menu"
This reverts commit 639a32aed4.
2026-06-11 19:06:32 +08:00
ssbg04 b8c18539de MOEWWWWW 2026-06-11 19:03:59 +08:00
ssbg04 639a32aed4 ADD: auto-capitalization, back confirmation, buttons hide, and added side bar menu 2026-06-03 23:56:45 +08:00
ssbg04 56257d0d7c ADDED: hover missing docs, UPDATED: button labels 2026-06-03 22:55:55 +08:00
390 changed files with 106195 additions and 15298 deletions

No files matched your search

+555
View File
@@ -0,0 +1,555 @@
name: Build & Release Client (Windows & Android)
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag_name:
description: 'Release tag (e.g. v1.0.0). Leave empty to use pubspec.yaml version.'
required: false
default: ''
type: string
jobs:
build-and-release:
name: Build Windows & Android Artifacts
runs-on: windows-latest
permissions:
contents: write
steps:
# ------------------------------------------------------------
# Checkout
# ------------------------------------------------------------
- name: Checkout Repository
uses: actions/checkout@v4
with:
fetch-depth: 0
# ------------------------------------------------------------
# Java
# ------------------------------------------------------------
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
cache: gradle
- name: Verify Java
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
Write-Host "JAVA_HOME = $env:JAVA_HOME"
if (-not $env:JAVA_HOME) {
throw "JAVA_HOME is not set."
}
if (-not (Test-Path "$env:JAVA_HOME\bin\java.exe")) {
throw "Java executable was not found at $env:JAVA_HOME\bin\java.exe"
}
& "$env:JAVA_HOME\bin\java.exe" -version
# ------------------------------------------------------------
# .NET
# ------------------------------------------------------------
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Verify .NET
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
dotnet --info
# ------------------------------------------------------------
# Flutter
# ------------------------------------------------------------
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- name: Flutter Doctor
working-directory: frontend
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
flutter doctor -v
# ------------------------------------------------------------
# Android / Gradle Java Fix
# ------------------------------------------------------------
- name: Fix Android Gradle Java Configuration
working-directory: frontend
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$gradleProperties = "android\gradle.properties"
if (Test-Path $gradleProperties) {
Write-Host "Checking $gradleProperties"
$content = Get-Content $gradleProperties
$filtered = $content | Where-Object {
$_ -notmatch '^\s*org\.gradle\.java\.home\s*='
}
if ($filtered.Count -ne $content.Count) {
Write-Host "Removing stale org.gradle.java.home configuration..."
Set-Content `
-Path $gradleProperties `
-Value $filtered `
-Encoding utf8
}
else {
Write-Host "No org.gradle.java.home override found."
}
}
else {
Write-Host "$gradleProperties does not exist."
}
Write-Host ""
Write-Host "Java used by GitHub Actions:"
Write-Host "JAVA_HOME = $env:JAVA_HOME"
& "$env:JAVA_HOME\bin\java.exe" -version
# ------------------------------------------------------------
# Google Services
# ------------------------------------------------------------
- name: Restore google-services.json from Secret
env:
GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }}
if: ${{ env.GOOGLE_SERVICES_JSON != '' }}
working-directory: frontend/android/app
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
Set-Content `
-Path "google-services.json" `
-Value $env:GOOGLE_SERVICES_JSON `
-Encoding utf8
Write-Host "google-services.json restored."
# ------------------------------------------------------------
# Android Release Keystore & Signing Configuration
# ------------------------------------------------------------
- name: Restore Android Release Keystore from Secret
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }}
working-directory: frontend/android/app
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$base64 = $env:ANDROID_KEYSTORE_BASE64.Trim()
$bytes = [Convert]::FromBase64String($base64)
[IO.File]::WriteAllBytes("$PWD/tis_rms_release.keystore", $bytes)
Write-Host "tis_rms_release.keystore successfully restored from secret."
- name: Configure Android key.properties
env:
KEY_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_STORE_PASSWORD }}
KEY_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEYSTORE_KEY_ALIAS }}
working-directory: frontend/android
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$keystoreFile = "app/tis_rms_release.keystore"
if (Test-Path $keystoreFile) {
$storePass = if ($env:KEY_STORE_PASSWORD) { $env:KEY_STORE_PASSWORD } else { "android" }
$keyPass = if ($env:KEY_PASSWORD) { $env:KEY_PASSWORD } else { "android" }
$alias = if ($env:KEY_ALIAS) { $env:KEY_ALIAS } else { "androiddebugkey" }
@(
"storePassword=$storePass",
"keyPassword=$keyPass",
"keyAlias=$alias",
"storeFile=tis_rms_release.keystore"
) | Set-Content -Path "key.properties" -Encoding ascii
Write-Host "Android key.properties successfully created."
} else {
Write-Warning "Keystore file not found at $keystoreFile. Build will use debug signing."
}
# ------------------------------------------------------------
# Determine Release Tag & Version
# ------------------------------------------------------------
- name: Determine Release Tag & Version
id: release_info
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$inputTag = '${{ github.event.inputs.tag_name }}'.Trim()
$runNumber = '${{ github.run_number }}'
$versionLine = Get-Content frontend/pubspec.yaml |
Select-String '^version:' |
Select-Object -First 1
if (-not $versionLine) {
throw "Could not find version: in frontend/pubspec.yaml"
}
$rawVer = ($versionLine.Line -split ':', 2)[1].Trim()
$verParts = $rawVer.Split('+')
$semVer = $verParts[0]
$semParts = $semVer.Split('.')
$major = if ($semParts.Length -ge 1) { $semParts[0] } else { "1" }
$minor = if ($semParts.Length -ge 2) { $semParts[1] } else { "0" }
$isTagPush = '${{ github.event_name }}' -eq 'push' -and '${{ github.ref }}'.StartsWith('refs/tags/')
if ($isTagPush) {
$refTag = '${{ github.ref_name }}'
$tag = if ($refTag.StartsWith('v')) { $refTag } else { "v$refTag" }
$verName = $tag.TrimStart('v')
}
elseif ($inputTag) {
$tag = if ($inputTag.StartsWith('v')) { $inputTag } else { "v$inputTag" }
$verName = $tag.TrimStart('v')
}
else {
# Auto-increment patch using GitHub Actions run_number
$verName = "$major.$minor.$runNumber"
$tag = "v$verName"
}
$buildNumber = $runNumber
if ($tag -notmatch '^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$') {
throw "Invalid release tag '$tag'. Expected format like v1.0.0."
}
Write-Host "Release tag : $tag"
Write-Host "Version name : $verName"
Write-Host "Build number : $buildNumber"
"tag=$tag" |
Out-File `
-FilePath $env:GITHUB_OUTPUT `
-Encoding utf8 `
-Append
"version_name=$verName" |
Out-File `
-FilePath $env:GITHUB_OUTPUT `
-Encoding utf8 `
-Append
"build_number=$buildNumber" |
Out-File `
-FilePath $env:GITHUB_OUTPUT `
-Encoding utf8 `
-Append
# Sync calculated version and build number into pubspec.yaml
$pubspecPath = "frontend/pubspec.yaml"
$pubContent = Get-Content $pubspecPath
$updatedPub = $pubContent -replace '^version:.*', "version: $verName+$buildNumber"
Set-Content -Path $pubspecPath -Value $updatedPub -Encoding utf8
Write-Host "Updated $pubspecPath to version: $verName+$buildNumber"
# ------------------------------------------------------------
# Manual Release Tag
# ------------------------------------------------------------
- name: Create Git Tag for Manual Release
if: ${{ github.event_name == 'workflow_dispatch' }}
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ steps.release_info.outputs.tag }}
run: |
$ErrorActionPreference = 'Stop'
Write-Host "Checking whether tag $env:RELEASE_TAG exists..."
gh api `
"repos/${{ github.repository }}/git/ref/tags/$env:RELEASE_TAG" `
--silent 2>$null
if ($LASTEXITCODE -eq 0) {
Write-Host "Tag $env:RELEASE_TAG already exists."
}
else {
Write-Host "Creating tag $env:RELEASE_TAG..."
gh api `
--method POST `
"repos/${{ github.repository }}/git/refs" `
-f "ref=refs/tags/$env:RELEASE_TAG" `
-f "sha=${{ github.sha }}"
if ($LASTEXITCODE -ne 0) {
throw "Failed to create tag $env:RELEASE_TAG"
}
Write-Host "Tag $env:RELEASE_TAG created successfully."
}
# ------------------------------------------------------------
# Windows Build
# ------------------------------------------------------------
- name: Build Windows Desktop App
working-directory: frontend
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
flutter pub get
if ($LASTEXITCODE -ne 0) {
throw "flutter pub get failed."
}
flutter build windows `
--release `
--obfuscate `
--split-debug-info=build/windows/symbols `
--build-name="${{ steps.release_info.outputs.version_name }}" `
--build-number="${{ steps.release_info.outputs.build_number }}"
if ($LASTEXITCODE -ne 0) {
throw "Flutter Windows build failed."
}
# ------------------------------------------------------------
# Bundle Microsoft Visual C++ CRT Runtime DLLs
# Fixes: vcruntime140.dll, vcruntime140_1.dll, msvcp140.dll missing errors
# ------------------------------------------------------------
Write-Host ""
Write-Host "Locating Microsoft Visual C++ CRT x64 redistributable libraries..."
$releaseDir = "build\windows\x64\runner\Release"
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
$copied = $false
if (Test-Path $vswhere) {
$vsPath = & $vswhere -latest -products * -property installationPath
if ($vsPath -and (Test-Path "$vsPath\VC\Redist\MSVC")) {
$crtDirs = Get-ChildItem -Path "$vsPath\VC\Redist\MSVC" -Recurse -Filter "Microsoft.VC*.CRT" |
Where-Object { $_.FullName -like "*\x64\*" -and $_.FullName -notlike "*debug*" -and $_.FullName -notlike "*onecore*" }
$targetCrtDir = $crtDirs | Select-Object -First 1
if ($targetCrtDir) {
Write-Host "Found CRT DLL directory at: $($targetCrtDir.FullName)"
Copy-Item -Path "$($targetCrtDir.FullName)\*.dll" -Destination $releaseDir -Force
$copied = $true
}
}
}
if (-not $copied) {
Write-Warning "CRT directory not found via vswhere. Searching System32 for required VC runtime DLLs..."
$requiredDlls = @(
"vcruntime140.dll",
"vcruntime140_1.dll",
"msvcp140.dll",
"msvcp140_1.dll",
"msvcp140_2.dll",
"msvcp140_atomic_wait.dll",
"msvcp140_codecvt_ids.dll",
"vcruntime140_threads.dll",
"concrt140.dll",
"vccorlib140.dll"
)
foreach ($dll in $requiredDlls) {
$sysPath = "$env:SystemRoot\System32\$dll"
if (Test-Path $sysPath) {
Copy-Item -Path $sysPath -Destination $releaseDir -Force
Write-Host "Copied $dll from System32"
}
}
}
$criticalDlls = @("vcruntime140.dll", "vcruntime140_1.dll", "msvcp140.dll")
foreach ($dll in $criticalDlls) {
$targetPath = Join-Path $releaseDir $dll
if (-not (Test-Path $targetPath)) {
Write-Warning "Missing critical CRT DLL: $dll in $releaseDir"
} else {
Write-Host "Verified bundled: $dll"
}
}
Write-Host ""
Write-Host "All DLLs bundled into Windows release:"
Get-ChildItem -Path "$releaseDir\*.dll" | Select-Object Name
# ------------------------------------------------------------
# Windows Installer
# ------------------------------------------------------------
- name: Compile Inno Setup Windows Installer
working-directory: frontend
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
New-Item `
-ItemType Directory `
-Force `
-Path ..\installers |
Out-Null
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" `
"/DMyAppVersion=${{ steps.release_info.outputs.version_name }}" `
"/DOutputBaseFilename=TIS_RMS_Client_Setup_${{ steps.release_info.outputs.tag }}" `
TIS_RMS_Client.iss
if ($LASTEXITCODE -ne 0) {
throw "Inno Setup compilation failed."
}
# ------------------------------------------------------------
# Android Build (Split 32-bit, 64-bit & Universal APKs)
# ------------------------------------------------------------
- name: Build Android APKs (Split 32-bit, 64-bit & Universal)
working-directory: frontend
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
Write-Host "Using JAVA_HOME:"
Write-Host $env:JAVA_HOME
& "$env:JAVA_HOME\bin\java.exe" -version
$tag = "${{ steps.release_info.outputs.tag }}"
$verName = "${{ steps.release_info.outputs.version_name }}"
$buildNum = "${{ steps.release_info.outputs.build_number }}"
# 1. Build Split-per-ABI APKs (32-bit armeabi-v7a & 64-bit arm64-v8a)
Write-Host ""
Write-Host "Building Android Split-per-ABI APKs (32-bit & 64-bit)..."
flutter build apk `
--release `
--split-per-abi `
--obfuscate `
--split-debug-info=build/app/outputs/symbols `
--build-name="$verName" `
--build-number="$buildNum"
if ($LASTEXITCODE -ne 0) {
throw "Flutter Android split-per-abi build failed."
}
$apk32 = "build\app\outputs\flutter-apk\app-armeabi-v7a-release.apk"
$apk64 = "build\app\outputs\flutter-apk\app-arm64-v8a-release.apk"
$output32 = "..\installers\TIS_RMS_32bit_$tag.apk"
$output64 = "..\installers\TIS_RMS_64_$tag.apk"
if (-not (Test-Path $apk32)) {
throw "Split build completed without producing $apk32"
}
if (-not (Test-Path $apk64)) {
throw "Split build completed without producing $apk64"
}
Copy-Item $apk32 $output32 -Force
Copy-Item $apk64 $output64 -Force
Copy-Item $apk32 "..\installers\TIS_RMS_32bit.apk" -Force
Copy-Item $apk64 "..\installers\TIS_RMS_64.apk" -Force
Write-Host "32-bit APK copied to $output32 and ..\installers\TIS_RMS_32bit.apk"
Write-Host "64-bit APK copied to $output64 and ..\installers\TIS_RMS_64.apk"
# 2. Build Universal APK (All ABIs bundled)
Write-Host ""
Write-Host "Building Android Universal APK..."
flutter build apk `
--release `
--obfuscate `
--split-debug-info=build/app/outputs/symbols `
--build-name="$verName" `
--build-number="$buildNum"
if ($LASTEXITCODE -ne 0) {
throw "Flutter Android universal APK build failed."
}
$apkUniversal = "build\app\outputs\flutter-apk\app-release.apk"
$outputUniversal = "..\installers\TIS_RMS_Universal_$tag.apk"
if (-not (Test-Path $apkUniversal)) {
throw "Universal build completed without producing $apkUniversal"
}
Copy-Item $apkUniversal $outputUniversal -Force
Copy-Item $apkUniversal "..\installers\TIS_RMS_Universal.apk" -Force
Write-Host "Universal APK copied to $outputUniversal and ..\installers\TIS_RMS_Universal.apk"
# ------------------------------------------------------------
# Verify Artifacts
# ------------------------------------------------------------
- name: Verify Release Artifacts
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$tag = "${{ steps.release_info.outputs.tag }}"
$files = @(
"installers\TIS_RMS_Client_Setup_$tag.exe",
"installers\TIS_RMS_32bit_$tag.apk",
"installers\TIS_RMS_64_$tag.apk",
"installers\TIS_RMS_Universal_$tag.apk"
)
foreach ($file in $files) {
if (-not (Test-Path $file)) {
throw "Missing release artifact: $file"
}
$size = (Get-Item $file).Length
if ($size -le 0) {
throw "Release artifact is empty: $file"
}
Write-Host "Found: $file"
Write-Host "Size: $size bytes"
}
# ------------------------------------------------------------
# GitHub Release
# ------------------------------------------------------------
- name: Create GitHub Release and Upload Artifacts
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.release_info.outputs.tag }}
target_commitish: ${{ github.sha }}
name: Release ${{ steps.release_info.outputs.tag }}
files: |
installers/TIS_RMS_Client_Setup_${{ steps.release_info.outputs.tag }}.exe
installers/TIS_RMS_32bit_${{ steps.release_info.outputs.tag }}.apk
installers/TIS_RMS_64_${{ steps.release_info.outputs.tag }}.apk
installers/TIS_RMS_Universal_${{ steps.release_info.outputs.tag }}.apk
generate_release_notes: true
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+44 -2
View File
@@ -1,10 +1,52 @@
.claude
.vscode
.openpost
/assets
backend_build
installers/
backend/node_modules
backend/bin/cloudflared.exe
backend/bin/LibreOffice
backend/logs/
requirements
TIS*
TIS*
!*.iss
screenshots/
issues/
.agents/
Pictures/
plans/
# Environment secrets
backend/.env
backend/.env.*
!backend/.example.env
backend/*firebase*.json
frontend/android/app/google-services.json
**/google-services.json
backend/brain
frontend/brain
documentation/TODO.md
# Database and runtime data folder (never commit)
backend/*.db
backend/data/
backend/uploads/
# Temporary debug / scratch scripts
backend/debug_*.js
backend/read_*.js
backend/search_*.js
backend/scratch_*.js
backend/test_*.js
# Flutter build artifacts
frontend/.dart_tool/
frontend/build/
frontend/android/build/
frontend/.flutter-plugins
frontend/.flutter-plugins-dependencies
# Backup logs
backend/data/backup_info.json
backend/data/backup_logs.txt
+74 -57
View File
@@ -1,68 +1,85 @@
# TIS RMS (Records Management System)
<div align="center">
A comprehensive, client-server Records Management System designed to streamline document handling, student records management, and automated data extraction.
# TIS RMS
## 🏗 Architecture Overview
### Records Management System
This project is divided into two primary components:
**A full-stack, offline-capable platform for managing student records, documents, and OCR-powered data extraction in Philippine educational institutions.**
### 1. Frontend (Flutter Windows Application)
A modern, performant desktop application tailored for Windows, built using **Flutter**.
- **Key Technologies:** Flutter, Riverpod (State Management), Dio (Networking), Syncfusion PDF Viewer.
- **Core Modules:**
- **Dashboard:** Overview of recent activities and user history.
- **Document Management:** Upload, preview, and manage student records and documents.
- **OCR Integration:** Automatically extract data from scanned documents directly into the system.
- **User & Student Management:** Interface to manage student profiles, teacher records, and administrative settings.
- **Reports Generation:** Support for standard educational Excel templates (e.g., School Form 10).
<br/>
### 2. Backend (Node.js REST API)
A robust and lightweight local server providing data persistence and powerful OCR processing capabilities.
- **Key Technologies:** Node.js, Express.js, SQLite (`better-sqlite3`), JWT (Authentication), Multer (File Uploads).
- **Core Features:**
- **Authentication:** Secure login and session management using `bcrypt` and `jsonwebtoken`.
- **Local Database:** Utilizes a fast, local SQLite database (`tis_rms.db`) for portability and simple deployment.
- **OCR & PDF Engine:** Integrates locally bundled **Tesseract OCR** and **Ghostscript** to securely process and extract text from images and PDFs completely offline.
- **File Handling:** Secure temporary storage and parsing pipeline for incoming files.
![Flutter](https://img.shields.io/badge/Flutter-3.x-02569B?style=for-the-badge&logo=flutter&logoColor=white)
![Node.js](https://img.shields.io/badge/Node.js-18+-339933?style=for-the-badge&logo=node.js&logoColor=white)
![SQLite](https://img.shields.io/badge/SQLite-003B57?style=for-the-badge&logo=sqlite&logoColor=white)
![Firebase](https://img.shields.io/badge/Firebase-FFCA28?style=for-the-badge&logo=firebase&logoColor=black)
![Windows](https://img.shields.io/badge/Windows-Desktop-0078D6?style=for-the-badge&logo=windows&logoColor=white)
![Android](https://img.shields.io/badge/Android-Mobile-3DDC84?style=for-the-badge&logo=android&logoColor=white)
![Version](https://img.shields.io/github/v/tag/ssbg04/TIS_RMS?style=for-the-badge&label=version&color=4CAF50)
</div>
---
## ✨ Features
- 📂 **Document Management** — Upload, preview, and organize student documents (PDFs & images)
- 🎓 **Student Records** — Track enrollment status, LRN, grade level, strand, and document completion
- 🔍 **OCR Extraction** — Extract data from scanned documents via bundled Tesseract OCR (fully offline)
- 📊 **Reports** — Generate DepEd School Form 10 (SF10) for JHS & SHS using bundled Excel templates
- 🔔 **Push Notifications** — Real-time alerts via Firebase Cloud Messaging
- 🔐 **Authentication** — JWT-based sessions with bcrypt password hashing
- 🛠️ **Windows Service** — Backend can run as a persistent Windows Service via NSSM
---
## 🏗️ Architecture
Two components communicating over a local network:
- **Frontend** — Flutter multi-platform client supporting **Windows** (desktop) and **Android** (mobile), built with Riverpod, Dio, Syncfusion PDF Viewer, and Socket.IO
- **Backend** — Node.js + Express REST API with a local SQLite database, Tesseract OCR, and Ghostscript bundled for offline document processing
---
## 🚀 Getting Started
### Prerequisites
- Node.js (v18 or higher recommended)
- Flutter SDK (stable channel)
- Visual Studio with C++ workload (for compiling the Windows Flutter app)
**Prerequisites:** Node.js v18+, Flutter SDK (stable), Visual Studio 2022 with C++ workload.
### Backend Setup
1. Navigate to the `backend` directory:
```bash
cd backend
```
2. Install dependencies:
```bash
npm install
```
3. Copy `.example.env` to `.env` and configure your environment variables.
4. Start the server:
```bash
npm start
```
*(For development, you can use `npm run dev` if nodemon is configured)*
> Tesseract OCR and Ghostscript are bundled — no extra installation needed.
### Frontend Setup
1. Navigate to the `frontend` directory:
```bash
cd frontend
```
2. Fetch dependencies:
```bash
flutter pub get
```
3. Run the Windows application:
```bash
flutter run -d windows
```
*(To build the release executable, run `flutter build windows`)*
### Backend
```bash
cd backend
npm install
copy .example.env .env # configure port, JWT secret, etc.
npm run dev # or: npm start
```
## 📦 Included Dependencies & Tooling
- **Tesseract & Ghostscript binaries:** The backend repository includes bundled Windows binaries for Tesseract OCR and Ghostscript to guarantee that document processing works out-of-the-box without requiring complex system path configurations.
- **Inno Setup:** Includes an installer script (`TIS_Frontend.iss`) for easily packaging and distributing the compiled Flutter application.
### Frontend
```bash
cd frontend
flutter pub get
# Windows
flutter run -d windows
flutter build windows
# Android
flutter run -d android
flutter build apk
```
To build a Windows installer, compile `frontend/TIS_RMS_Client.iss` with **Inno Setup**, or run:
```powershell
.\build_release.ps1
```
---
## 📜 License
Developed by **BSIT3DSB** — PLSP.
<div align="center">
<sub>Built with ❤️ using Flutter &amp; Node.js · TIS Records Management System</sub>
</div>
+9
View File
@@ -4,3 +4,12 @@ DB_PATH=./data/tis_rms.db
UPLOAD_PATH=./uploads
# Root folder where auto-created student directories are stored
STUDENT_DIR_ROOT=./data/students
FIREBASE_SERVICE_ACCOUNT=./firebase-service-account.json
# Auto Cloudflare Tunnel activation
CLOUDFLARE_TUNNEL_ENABLED=true
CLOUDFLARE_TUNNEL_TOKEN=your_cloudflare_tunnel_token_here
# Gmail SMTP Email Service (for Password Reset OTP)
SMTP_USER=your_school_email@gmail.com
SMTP_PASS=your_16_character_google_app_password
SMTP_FROM="TIS Record Management System" <your_school_email@gmail.com>
Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+1
View File
@@ -0,0 +1 @@
{}
Binary file not shown.
Binary file not shown.
View File
Whitespace-only changes.
+74 -8
View File
@@ -1,16 +1,18 @@
process.env.TZ = 'Asia/Manila';
const express = require('express');
const http = require('http');
const cors = require('cors');
const morgan = require('morgan');
const path = require('path');
require('dotenv').config();
const { initSocket } = require('./src/services/socketService');
require('dotenv').config({ path: path.join(__dirname, '.env'), override: true });
require('dotenv').config({ path: path.join(__dirname, '..', '.env'), override: true });
// ==========================================
// BUNDLED BINARIES FOR OCR (Ghostscript, Tesseract)
// CROSS-PLATFORM SYSTEM SETUP (Ghostscript, Tesseract)
// ==========================================
const gsPath = path.join(__dirname, 'ghostscript', 'bin');
const tessPath = path.join(__dirname, 'tesseract');
process.env.PATH = `${gsPath}${path.delimiter}${tessPath}${path.delimiter}${process.env.PATH}`;
const { setupSystemEnvironment } = require('./src/config/systemSetup');
setupSystemEnvironment();
const { initSchema } = require('./src/models/schema');
@@ -26,16 +28,25 @@ const folderRoutes = require('./src/routes/folders');
const requirementRoutes = require('./src/routes/requirements');
const ocrRoutes = require('./src/routes/ocr.routes.js');
const notificationRoutes = require('./src/routes/notifications');
const backupRoutes = require('./src/routes/backup');
const serverRoutes = require('./src/routes/server.routes');
const settingsRoutes = require('./src/routes/settings');
const templateRoutes = require('./src/routes/templates');
const app = express();
const server = http.createServer(app);
const PORT = process.env.PORT || 18484;
// Initialize Socket.IO Real-Time Server
initSocket(server);
// Initialize Database
initSchema();
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(morgan('dev'));
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
@@ -52,11 +63,66 @@ app.use('/api/folders', folderRoutes);
app.use('/api/requirements', requirementRoutes);
app.use('/api/ocr', ocrRoutes);
app.use('/api/notifications', notificationRoutes);
app.use('/api/backup', backupRoutes);
app.use('/api/server', serverRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/templates', templateRoutes);
app.get('/', (req, res) => {
app.get(['/', '/api'], (req, res) => {
res.set('X-TIS-RMS', 'true');
res.json({ message: 'TIS RMS API is running' });
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
server.listen(PORT, '0.0.0.0', () => {
console.log(`Server running on port ${PORT} (0.0.0.0)`);
// Run auto-graduation check on startup and daily
try {
const { checkAndRunAutoGraduation } = require('./src/services/autoGraduationService');
checkAndRunAutoGraduation(1);
setInterval(() => checkAndRunAutoGraduation(1), 24 * 60 * 60 * 1000);
} catch (err) {
console.error('Failed to start auto-graduation schedule:', err.message);
}
// Run auto-archiving check on startup, set precision timer, and check every 30 seconds
try {
const { checkAndRunAutoArchive, scheduleAutoArchiveTimer } = require('./src/services/autoArchiveService');
checkAndRunAutoArchive(1);
scheduleAutoArchiveTimer();
setInterval(() => checkAndRunAutoArchive(1), 30 * 1000);
} catch (err) {
console.error('Failed to start auto-archiving schedule:', err.message);
}
// Run teacher document requirement due date reminders check on startup and hourly
try {
const { checkAndRunTeacherDueDateReminders } = require('./src/services/teacherReminderService');
checkAndRunTeacherDueDateReminders();
setInterval(() => checkAndRunTeacherDueDateReminders(), 60 * 60 * 1000);
} catch (err) {
console.error('Failed to start teacher reminder service:', err.message);
}
// Initialize Auto Cloudflare Tunnel activation when internet is connected
try {
const { startAutoTunnel } = require('./src/services/tunnelService');
startAutoTunnel();
} catch (err) {
console.error('Failed to start auto tunnel service:', err.message);
}
// Initialize Password Reset Link Expiration Monitor
try {
const { startResetLinkMonitor } = require('./src/services/passwordResetMonitor');
startResetLinkMonitor();
} catch (err) {
console.error('Failed to start password reset link monitor:', err.message);
}
// Initialize Firebase Cloud Messaging for Push Notifications
try {
require('./src/services/fcmService').init();
} catch (err) {
console.error('Failed to initialize FCM service:', err.message);
}
});
+183
View File
@@ -0,0 +1,183 @@
@echo off
setlocal EnableDelayedExpansion
:: ============================================================
:: TIS RMS - NSSM Service Installer
:: Installs Node.js backend as a Windows Service using NSSM.
:: Also fixes ghost Cloudflared service and firewall rules.
:: Run as Administrator (right-click -> Run as administrator)
:: ============================================================
NET SESSION >nul 2>&1
IF %ERRORLEVEL% NEQ 0 (
echo [ERROR] This script must be run as Administrator.
echo Right-click the file and choose "Run as administrator"
pause
exit /b 1
)
:: ── Config ────────────────────────────────────────────────────
set BACKEND_DIR=F:\SumbrerongBato\tis_rms_server\backend
set NSSM=%BACKEND_DIR%\bin\nssm.exe
set CLOUDFLARED=%BACKEND_DIR%\bin\cloudflared.exe
set SERVICE_NAME=TIS.RMS.ServerService
set LOG_DIR=%BACKEND_DIR%\logs
set NODE_PORT=18484
echo.
echo ============================================================
echo TIS RMS - NSSM Service Installer
echo ============================================================
echo.
:: ── 0. Verify NSSM exists ────────────────────────────────────
IF NOT EXIST "%NSSM%" (
echo [ERROR] nssm.exe not found at: %NSSM%
pause
exit /b 1
)
echo [OK] NSSM found: %NSSM%
:: ── 1. Find node.exe ─────────────────────────────────────────
echo.
echo [1/7] Locating node.exe...
set NODE_EXE=
:: Check bundled node first
IF EXIST "%BACKEND_DIR%\node.exe" set NODE_EXE=%BACKEND_DIR%\node.exe
IF NOT DEFINED NODE_EXE IF EXIST "C:\Program Files\nodejs\node.exe" set NODE_EXE=C:\Program Files\nodejs\node.exe
IF NOT DEFINED NODE_EXE IF EXIST "C:\Program Files (x86)\nodejs\node.exe" set NODE_EXE=C:\Program Files (x86)\nodejs\node.exe
:: Try PATH lookup
IF NOT DEFINED NODE_EXE (
for /f "delims=" %%i in ('where node.exe 2^>nul') do (
IF NOT DEFINED NODE_EXE set NODE_EXE=%%i
)
)
IF NOT DEFINED NODE_EXE (
echo [ERROR] node.exe not found. Install Node.js from https://nodejs.org first.
pause
exit /b 1
)
echo [OK] Found: %NODE_EXE%
:: ── 2. Remove ghost Cloudflared Windows Service ──────────────
echo.
echo [2/7] Checking for ghost Cloudflared Windows service...
sc query Cloudflared >nul 2>&1
IF %ERRORLEVEL% EQU 0 (
echo Found! Stopping and removing...
sc stop Cloudflared >nul 2>&1
timeout /t 3 /nobreak >nul
sc delete Cloudflared >nul 2>&1
echo [OK] Ghost Cloudflared service removed.
) ELSE (
echo [OK] No ghost service found.
)
:: ── 3. Stop and remove existing TIS RMS service (clean reinstall) ──
echo.
echo [3/7] Removing existing "%SERVICE_NAME%" service (if any)...
sc query "%SERVICE_NAME%" >nul 2>&1
IF %ERRORLEVEL% EQU 0 (
echo Stopping existing service...
"%NSSM%" stop "%SERVICE_NAME%" >nul 2>&1
timeout /t 3 /nobreak >nul
"%NSSM%" remove "%SERVICE_NAME%" confirm >nul 2>&1
echo [OK] Old service removed.
) ELSE (
echo [OK] No existing service to remove.
)
:: ── 4. Create logs directory ──────────────────────────────────
echo.
echo [4/7] Creating logs directory...
IF NOT EXIST "%LOG_DIR%" mkdir "%LOG_DIR%"
echo [OK] %LOG_DIR%
:: ── 5. Install service via NSSM ───────────────────────────────
echo.
echo [5/7] Installing "%SERVICE_NAME%" via NSSM...
"%NSSM%" install "%SERVICE_NAME%" "%NODE_EXE%"
"%NSSM%" set "%SERVICE_NAME%" AppDirectory "%BACKEND_DIR%"
"%NSSM%" set "%SERVICE_NAME%" AppParameters "index.js"
:: Logging
"%NSSM%" set "%SERVICE_NAME%" AppStdout "%LOG_DIR%\server.log"
"%NSSM%" set "%SERVICE_NAME%" AppStderr "%LOG_DIR%\server.log"
"%NSSM%" set "%SERVICE_NAME%" AppRotateFiles 1
"%NSSM%" set "%SERVICE_NAME%" AppRotateBytes 5242880
:: Auto-start on boot
"%NSSM%" set "%SERVICE_NAME%" Start SERVICE_AUTO_START
:: Restart on crash: 5s, 10s, 30s
"%NSSM%" set "%SERVICE_NAME%" AppExit Default Restart
"%NSSM%" set "%SERVICE_NAME%" AppRestartDelay 5000
:: Display name and description
"%NSSM%" set "%SERVICE_NAME%" DisplayName "TIS RMS Backend Server"
"%NSSM%" set "%SERVICE_NAME%" Description "TIS Record Management System - Node.js Backend API Server with Cloudflare Tunnel"
:: Shutdown: send Ctrl+C then wait 10s before kill
"%NSSM%" set "%SERVICE_NAME%" AppStopMethodSkip 0
"%NSSM%" set "%SERVICE_NAME%" AppStopMethodConsole 5000
"%NSSM%" set "%SERVICE_NAME%" AppStopMethodWindow 0
"%NSSM%" set "%SERVICE_NAME%" AppStopMethodThreads 5000
echo [OK] Service installed.
:: ── 6. Configure firewall rules ───────────────────────────────
echo.
echo [6/7] Configuring Windows Firewall...
:: cloudflared.exe - all profiles
netsh advfirewall firewall delete rule name="TIS-RMS-Cloudflared" >nul 2>&1
netsh advfirewall firewall add rule name="TIS-RMS-Cloudflared" dir=in action=allow program="%CLOUDFLARED%" enable=yes profile=any >nul 2>&1
netsh advfirewall firewall add rule name="TIS-RMS-Cloudflared" dir=out action=allow program="%CLOUDFLARED%" enable=yes profile=any >nul 2>&1
:: Node port - all profiles
netsh advfirewall firewall delete rule name="TIS-RMS-NodeServer-Inbound" >nul 2>&1
netsh advfirewall firewall delete rule name="TIS-RMS-NodeServer-Outbound" >nul 2>&1
netsh advfirewall firewall add rule name="TIS-RMS-NodeServer-Inbound" dir=in action=allow protocol=TCP localport=%NODE_PORT% enable=yes profile=any >nul 2>&1
netsh advfirewall firewall add rule name="TIS-RMS-NodeServer-Outbound" dir=out action=allow protocol=TCP localport=%NODE_PORT% enable=yes profile=any >nul 2>&1
:: URL ACL reservation (allows non-admin binding)
netsh http add urlacl url=http://+:%NODE_PORT%/ user=Everyone >nul 2>&1
echo [OK] Firewall rules set for all network profiles (Public/Private/Domain).
:: ── 7. Start the service ──────────────────────────────────────
echo.
echo [7/7] Starting service...
"%NSSM%" start "%SERVICE_NAME%"
timeout /t 4 /nobreak >nul
:: Verify
sc query "%SERVICE_NAME%" | findstr /i "RUNNING" >nul 2>&1
IF %ERRORLEVEL% EQU 0 (
echo [OK] Service is RUNNING.
) ELSE (
echo [WARN] Service may still be starting up. Check status in the Server Manager app.
)
:: ── Done ──────────────────────────────────────────────────────
echo.
echo ============================================================
echo Installation Complete!
echo.
echo Service Name : %SERVICE_NAME%
echo Node.js : %NODE_EXE%
echo Backend Dir : %BACKEND_DIR%
echo Logs : %LOG_DIR%\server.log
echo Port : %NODE_PORT%
echo Auto-Start : Yes (starts on Windows boot)
echo.
echo The WPF Server Manager can now Start/Stop/Restart the
echo service using the Service page.
echo.
echo To uninstall later, run: uninstall_service.bat
echo ============================================================
echo.
pause
+4900 -60
View File
File diff suppressed because it is too large. Load diff
+25 -2
View File
@@ -5,23 +5,46 @@
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "echo \"Error: no test specified\" && exit 1"
"dev": "nodemon index.js",
"test": "echo \"Error: no test specified\" && exit 1",
"service:install": "install_service.bat",
"service:uninstall": "uninstall_service.bat",
"service:start": "bin\\nssm.exe start TIS.RMS.ServerService",
"service:stop": "bin\\nssm.exe stop TIS.RMS.ServerService",
"service:restart": "bin\\nssm.exe restart TIS.RMS.ServerService",
"service:status": "sc query TIS.RMS.ServerService"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"archiver": "^8.0.0",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.9.0",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"exceljs": "^4.4.0",
"express": "^5.2.1",
"firebase-admin": "^14.2.0",
"jsonwebtoken": "^9.0.3",
"morgan": "^1.10.1",
"multer": "^2.1.1"
"multer": "^2.1.1",
"nodemailer": "^9.0.6",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.20.2",
"quickemailverification": "^1.0.4",
"sharp": "^0.35.4",
"socket.io": "^4.8.4",
"unzipper": "^0.12.5"
},
"devDependencies": {
"nodemon": "^3.1.14"
},
"allowScripts": {
"@firebase/util@1.15.2": true,
"bcrypt@6.0.0": true,
"better-sqlite3@12.9.0": true,
"protobufjs@7.6.5": true
}
}
+81
View File
@@ -0,0 +1,81 @@
const path = require('path');
const fs = require('fs');
const { execSync } = require('child_process');
/**
* Cross-platform environment setup helper.
* On Windows: Configures bundled Ghostscript and Tesseract binaries from backend directory.
* On Linux (Ubuntu): Checks system binaries, attempts auto-setup if missing, and configures system tessdata.
*/
function setupSystemEnvironment() {
const isWindows = process.platform === 'win32';
const isLinux = process.platform === 'linux';
console.log(`[System Setup] Operating System detected: ${process.platform} (${isWindows ? 'Windows' : isLinux ? 'Linux/Ubuntu' : 'Other'})`);
if (isWindows) {
// WINDOWS: Use bundled binaries inside backend directory
const gsPath = path.join(__dirname, '..', '..', 'ghostscript', 'bin');
const tessPath = path.join(__dirname, '..', '..', 'tesseract');
process.env.PATH = `${gsPath}${path.delimiter}${tessPath}${path.delimiter}${process.env.PATH}`;
const localTessData = path.join(__dirname, '..', '..', 'tesseract', 'tessdata');
if (fs.existsSync(localTessData)) {
process.env.TESSDATA_PREFIX = localTessData;
}
console.log('[System Setup] Windows environment configured using bundled Ghostscript & Tesseract binaries.');
} else if (isLinux) {
// LINUX / UBUNTU: Check and use system installed packages
const ubuntuOneLiner = 'sudo apt-get update && sudo apt-get install -y ghostscript tesseract-ocr tesseract-ocr-eng libtesseract-dev';
let hasGs = false;
let hasTesseract = false;
try {
execSync('which gs', { stdio: 'ignore' });
hasGs = true;
} catch (_) {}
try {
execSync('which tesseract', { stdio: 'ignore' });
hasTesseract = true;
} catch (_) {}
if (!hasGs || !hasTesseract) {
console.warn('[System Setup] WARNING: Missing OCR dependencies on Linux system!');
if (!hasGs) console.warn(' - Ghostscript (gs) is NOT installed.');
if (!hasTesseract) console.warn(' - Tesseract (tesseract) is NOT installed.');
console.log('[System Setup] Attempting auto-setup for Ubuntu/Debian...');
try {
execSync(ubuntuOneLiner, { stdio: 'inherit' });
console.log('[System Setup] Auto-installation of Ghostscript & Tesseract completed successfully.');
} catch (_) {
console.warn('[System Setup] Auto-installation requires root/sudo privileges.');
console.log(`[System Setup] Run this command on your Ubuntu server:\n\n ${ubuntuOneLiner}\n`);
}
} else {
console.log('[System Setup] Linux environment verified: Ghostscript and Tesseract are installed.');
}
// Set TESSDATA_PREFIX for Linux (prioritize system paths, fallback to local tessdata)
const possibleTessDataPaths = [
'/usr/share/tesseract-ocr/4.00/tessdata',
'/usr/share/tesseract-ocr/5/tessdata',
'/usr/share/tessdata',
'/usr/share/tesseract-ocr/tessdata',
path.join(__dirname, '..', '..', 'tesseract', 'tessdata')
];
for (const tessDir of possibleTessDataPaths) {
if (fs.existsSync(tessDir)) {
process.env.TESSDATA_PREFIX = tessDir;
break;
}
}
}
}
module.exports = { setupSystemEnvironment };
+135 -58
View File
@@ -1,5 +1,29 @@
const db = require('../config/db');
const path = require('path');
const fs = require('fs');
// ── Helper: format file size in human-readable string ─────────────────────────
const formatBytes = (bytes) => {
if (bytes === null || bytes === undefined || isNaN(bytes) || bytes < 0) return 'Unknown';
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
const getDocSize = (d) => {
if (d.file_size !== null && d.file_size !== undefined && d.file_size > 0) {
return d.file_size;
}
if (d.file_path && fs.existsSync(d.file_path)) {
try {
const stat = fs.statSync(d.file_path);
return stat.size;
} catch (_) {}
}
return null;
};
exports.getArchivedStudents = (req, res) => {
@@ -10,41 +34,58 @@ exports.getArchivedStudents = (req, res) => {
status = 'All Statuses',
} = req.query;
const pageNum = Math.max(1, parseInt(page));
const pageNum = Math.max(1, parseInt(page));
const limitNum = Math.min(100, Math.max(1, parseInt(limit)));
const offset = (pageNum - 1) * limitNum;
const offset = (pageNum - 1) * limitNum;
const isTeacher = req.user?.role?.toLowerCase() === 'teacher';
const teacherId = req.user?.id;
try {
const conditions = [];
const params = [];
const params = [];
// Base condition: only archived statuses
if (status === 'All Statuses' || !status) {
conditions.push(`s.status IN ('Graduated', 'Transferred', 'Dropped')`);
conditions.push(`s.status IN ('Graduated', 'Transferred', 'Dropped', 'Inactive')`);
} else {
conditions.push(`s.status = ?`);
params.push(status);
}
if (search.trim()) {
const like = `%${search.trim()}%`;
const like = `%${search.trim().split('').join('%')}%`;
conditions.push(`(s.lrn LIKE ? OR s.first_name LIKE ? OR s.last_name LIKE ? OR s.middle_name LIKE ?)`);
params.push(like, like, like, like);
}
// ---- Teacher section scoping ----
const teacherJoin = isTeacher
? `JOIN enrollments e_teacher ON e_teacher.student_id = s.id
AND e_teacher.id = (
SELECT e2.id FROM enrollments e2
JOIN academic_years ay ON e2.academic_year_id = ay.id
WHERE e2.student_id = s.id
ORDER BY e2.grade_level DESC, ay.year_range DESC, e2.id DESC LIMIT 1
)
JOIN teacher_sections ts ON ts.section_id = e_teacher.section_id AND ts.teacher_id = ?`
: '';
if (isTeacher) params.unshift(teacherId);
const whereClause = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
// Count query
const countSql = `
SELECT COUNT(DISTINCT s.id) as total
FROM students s
${teacherJoin}
${whereClause}
`;
const total = db.prepare(countSql).get(params).total;
// Fetch query
const fetchSql = `
SELECT
SELECT DISTINCT
s.id, s.lrn, s.first_name, s.middle_name, s.last_name, s.extension,
s.status, s.created_at,
date(s.created_at) as archivedDate,
@@ -55,6 +96,7 @@ exports.getArchivedStudents = (req, res) => {
ELSE date(s.created_at, '+5 years')
END as expiryDate
FROM students s
${teacherJoin}
${whereClause}
ORDER BY s.last_name ASC, s.first_name ASC
LIMIT ? OFFSET ?
@@ -68,7 +110,7 @@ exports.getArchivedStudents = (req, res) => {
const nameParts = [student.last_name + ',', student.first_name];
if (student.middle_name) nameParts.push(student.middle_name.charAt(0) + '.');
if (student.extension) nameParts.push(student.extension);
const isExpired = student.expiryDate < currentDate;
return {
@@ -86,8 +128,8 @@ exports.getArchivedStudents = (req, res) => {
archives,
pagination: {
total,
page: pageNum,
limit: limitNum,
page: pageNum,
limit: limitNum,
totalPages: Math.ceil(total / limitNum),
},
});
@@ -119,26 +161,10 @@ exports.restoreArchive = (req, res) => {
}
};
exports.purgeArchive = (req, res) => {
const { id } = req.params;
const existing = db.prepare('SELECT id FROM students WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ message: 'Student not found.' });
try {
// Due to CASCADE DELETE on constraints in schema.js, deleting the student will delete their documents, grades, and enrollments
db.prepare('DELETE FROM students WHERE id = ?').run(id);
res.json({ message: 'Record permanently purged successfully' });
} catch (error) {
console.error('purgeArchive error:', error);
res.status(500).json({ message: 'Failed to purge record', error: error.message });
}
};
// ============================================================
// GET /api/archives/documents — paginated archived docs
// Includes:
// • All documents (deleted_at IS NULL) from Graduated/Transferred/Dropped students
// • All documents (deleted_at IS NULL) from Graduated/Transferred/Dropped/Inactive students
// • All documents with status = 'Archived' from Enrolled students
// ============================================================
exports.getArchivedDocuments = (req, res) => {
@@ -153,13 +179,16 @@ exports.getArchivedDocuments = (req, res) => {
studentId = '',
} = req.query;
const pageNum = Math.max(1, parseInt(page));
const pageNum = Math.max(1, parseInt(page));
const limitNum = Math.min(100, Math.max(1, parseInt(limit)));
const offset = (pageNum - 1) * limitNum;
const offset = (pageNum - 1) * limitNum;
const isTeacher = req.user?.role?.toLowerCase() === 'teacher';
const teacherId = req.user?.id;
try {
const conditions = [];
const params = [];
const params = [];
// Core filter: non-enrolled student docs OR archived-status docs
if (status && status !== 'All Statuses') {
@@ -170,14 +199,14 @@ exports.getArchivedDocuments = (req, res) => {
} else {
// Default: non-enrolled docs OR explicitly Archived docs
conditions.push(`(
(s.status IN ('Graduated','Transferred','Dropped') AND d.deleted_at IS NULL)
(s.status IN ('Graduated','Transferred','Dropped','Inactive') AND d.deleted_at IS NULL)
OR
(s.status = 'Enrolled' AND d.status = 'Archived' AND d.deleted_at IS NULL)
)`);
}
if (search.trim()) {
const like = `%${search.trim()}%`;
const like = `%${search.trim().split('').join('%')}%`;
conditions.push(`(s.lrn LIKE ? OR s.first_name LIKE ? OR s.last_name LIKE ? OR d.file_name LIKE ?)`);
params.push(like, like, like, like);
}
@@ -187,13 +216,20 @@ exports.getArchivedDocuments = (req, res) => {
if (types.length > 0) {
const typeConditions = [];
for (const t of types) {
if (t === 'All JHS') {
if (t === 'All JHS' || t === 'All JHS Requirements') {
typeConditions.push(`dr.category = 'JHS'`);
} else if (t === 'All SHS') {
} else if (t === 'All SHS' || t === 'All SHS Requirements') {
typeConditions.push(`dr.category = 'SHS'`);
} else {
typeConditions.push(`(d.document_type = ? OR dr.name = ?)`);
params.push(t, t);
const cleanT = t.replace(/^(JHS|SHS)\s*-\s*/i, '').trim();
typeConditions.push(`(
d.document_type = ?
OR dr.name = ?
OR d.document_type = ?
OR d.document_type = ?
OR dr.name = ?
)`);
params.push(t, t, `JHS - ${cleanT}`, `SHS - ${cleanT}`, cleanT);
}
}
conditions.push(`(${typeConditions.join(' OR ')})`);
@@ -215,12 +251,23 @@ exports.getArchivedDocuments = (req, res) => {
params.push(studentId.trim());
}
const teacherJoin = isTeacher
? `JOIN teacher_sections ts ON e.section_id = ts.section_id AND ts.teacher_id = ?`
: '';
if (isTeacher) params.unshift(teacherId);
const whereClause = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const joins = `
LEFT JOIN students s ON d.student_id = s.id
LEFT JOIN enrollments e ON e.student_id = s.id
AND e.id = (SELECT id FROM enrollments WHERE student_id = s.id ORDER BY grade_level DESC, id DESC LIMIT 1)
AND e.id = (
SELECT e2.id FROM enrollments e2
JOIN academic_years ay_inner ON e2.academic_year_id = ay_inner.id
WHERE e2.student_id = s.id
ORDER BY ay_inner.year_range DESC, e2.grade_level DESC, e2.id DESC LIMIT 1
)
${teacherJoin}
LEFT JOIN academic_years ay ON e.academic_year_id = ay.id
LEFT JOIN document_requirements dr ON d.requirement_id = dr.id
`;
@@ -231,38 +278,51 @@ exports.getArchivedDocuments = (req, res) => {
const rows = db.prepare(`
SELECT DISTINCT
d.id, d.student_id, d.file_name, d.document_type, d.status,
d.created_at, d.file_path,
d.id, d.student_id, d.requirement_id, d.file_name, d.document_type, d.status,
d.created_at, d.file_path, d.uploaded_by, d.file_size,
s.lrn as student_lrn,
s.status as student_status,
s.first_name || ' ' || s.last_name as student_name
s.first_name || ' ' || s.last_name as student_name,
TRIM(COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')) as uploaded_by_name,
u.username as uploaded_by_username
FROM documents d
LEFT JOIN users u ON d.uploaded_by = u.id
${joins}
${whereClause}
ORDER BY d.created_at DESC
LIMIT ? OFFSET ?
`).all([...params, limitNum, offset]);
const documents = rows.map(d => ({
id: d.id,
studentId: d.student_id,
fileName: d.file_name,
documentType: d.document_type,
status: d.status,
createdAt: d.created_at,
studentLrn: d.student_lrn,
studentName: d.student_name,
studentStatus: d.student_status,
size: 'Unknown',
filePath: d.file_path,
}));
const documents = rows.map(d => {
const rawSize = getDocSize(d);
const uploaderName = d.uploaded_by_name && d.uploaded_by_name.trim().length > 0
? d.uploaded_by_name.trim()
: (d.uploaded_by_username || (d.uploaded_by ? `User #${d.uploaded_by}` : null));
return {
id: d.id,
studentId: d.student_id,
requirementId: d.requirement_id,
fileName: d.file_name,
documentType: d.document_type,
status: d.status,
createdAt: d.created_at,
studentLrn: d.student_lrn,
studentName: d.student_name,
studentStatus: d.student_status,
uploadedBy: d.uploaded_by,
uploadedByName: uploaderName,
fileSize: rawSize,
size: rawSize !== null ? formatBytes(rawSize) : 'Unknown',
filePath: d.file_path,
};
});
res.json({
documents,
pagination: {
total,
page: pageNum,
limit: limitNum,
page: pageNum,
limit: limitNum,
totalPages: Math.ceil(total / limitNum),
},
});
@@ -278,32 +338,49 @@ exports.getArchivedDocuments = (req, res) => {
// ============================================================
exports.getArchivedStudentFolders = (req, res) => {
const { search = '', status = 'All Statuses' } = req.query;
const isTeacher = req.user?.role?.toLowerCase() === 'teacher';
const teacherId = req.user?.id;
try {
const conditions = [];
const params = [];
const params = [];
if (status && status !== 'All Statuses') {
conditions.push(`s.status = ?`);
params.push(status);
} else {
conditions.push(`s.status IN ('Graduated','Transferred','Dropped')`);
conditions.push(`s.status IN ('Graduated','Transferred','Dropped','Inactive')`);
}
if (search.trim()) {
const like = `%${search.trim()}%`;
const like = `%${search.trim().split('').join('%')}%`;
conditions.push(`(s.lrn LIKE ? OR s.first_name LIKE ? OR s.last_name LIKE ?)`);
params.push(like, like, like);
}
let teacherJoinSql = '';
if (isTeacher) {
teacherJoinSql = `JOIN enrollments e_teacher ON e_teacher.student_id = f.student_id
AND e_teacher.id = (
SELECT e2.id FROM enrollments e2
JOIN academic_years ay ON e2.academic_year_id = ay.id
WHERE e2.student_id = f.student_id
ORDER BY e2.grade_level DESC, ay.year_range DESC, e2.id DESC LIMIT 1
)
JOIN teacher_sections ts ON ts.section_id = e_teacher.section_id AND ts.teacher_id = ?`;
params.unshift(teacherId);
}
const whereClause = `WHERE ${conditions.join(' AND ')}`;
const rows = db.prepare(`
SELECT
SELECT DISTINCT
f.id, f.name, f.student_id, f.category, f.created_at,
s.lrn, s.first_name, s.last_name, s.status as student_status,
(SELECT COUNT(*) FROM documents d
WHERE d.student_id = f.student_id AND d.deleted_at IS NULL) as document_count
FROM document_folders f
${teacherJoinSql}
JOIN students s ON f.student_id = s.id
${whereClause}
AND f.category = 'root'
File diff suppressed because it is too large. Load diff
+283
View File
@@ -0,0 +1,283 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const archiver = require('archiver');
const unzipper = require('unzipper');
const db = require('../config/db');
const INFO_FILE = path.resolve('./data/backup_info.json');
const LOGS_FILE = path.resolve('./data/backup_logs.txt');
const SCHEMA_VERSION = '2'; // bump when backup format changes
// ── Logging helpers ──────────────────────────────────────────────────────────
function logActivity(action) {
const timestamp = new Date().toISOString();
let info = {};
if (fs.existsSync(INFO_FILE)) {
try { info = JSON.parse(fs.readFileSync(INFO_FILE, 'utf8')); } catch (e) {}
}
if (action === 'backup') info.lastBackup = timestamp;
if (action === 'restore') info.lastRestore = timestamp;
fs.writeFileSync(INFO_FILE, JSON.stringify(info, null, 2));
fs.appendFileSync(LOGS_FILE, `[${timestamp}] ${action.toUpperCase()} completed successfully.\n`);
}
function safeRemoveDirSync(dirPath) {
if (!dirPath || !fs.existsSync(dirPath)) return;
try { fs.rmSync(dirPath, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); }
catch (e) { console.warn(`[Backup] Could not remove ${dirPath}: ${e.message}`); }
}
function safeRemoveFileSync(filePath) {
if (!filePath || !fs.existsSync(filePath)) return;
try { fs.unlinkSync(filePath); }
catch (e) { console.warn(`[Backup] Could not remove ${filePath}: ${e.message}`); }
}
// ── SHA-256 checksum ──────────────────────────────────────────────────────────
function sha256File(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePath);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
// ── Validate SQLite magic bytes ───────────────────────────────────────────────
function isSqliteFile(filePath) {
try {
const fd = fs.openSync(filePath, 'r');
const buffer = Buffer.alloc(16);
fs.readSync(fd, buffer, 0, 16, 0);
fs.closeSync(fd);
return buffer.toString('utf8', 0, 15) === 'SQLite format 3';
} catch (e) { return false; }
}
// ── GET /api/backup/info ──────────────────────────────────────────────────────
exports.getBackupInfo = (req, res) => {
let info = { lastBackup: null, lastRestore: null };
if (fs.existsSync(INFO_FILE)) {
try { info = JSON.parse(fs.readFileSync(INFO_FILE, 'utf8')); } catch (e) {}
}
res.json(info);
};
// ── GET /api/backup/download ──────────────────────────────────────────────────
exports.downloadBackup = async (req, res) => {
const tempDbPath = path.resolve('./data', `temp_backup_${Date.now()}_tis_rms.db`);
try {
const dataDir = path.resolve('./data');
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
const dateStr = new Date().toISOString().split('T')[0];
const zipFileName = `tis_rms_backup_${dateStr}.zip`;
// 1. Online SQLite backup
console.log('[Backup] Running online SQLite backup…');
await db.backup(tempDbPath);
// 2. Build manifest with checksums
const dbChecksum = await sha256File(tempDbPath);
const manifest = {
schema_version: SCHEMA_VERSION,
created_at: new Date().toISOString(),
db_checksum_sha256: dbChecksum,
includes: ['tis_rms.db', 'students/', 'templates/'],
};
// 3. Stream ZIP to response
res.setHeader('Content-Disposition', `attachment; filename="${zipFileName}"`);
res.setHeader('Content-Type', 'application/zip');
const archive = archiver('zip', { zlib: { level: 6 } });
archive.on('warning', (err) => {
if (err.code !== 'ENOENT') throw err;
console.warn('[Backup] archiver warning:', err.message);
});
archive.on('error', (err) => {
console.error('[Backup] archiver error:', err);
if (!res.headersSent) res.status(500).json({ message: 'Backup archive failed', error: err.message });
});
archive.pipe(res);
// DB file
archive.file(tempDbPath, { name: 'tis_rms.db' });
// Students directory
const studentsDir = process.env.STUDENT_DIR_ROOT
? path.resolve(process.env.STUDENT_DIR_ROOT)
: path.resolve('./data/students');
if (fs.existsSync(studentsDir)) {
archive.directory(studentsDir, 'students');
}
// Templates directory
const templatesDir = process.env.TEMPLATES_DIR
? path.resolve(process.env.TEMPLATES_DIR)
: path.resolve('./data/templates');
if (fs.existsSync(templatesDir)) {
archive.directory(templatesDir, 'templates');
}
// Manifest
archive.append(JSON.stringify(manifest, null, 2), { name: 'manifest.json' });
await archive.finalize();
// Cleanup temp DB after stream finishes
res.on('finish', () => {
safeRemoveFileSync(tempDbPath);
logActivity('backup');
});
} catch (error) {
safeRemoveFileSync(tempDbPath);
console.error('[Backup] Download error:', error);
if (!res.headersSent) {
res.status(500).json({ message: 'Failed to generate backup', error: error.message });
}
}
};
// ── POST /api/backup/restore ──────────────────────────────────────────────────
exports.restoreBackup = async (req, res) => {
let tempExtractDir = null;
try {
if (!req.file) return res.status(400).json({ message: 'No backup file provided.' });
const filePath = req.file.path;
const originalName = (req.file.originalname || '').toLowerCase();
const isZip = originalName.endsWith('.zip');
const isDb = originalName.endsWith('.db') || originalName.endsWith('.sqlite') || originalName.endsWith('.sqlite3');
if (!isZip && !isDb) {
safeRemoveFileSync(filePath);
return res.status(400).json({ message: 'Invalid backup file. Provide a .zip backup (recommended) or a legacy .db file.' });
}
// ── Legacy DB-only restore ────────────────────────────────────────────
if (isDb) {
if (!isSqliteFile(filePath)) {
safeRemoveFileSync(filePath);
return res.status(400).json({ message: 'Invalid SQLite file.' });
}
console.log('[Backup] Legacy DB-only restore…');
const safetyPath = path.resolve('./data', `pre_restore_${Date.now()}.db`);
await db.backup(safetyPath);
db.close();
fs.copyFileSync(filePath, path.resolve('./data/tis_rms.db'));
['.db-wal', '.db-shm'].forEach(ext => {
const p = path.resolve('./data/tis_rms' + ext);
if (fs.existsSync(p)) safeRemoveFileSync(p);
});
safeRemoveFileSync(filePath);
logActivity('restore');
res.json({ success: true, message: 'Database restored (legacy mode — no file restore). Server will restart.', warning: 'Student and template files were not included in this backup.' });
setTimeout(() => process.exit(0), 2000);
return;
}
// ── ZIP restore ───────────────────────────────────────────────────────
console.log('[Backup] ZIP restore started…');
tempExtractDir = path.resolve('./data', `temp_restore_${Date.now()}`);
fs.mkdirSync(tempExtractDir, { recursive: true });
// 1. Extract
await fs.createReadStream(filePath)
.pipe(unzipper.Extract({ path: tempExtractDir }))
.promise();
// 2. Read + validate manifest
const manifestPath = path.join(tempExtractDir, 'manifest.json');
if (!fs.existsSync(manifestPath)) {
safeRemoveDirSync(tempExtractDir);
safeRemoveFileSync(filePath);
return res.status(400).json({ message: 'Invalid backup: missing manifest.json.' });
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
console.log(`[Backup] Manifest: schema_version=${manifest.schema_version}, created_at=${manifest.created_at}`);
// 3. Verify DB checksum
const extractedDbPath = path.join(tempExtractDir, 'tis_rms.db');
if (!fs.existsSync(extractedDbPath)) {
safeRemoveDirSync(tempExtractDir);
safeRemoveFileSync(filePath);
return res.status(400).json({ message: 'Invalid backup: tis_rms.db not found in archive.' });
}
if (!isSqliteFile(extractedDbPath)) {
safeRemoveDirSync(tempExtractDir);
safeRemoveFileSync(filePath);
return res.status(400).json({ message: 'Backup database file is not a valid SQLite file.' });
}
if (manifest.db_checksum_sha256) {
const actualChecksum = await sha256File(extractedDbPath);
if (actualChecksum !== manifest.db_checksum_sha256) {
safeRemoveDirSync(tempExtractDir);
safeRemoveFileSync(filePath);
return res.status(400).json({ message: 'Backup integrity check failed: database checksum mismatch.' });
}
console.log('[Backup] DB checksum verified ✓');
}
// 4. Safety backup of current installation
const safetyPath = path.resolve('./data', `pre_restore_${Date.now()}.db`);
console.log(`[Backup] Creating safety backup → ${safetyPath}`);
await db.backup(safetyPath);
// 5. Close DB + overwrite
console.log('[Backup] Closing DB and restoring…');
db.close();
fs.copyFileSync(extractedDbPath, path.resolve('./data/tis_rms.db'));
['.db-wal', '.db-shm'].forEach(ext => {
const p = path.resolve('./data/tis_rms' + ext);
if (fs.existsSync(p)) safeRemoveFileSync(p);
});
// 6. Restore student files (merge)
const extractedStudentsDir = path.join(tempExtractDir, 'students');
if (fs.existsSync(extractedStudentsDir)) {
const studentsDir = process.env.STUDENT_DIR_ROOT
? path.resolve(process.env.STUDENT_DIR_ROOT)
: path.resolve('./data/students');
console.log(`[Backup] Restoring student files → ${studentsDir}`);
fs.cpSync(extractedStudentsDir, studentsDir, { recursive: true, force: true });
}
// 7. Restore template files (merge)
const extractedTemplatesDir = path.join(tempExtractDir, 'templates');
if (fs.existsSync(extractedTemplatesDir)) {
const templatesDir = process.env.TEMPLATES_DIR
? path.resolve(process.env.TEMPLATES_DIR)
: path.resolve('./data/templates');
console.log(`[Backup] Restoring template files → ${templatesDir}`);
fs.cpSync(extractedTemplatesDir, templatesDir, { recursive: true, force: true });
}
// 8. Cleanup
safeRemoveDirSync(tempExtractDir);
safeRemoveFileSync(filePath);
logActivity('restore');
res.json({ success: true, message: 'Backup restored successfully (DB + files). Server will restart.' });
setTimeout(() => {
console.log('[Backup] Restarting after restore…');
process.exit(0);
}, 2000);
} catch (error) {
console.error('[Backup] Restore error:', error);
if (tempExtractDir) safeRemoveDirSync(tempExtractDir);
if (req.file?.path && fs.existsSync(req.file.path)) safeRemoveFileSync(req.file.path);
if (!res.headersSent) {
res.status(500).json({ message: 'Failed to restore backup', error: error.message });
}
}
};
@@ -0,0 +1,133 @@
const os = require('os');
// ─────────────────────────────────────────────────────────────────────────────
// In-memory connected-users store
// Key: username (string)
// Value: { username, role, platform, ip, loginTime, lastSeen }
// ─────────────────────────────────────────────────────────────────────────────
const connectedUsers = new Map();
const SERVER_START_TIME = Date.now();
// Evict users whose lastSeen is older than 3 minutes (3 × heartbeat interval)
const EVICT_MS = 3 * 60 * 1000;
function evictStale() {
const now = Date.now();
for (const [key, user] of connectedUsers) {
if (now - new Date(user.lastSeen).getTime() > EVICT_MS) {
connectedUsers.delete(key);
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// POST /api/server/heartbeat
// Body: { username, role, platform, ip }
// Upserts the user into the in-memory store.
// ─────────────────────────────────────────────────────────────────────────────
exports.heartbeat = (req, res) => {
evictStale();
const { username, role, platform, ip } = req.body;
if (!username) {
return res.status(400).json({ message: 'username is required.' });
}
const now = new Date().toISOString();
const existing = connectedUsers.get(username);
const effectivePlatform = platform || req.headers['x-platform'] || req.headers['x-client-platform'] || existing?.platform || 'windows';
connectedUsers.set(username, {
username,
role: role || existing?.role || 'unknown',
platform: effectivePlatform,
ip: ip || existing?.ip || null,
loginTime: existing?.loginTime || now,
lastSeen: now,
});
res.json({ ok: true });
};
// ─────────────────────────────────────────────────────────────────────────────
// POST /api/server/logout
// Body: { username }
// Removes the user from the in-memory store.
// ─────────────────────────────────────────────────────────────────────────────
exports.logout = (req, res) => {
const { username } = req.body;
if (username) connectedUsers.delete(username);
res.json({ ok: true });
};
// ─────────────────────────────────────────────────────────────────────────────
// GET /api/server/connected-users
// Returns the current list of connected users (stale entries evicted first).
// ─────────────────────────────────────────────────────────────────────────────
exports.getConnectedUsers = (req, res) => {
evictStale();
res.json(Array.from(connectedUsers.values()));
};
// ─────────────────────────────────────────────────────────────────────────────
// GET /api/server/status
// Returns CPU %, memory usage, uptime, and connected user count.
// ─────────────────────────────────────────────────────────────────────────────
/** Computes an instantaneous CPU usage percentage by sampling os.cpus() twice. */
function getCpuPercent() {
return new Promise((resolve) => {
const cpusBefore = os.cpus();
setTimeout(() => {
const cpusAfter = os.cpus();
let totalIdle = 0;
let totalTick = 0;
for (let i = 0; i < cpusBefore.length; i++) {
const before = cpusBefore[i].times;
const after = cpusAfter[i].times;
const idleDiff = after.idle - before.idle;
const totalDiff = Object.values(after).reduce((a, v) => a + v, 0)
- Object.values(before).reduce((a, v) => a + v, 0);
totalIdle += idleDiff;
totalTick += totalDiff;
}
const percent = totalTick === 0
? 0
: parseFloat(((1 - totalIdle / totalTick) * 100).toFixed(1));
resolve(percent);
}, 200); // 200 ms sample window
});
}
exports.getServerStatus = async (req, res) => {
evictStale();
try {
const cpuPercent = await getCpuPercent();
const memTotalMB = Math.round(os.totalmem() / 1024 / 1024);
const memUsedMB = Math.round((os.totalmem() - os.freemem()) / 1024 / 1024);
const uptimeSec = Math.round((Date.now() - SERVER_START_TIME) / 1000);
let tunnelStatus = null;
try {
const { getTunnelStatus } = require('../services/tunnelService');
tunnelStatus = getTunnelStatus();
} catch (_) {}
res.json({
uptime: uptimeSec,
cpuPercent,
memUsedMB,
memTotalMB,
connectedUserCount: connectedUsers.size,
tunnel: tunnelStatus,
});
} catch (err) {
res.status(500).json({ message: 'Failed to get server status.', error: err.message });
}
};
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+103 -21
View File
@@ -14,17 +14,29 @@ const sanitizeFolderName = (str) =>
// GET /api/folders — list folders (manual + auto-created)
// ============================================================
exports.getFolders = (req, res) => {
const { studentId, parentId, search = '' } = req.query;
const isTeacher = req.user?.role === 'teacher';
const { studentId, parentId, search = '', gradeLevel = '', section = '', academicYear = '' } = req.query;
const isTeacher = req.user?.role?.toLowerCase() === 'teacher';
const teacherId = req.user?.id;
try {
// Auto-ensure root document_folders exist for all students
db.prepare(`
INSERT INTO document_folders (name, student_id, category, created_by)
SELECT s.last_name || '_' || s.first_name || '_' || s.lrn, s.id, 'root', NULL
FROM students s
WHERE s.id NOT IN (
SELECT df.student_id FROM document_folders df WHERE df.student_id IS NOT NULL AND df.parent_id IS NULL
)
`).run();
const conditions = [];
const params = [];
const whereParams = [];
const enrollmentParams = [];
const teacherParams = [];
if (studentId) {
conditions.push('f.student_id = ?');
params.push(studentId);
whereParams.push(studentId);
}
if (parentId !== undefined) {
@@ -32,43 +44,89 @@ exports.getFolders = (req, res) => {
conditions.push('f.parent_id IS NULL');
} else {
conditions.push('f.parent_id = ?');
params.push(parseInt(parentId));
whereParams.push(parseInt(parentId));
}
} else if (!studentId) {
conditions.push('f.parent_id IS NULL');
}
if (search.trim()) {
conditions.push('f.name LIKE ?');
params.push(`%${search.trim()}%`);
whereParams.push(`%${search.trim().split('').join('%')}%`);
}
// Only show folders for Enrolled students (or manual subfolders)
conditions.push("(f.student_id IS NULL OR s.status = 'Enrolled')");
// Resolve target academic year: explicitly requested or active academic year
let targetYearRange = academicYear.trim();
if (!targetYearRange || targetYearRange === 'All Years') {
const activeAy = db.prepare("SELECT year_range FROM academic_years WHERE LOWER(status) = 'active' LIMIT 1").get();
if (activeAy) {
targetYearRange = activeAy.year_range;
}
}
let enrollmentJoin = '';
if (gradeLevel.trim() || section.trim() || targetYearRange) {
enrollmentJoin = `JOIN (
SELECT e1.student_id, e1.grade_level, sec.name as section_name
FROM enrollments e1
JOIN academic_years ay1 ON e1.academic_year_id = ay1.id
LEFT JOIN sections sec ON e1.section_id = sec.id
WHERE e1.id = (
SELECT e2.id
FROM enrollments e2
JOIN academic_years ay2 ON e2.academic_year_id = ay2.id
WHERE e2.student_id = e1.student_id
${targetYearRange ? 'AND ay2.year_range = ?' : "AND LOWER(ay2.status) = 'active'"}
ORDER BY e2.id DESC
LIMIT 1
)
) latest_enr ON latest_enr.student_id = f.student_id`;
if (targetYearRange) {
enrollmentParams.push(targetYearRange);
}
if (gradeLevel.trim()) {
const gradeMatch = gradeLevel.match(/\d+/);
if (gradeMatch) {
conditions.push('latest_enr.grade_level = ?');
whereParams.push(parseInt(gradeMatch[0]));
}
}
if (section.trim()) {
conditions.push('latest_enr.section_name = ?');
whereParams.push(section.trim());
}
}
const whereClause = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
// When the caller is a teacher, restrict to folders for students in
// the teacher's assigned sections only. Uses a parameterised sub-select.
// the teacher's assigned sections only.
let teacherJoinSql = '';
if (isTeacher) {
teacherJoinSql = `JOIN (
SELECT DISTINCT e2.student_id FROM enrollments e2
JOIN teacher_sections ts2 ON ts2.section_id = e2.section_id
WHERE ts2.teacher_id = ?
) scoped ON scoped.student_id = f.student_id`;
params.unshift(teacherId);
SELECT DISTINCT e_sub.student_id FROM enrollments e_sub
JOIN teacher_sections ts_sub ON e_sub.section_id = ts_sub.section_id
WHERE ts_sub.teacher_id = ?
) ts_scope ON ts_scope.student_id = f.student_id`;
teacherParams.push(teacherId);
}
const sql = `
SELECT f.*,
s.lrn, s.first_name, s.last_name,
COALESCE(u.username, dh.username, 'Deleted User') as created_by_username,
(SELECT COUNT(*) FROM documents d WHERE d.student_id = f.student_id AND d.deleted_at IS NULL) as document_count,
(SELECT COUNT(*) FROM documents d WHERE d.student_id = f.student_id AND d.deleted_at IS NULL AND d.status = 'Completed') as document_count,
-- Which tier (JHS/SHS) applies to this student based on latest enrollment
(
SELECT CASE WHEN e_tier.grade_level <= 10 THEN 'JHS' ELSE 'SHS' END
FROM enrollments e_tier
WHERE e_tier.student_id = f.student_id
ORDER BY e_tier.id DESC LIMIT 1
SELECT CASE WHEN e_tier.grade_level <= 10 THEN 'JHS' ELSE 'SHS' END
FROM enrollments e_tier
JOIN academic_years ay ON e_tier.academic_year_id = ay.id
WHERE e_tier.student_id = f.student_id
ORDER BY e_tier.grade_level DESC, ay.year_range DESC, e_tier.id DESC LIMIT 1
) as student_tier,
-- Total enabled mandatory JHS requirements
(SELECT COUNT(*) FROM document_requirements WHERE category='JHS' AND is_mandatory=1 AND is_enabled=1) as jhs_total,
@@ -76,7 +134,7 @@ exports.getFolders = (req, res) => {
(
SELECT COUNT(DISTINCT d2.requirement_id) FROM documents d2
JOIN document_requirements dr2 ON dr2.id = d2.requirement_id
WHERE d2.student_id = f.student_id AND d2.status IN ('Completed', 'Archived') AND d2.deleted_at IS NULL
WHERE d2.student_id = f.student_id AND d2.status = 'Completed' AND d2.deleted_at IS NULL
AND dr2.category = 'JHS' AND dr2.is_mandatory = 1 AND dr2.is_enabled = 1
) as jhs_completed,
-- Total enabled mandatory SHS requirements
@@ -85,11 +143,12 @@ exports.getFolders = (req, res) => {
(
SELECT COUNT(DISTINCT d3.requirement_id) FROM documents d3
JOIN document_requirements dr3 ON dr3.id = d3.requirement_id
WHERE d3.student_id = f.student_id AND d3.status IN ('Completed', 'Archived') AND d3.deleted_at IS NULL
WHERE d3.student_id = f.student_id AND d3.status = 'Completed' AND d3.deleted_at IS NULL
AND dr3.category = 'SHS' AND dr3.is_mandatory = 1 AND dr3.is_enabled = 1
) as shs_completed
FROM document_folders f
${teacherJoinSql}
${enrollmentJoin}
LEFT JOIN students s ON f.student_id = s.id
LEFT JOIN users u ON f.created_by = u.id
LEFT JOIN deleted_users_history dh ON f.created_by = dh.deleted_user_id
@@ -97,7 +156,8 @@ exports.getFolders = (req, res) => {
ORDER BY f.name ASC
`;
const folders = db.prepare(sql).all(params);
const finalParams = [...teacherParams, ...enrollmentParams, ...whereParams];
const folders = db.prepare(sql).all(...finalParams);
res.json(folders);
} catch (error) {
console.error('getFolders error:', error);
@@ -263,6 +323,17 @@ exports.deleteFolder = (req, res) => {
// ============================================================
exports.getStudentFolder = (req, res) => {
const { studentId } = req.params;
const isTeacher = req.user?.role?.toLowerCase() === 'teacher';
if (isTeacher) {
const hasAccess = db.prepare(`
SELECT 1 FROM enrollments e
JOIN teacher_sections ts ON e.section_id = ts.section_id
WHERE e.student_id = ? AND ts.teacher_id = ?
`).get(studentId, req.user.id);
if (!hasAccess) {
return res.status(403).json({ message: "Access denied to this student's folder." });
}
}
try {
const student = db.prepare('SELECT * FROM students WHERE id = ?').get(studentId);
@@ -340,6 +411,17 @@ exports.getStudentFolder = (req, res) => {
// ============================================================
exports.syncFolders = (req, res) => {
const { studentId } = req.body;
const isTeacher = req.user?.role?.toLowerCase() === 'teacher';
if (isTeacher) {
const hasAccess = db.prepare(`
SELECT 1 FROM enrollments e
JOIN teacher_sections ts ON e.section_id = ts.section_id
WHERE e.student_id = ? AND ts.teacher_id = ?
`).get(studentId, req.user.id);
if (!hasAccess) {
return res.status(403).json({ message: "Access denied to sync this student's folder." });
}
}
try {
const student = db.prepare('SELECT * FROM students WHERE id = ?').get(studentId);
@@ -388,4 +470,4 @@ exports.syncFolders = (req, res) => {
console.error('syncFolders error:', error);
res.status(500).json({ message: 'Failed to sync folders', error: error.message });
}
};
};
+227 -23
View File
@@ -1,48 +1,180 @@
const db = require('../config/db');
const fcmService = require('../services/fcmService');
fcmService.init();
// In-memory debouncer to prevent rapid duplicate notification creation (within 2 seconds)
const _recentCreates = new Map();
// Programmatic helper to create notifications (can be called from other controllers)
// category: 'student' | 'document' | 'user' | 'system'
exports.createNotification = (userId, title, message, category = 'system') => {
exports.createNotification = (userId, title, message, category = 'system', entityType = null, entityId = null) => {
// Deduplication check: suppress identical notifications fired within 2000ms
const dedupeKey = `${userId || 'all'}_${title}_${message}_${entityType || ''}_${entityId || ''}`;
const now = Date.now();
const lastCreated = _recentCreates.get(dedupeKey);
if (lastCreated && (now - lastCreated < 2000)) {
return; // Suppress duplicate call
}
_recentCreates.set(dedupeKey, now);
// Evict old cache entries periodically
if (_recentCreates.size > 200) {
for (const [k, t] of _recentCreates.entries()) {
if (now - t > 10000) _recentCreates.delete(k);
}
}
let notifId = null;
try {
db.prepare('INSERT INTO notifications (user_id, title, message, is_read, category) VALUES (?, ?, ?, 0, ?)')
.run(userId || null, title, message, category);
const result = db.prepare('INSERT INTO notifications (user_id, title, message, is_read, category, entity_type, entity_id) VALUES (?, ?, ?, 0, ?, ?, ?)')
.run(userId || null, title, message, category, entityType, entityId);
notifId = result.lastInsertRowid;
} catch (err) {
// Gracefully fall back if category column doesn't exist yet (before migration)
try {
db.prepare('INSERT INTO notifications (user_id, title, message, is_read) VALUES (?, ?, ?, 0)')
const result2 = db.prepare('INSERT INTO notifications (user_id, title, message, is_read) VALUES (?, ?, ?, 0)')
.run(userId || null, title, message);
notifId = result2.lastInsertRowid;
} catch (err2) {
console.error('Error creating notification:', err2.message);
}
}
// Fire-and-forget real-time FCM push to target audience
fcmService.sendNotification({
userId: userId || null,
title,
body: message,
category,
entityType,
entityId,
notificationId: notifId
}).catch(() => {});
// Real-time WebSocket notification emission to active clients
try {
const { emitNotificationCreated } = require('../services/socketService');
emitNotificationCreated({
id: notifId,
userId: userId || null,
title,
message,
category,
entityType,
entityId,
createdAt: new Date().toISOString(),
});
} catch (_) {}
};
// POST /api/notifications/fcm-token
exports.registerFcmToken = (req, res) => {
const { token } = req.body;
if (!token) return res.status(400).json({ message: 'Token required' });
try {
db.prepare(`
INSERT INTO fcm_tokens (user_id, token, updated_at)
VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
ON CONFLICT(token) DO UPDATE
SET user_id = excluded.user_id,
updated_at = excluded.updated_at
`).run(req.user.id, token);
// Prune stale tokens: keep only the 3 most recently updated tokens for this user
db.prepare(`
DELETE FROM fcm_tokens
WHERE user_id = ? AND id NOT IN (
SELECT id FROM fcm_tokens
WHERE user_id = ?
ORDER BY updated_at DESC, id DESC
LIMIT 3
)
`).run(req.user.id, req.user.id);
res.json({ message: 'FCM token registered' });
} catch (error) {
res.status(500).json({ message: 'Failed to register FCM token', error: error.message });
}
};
// DELETE /api/notifications/fcm-token OR POST /api/notifications/fcm-token/unregister
exports.unregisterFcmToken = (req, res) => {
const { token } = req.body || {};
try {
if (token) {
db.prepare('DELETE FROM fcm_tokens WHERE token = ?').run(token);
} else if (req.user?.id) {
db.prepare('DELETE FROM fcm_tokens WHERE user_id = ?').run(req.user.id);
}
res.json({ message: 'FCM token unregistered successfully' });
} catch (error) {
res.status(500).json({ message: 'Failed to unregister FCM token', error: error.message });
}
};
// Common WHERE clause fragment for teacher notification scoping.
// Requires 4 bind parameters: [teacherId, teacherId, teacherId, teacherId]
const TEACHER_NOTIF_WHERE_CLAUSE = `
(
n.user_id = ?
OR
(
n.user_id IS NULL AND (
(n.entity_type = 'student' AND n.entity_id IN (
SELECT s.id FROM students s
JOIN enrollments e ON s.id = e.student_id
JOIN teacher_sections ts ON e.section_id = ts.section_id
WHERE ts.teacher_id = ?
AND e.id = (
SELECT e2.id FROM enrollments e2
JOIN academic_years ay_inner ON e2.academic_year_id = ay_inner.id
WHERE e2.student_id = s.id
ORDER BY ay_inner.year_range DESC, e2.grade_level DESC, e2.id DESC LIMIT 1
)
))
OR
(n.entity_type = 'document' AND n.entity_id IN (
SELECT d.id FROM documents d
JOIN enrollments e ON d.student_id = e.student_id
JOIN teacher_sections ts ON e.section_id = ts.section_id
WHERE ts.teacher_id = ?
AND e.id = (
SELECT e2.id FROM enrollments e2
JOIN academic_years ay_inner ON e2.academic_year_id = ay_inner.id
WHERE e2.student_id = d.student_id
ORDER BY ay_inner.year_range DESC, e2.grade_level DESC, e2.id DESC LIMIT 1
)
))
OR
(n.entity_type = 'section' AND n.entity_id IN (
SELECT ts.section_id FROM teacher_sections ts
WHERE ts.teacher_id = ?
))
)
)
)
`;
// GET /api/notifications - Get notifications for the logged-in user
// Teachers only see student/document category notifications.
// Teachers only see notifications scoped to their assigned sections or directed specifically to them.
exports.getNotifications = (req, res) => {
try {
const userId = req.user.id;
const role = req.user.role;
const isTeacher = role === 'teacher';
const userId = req.user.id;
const role = req.user.role;
const isTeacher = role?.toLowerCase() === 'teacher';
let notifications;
if (isTeacher) {
// Teachers: only student- and document-related notifications
notifications = db.prepare(`
SELECT id, user_id, title, message, is_read, created_at
FROM notifications
WHERE (user_id IS NULL OR user_id = ?)
AND (LOWER(title) LIKE '%student%'
OR LOWER(title) LIKE '%document%'
OR LOWER(title) LIKE '%enrolled%'
OR LOWER(title) LIKE '%upload%')
ORDER BY created_at DESC, id DESC
SELECT n.id, n.user_id, n.title, n.message, n.is_read, n.category, n.entity_type, n.entity_id, n.created_at
FROM notifications n
WHERE ${TEACHER_NOTIF_WHERE_CLAUSE}
ORDER BY n.created_at DESC, n.id DESC
LIMIT 50
`).all(userId);
`).all(userId, userId, userId, userId);
} else {
// Admins: all notifications
notifications = db.prepare(`
SELECT id, user_id, title, message, is_read, created_at
SELECT id, user_id, title, message, is_read, category, entity_type, entity_id, created_at
FROM notifications
WHERE user_id IS NULL OR user_id = ?
ORDER BY created_at DESC, id DESC
@@ -60,7 +192,21 @@ exports.getNotifications = (req, res) => {
exports.markAllRead = (req, res) => {
try {
const userId = req.user.id;
db.prepare('UPDATE notifications SET is_read = 1 WHERE user_id IS NULL OR user_id = ?').run(userId);
const role = req.user.role;
const isTeacher = role?.toLowerCase() === 'teacher';
if (isTeacher) {
db.prepare(`
UPDATE notifications
SET is_read = 1
WHERE id IN (
SELECT n.id FROM notifications n
WHERE ${TEACHER_NOTIF_WHERE_CLAUSE}
)
`).run(userId, userId, userId, userId);
} else {
db.prepare('UPDATE notifications SET is_read = 1 WHERE user_id IS NULL OR user_id = ?').run(userId);
}
res.json({ message: 'All notifications marked as read' });
} catch (error) {
res.status(500).json({ message: 'Failed to mark notifications as read', error: error.message });
@@ -72,8 +218,23 @@ exports.markRead = (req, res) => {
const { id } = req.params;
try {
const userId = req.user.id;
const result = db.prepare('UPDATE notifications SET is_read = 1 WHERE id = ? AND (user_id IS NULL OR user_id = ?)')
.run(id, userId);
const role = req.user.role;
const isTeacher = role?.toLowerCase() === 'teacher';
let result;
if (isTeacher) {
result = db.prepare(`
UPDATE notifications
SET is_read = 1
WHERE id = ? AND id IN (
SELECT n.id FROM notifications n
WHERE ${TEACHER_NOTIF_WHERE_CLAUSE}
)
`).run(id, userId, userId, userId, userId);
} else {
result = db.prepare('UPDATE notifications SET is_read = 1 WHERE id = ? AND (user_id IS NULL OR user_id = ?)')
.run(id, userId);
}
if (result.changes === 0) {
return res.status(404).json({ message: 'Notification not found or access denied.' });
@@ -88,10 +249,53 @@ exports.markRead = (req, res) => {
exports.clearNotifications = (req, res) => {
try {
const userId = req.user.id;
db.prepare('DELETE FROM notifications WHERE user_id IS NULL OR user_id = ?').run(userId);
const role = req.user.role;
const isTeacher = role?.toLowerCase() === 'teacher';
if (isTeacher) {
db.prepare(`
DELETE FROM notifications
WHERE id IN (
SELECT n.id FROM notifications n
WHERE ${TEACHER_NOTIF_WHERE_CLAUSE}
)
`).run(userId, userId, userId, userId);
} else {
db.prepare('DELETE FROM notifications WHERE user_id IS NULL OR user_id = ?').run(userId);
}
res.json({ message: 'All notifications cleared' });
} catch (error) {
res.status(500).json({ message: 'Failed to clear notifications', error: error.message });
}
};
// DELETE /api/notifications/:id - Delete a specific notification
exports.deleteNotification = (req, res) => {
const { id } = req.params;
try {
const userId = req.user.id;
const role = req.user.role;
const isTeacher = role?.toLowerCase() === 'teacher';
let result;
if (isTeacher) {
result = db.prepare(`
DELETE FROM notifications
WHERE id = ? AND id IN (
SELECT n.id FROM notifications n
WHERE ${TEACHER_NOTIF_WHERE_CLAUSE}
)
`).run(id, userId, userId, userId, userId);
} else {
result = db.prepare('DELETE FROM notifications WHERE id = ? AND (user_id IS NULL OR user_id = ?)')
.run(id, userId);
}
if (result.changes === 0) {
return res.status(404).json({ message: 'Notification not found or access denied.' });
}
res.json({ message: 'Notification deleted' });
} catch (error) {
res.status(500).json({ message: 'Failed to delete notification', error: error.message });
}
};
+17 -69
View File
@@ -16,78 +16,26 @@ exports.extractOcrData = async (req, res) => {
const docType = req.body.docType; // 'SF9' or 'SF10'
console.log(`[OCR] Received file: ${req.file.originalname} (Type: ${req.file.mimetype})`);
let imagePathToScan = req.file.path;
let isPdf = req.file.mimetype === 'application/pdf';
// ==========================================
// 1. PDF TO IMAGE CONVERSION (Native Ghostscript)
// ==========================================
if (isPdf) {
console.log('[OCR] PDF detected. Spawning Ghostscript natively to convert to PNG...');
const saveDirectory = path.resolve('./uploads/temp_ocr/');
if (!fs.existsSync(saveDirectory)) {
fs.mkdirSync(saveDirectory, { recursive: true });
let text = '';
try {
text = await ocrParser.extractTextFromFile(req.file.path, req.file.originalname, req.file.mimetype);
} catch (extractErr) {
if (extractErr.message && extractErr.message.includes('ENOENT')) {
const missingErr = process.platform === 'win32'
? 'Tesseract/Ghostscript OCR is not installed or missing from backend folders.'
: 'Tesseract/Ghostscript OCR is missing on Linux. Install via: sudo apt-get install -y tesseract-ocr tesseract-ocr-eng ghostscript';
return res.status(500).json({
message: 'Missing OCR Program',
error: missingErr
});
}
const outputPngPath = path.join(saveDirectory, `temp_ocr_${Date.now()}.png`);
// Execute Ghostscript directly to avoid GraphicsMagick registry issues on Windows
const gsArgs = [
'-dQUIET', '-dPARANOIDSAFER', '-dBATCH', '-dNOPAUSE', '-dNOPROMPT',
'-sDEVICE=png16m', // Output format
'-dTextAlphaBits=4', '-dGraphicsAlphaBits=4', // Anti-aliasing
'-r300', // 300 DPI resolution
'-dFirstPage=1', '-dLastPage=1', // Only convert the first page
`-sOutputFile=${outputPngPath}`,
req.file.path // Input PDF file
];
try {
// Try gswin64c first, if it fails, fallback to gs
await execFileAsync('gswin64c', gsArgs);
} catch (err) {
if (err.code === 'ENOENT') {
// Fallback to gs or gswin32c if gswin64c doesn't exist
try {
await execFileAsync('gs', gsArgs);
} catch (fallbackErr) {
await execFileAsync('gswin32c', gsArgs);
}
} else {
throw err;
}
throw extractErr;
} finally {
if (fs.existsSync(req.file.path)) {
try { fs.unlinkSync(req.file.path); } catch (_) {}
}
console.log('[OCR] Ghostscript successfully generated PNG:', outputPngPath);
imagePathToScan = outputPngPath;
generatedImagePath = outputPngPath;
}
// ==========================================
// 2. READ FILE INTO BUFFER
// ==========================================
// 3. RUN NATIVE TESSERACT EXECUTABLE
// ==========================================
console.log('[OCR] Starting Native Tesseract Engine...');
// Ensure TESSDATA_PREFIX is set to the tesseract folder so it finds eng.traineddata
const tessEnv = { ...process.env, TESSDATA_PREFIX: path.join(__dirname, '..', '..', 'tesseract', 'tessdata') };
const { stdout } = await execFileAsync('tesseract', [
imagePathToScan,
'stdout', // Output to standard output instead of a file
'-l', 'eng'
], { env: tessEnv, maxBuffer: 1024 * 1024 * 10 }); // 10MB buffer just in case
const text = stdout;
console.log('[OCR] Tesseract scan complete. Processing regex...');
// Clean up immediately after reading
if (fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path);
if (generatedImagePath && fs.existsSync(generatedImagePath)) fs.unlinkSync(generatedImagePath);
// ==========================================
// ROUTE TO THE CORRECT PARSER
// ==========================================
@@ -117,4 +65,4 @@ exports.extractOcrData = async (req, res) => {
res.status(500).json({ message: 'Failed to process document OCR', error: error.message });
}
};
};
+588 -31
View File
@@ -1,9 +1,11 @@
const db = require('../config/db');
const pdfService = require('../services/pdfService');
const excelService = require('../services/excelService');
// GET /api/reports/academic-years
exports.getAcademicYears = (req, res) => {
try {
const years = db.prepare('SELECT * FROM academic_years ORDER BY id DESC').all();
const years = db.prepare('SELECT * FROM academic_years ORDER BY year_range DESC, id DESC').all();
res.json(years);
} catch (error) {
res.status(500).json({ message: 'Failed to fetch academic years', error: error.message });
@@ -19,6 +21,13 @@ exports.getStats = (req, res) => {
let whereClauses = [];
let enrollWhereClauses = [];
if (req.user?.role === 'teacher') {
whereClauses.push('e.section_id IN (SELECT section_id FROM teacher_sections WHERE teacher_id = ?)');
enrollWhereClauses.push('e.section_id IN (SELECT section_id FROM teacher_sections WHERE teacher_id = ?)');
params.push(req.user.id);
enrollParams.push(req.user.id);
}
if (academicYearId) {
whereClauses.push('e.academic_year_id = ?');
enrollWhereClauses.push('e.academic_year_id = ?');
@@ -41,15 +50,24 @@ exports.getStats = (req, res) => {
const whereSql = whereClauses.length > 0 ? 'AND ' + whereClauses.join(' AND ') : '';
const enrollWhereSql = enrollWhereClauses.length > 0 ? 'AND ' + enrollWhereClauses.join(' AND ') : '';
// 1. Fetch counts of active, dropped, transferee, graduated
// 1. Fetch counts of active, inactive, dropped, transferee, graduated, and 4Ps beneficiaries
const countsQuery = `
SELECT
COUNT(DISTINCT CASE WHEN s.status = 'Enrolled' THEN s.id END) as active,
COUNT(DISTINCT CASE WHEN s.status = 'Inactive' THEN s.id END) as inactive,
COUNT(DISTINCT CASE WHEN s.status = 'Dropped' THEN s.id END) as dropped,
COUNT(DISTINCT CASE WHEN s.status = 'Transferred' THEN s.id END) as transferee,
COUNT(DISTINCT CASE WHEN s.status = 'Graduated' THEN s.id END) as graduated
COUNT(DISTINCT CASE WHEN s.status = 'Graduated' THEN s.id END) as graduated,
COUNT(DISTINCT CASE WHEN s.is_4ps = 1 THEN s.id END) as fourPs
FROM students s
LEFT JOIN enrollments e ON s.id = e.student_id
JOIN enrollments e ON s.id = e.student_id
AND e.id = (
SELECT e2.id FROM enrollments e2
JOIN academic_years ay ON e2.academic_year_id = ay.id
WHERE e2.student_id = s.id
${academicYearId ? 'AND e2.academic_year_id = ' + Number(academicYearId) : ''}
ORDER BY e2.grade_level DESC, ay.year_range DESC, e2.id DESC LIMIT 1
)
WHERE 1=1 ${enrollWhereSql}
`;
const studentCounts = db.prepare(countsQuery).get(enrollParams);
@@ -60,17 +78,26 @@ exports.getStats = (req, res) => {
FROM document_requirements r
CROSS JOIN students s
JOIN enrollments e ON s.id = e.student_id
AND e.id = (
SELECT e2.id FROM enrollments e2
JOIN academic_years ay ON e2.academic_year_id = ay.id
WHERE e2.student_id = s.id
${academicYearId ? 'AND e2.academic_year_id = ' + Number(academicYearId) : ''}
ORDER BY e2.grade_level DESC, ay.year_range DESC, e2.id DESC LIMIT 1
)
WHERE r.is_enabled = 1
AND (
(r.category = 'JHS' AND e.grade_level BETWEEN 7 AND 10)
OR (r.category = 'SHS' AND e.grade_level BETWEEN 11 AND 12)
AND r.is_mandatory = 1
AND r.category IN (
SELECT DISTINCT CASE WHEN grade_level <= 10 THEN 'JHS' ELSE 'SHS' END
FROM enrollments WHERE student_id = s.id
)
${whereSql}
AND NOT EXISTS (
SELECT 1 FROM documents d
WHERE d.student_id = s.id
AND d.requirement_id = r.id
AND d.status IN ('Completed', 'Archived')
AND (d.status = 'Completed' OR (d.status = 'Archived' AND s.status != 'Enrolled'))
AND d.deleted_at IS NULL
)
GROUP BY r.id, r.category, r.name
ORDER BY count DESC
@@ -87,42 +114,52 @@ exports.getStats = (req, res) => {
const studentsQuery = `
SELECT s.id, s.lrn, s.first_name, s.last_name, s.sex, s.status,
MAX(e.grade_level) as grade_level, sec.name as section_name,
e_latest.grade_level, sec.name as section_name,
(
SELECT COUNT(*)
FROM document_requirements r
WHERE r.is_enabled = 1
AND (
(r.category = 'JHS' AND MAX(e.grade_level) BETWEEN 7 AND 10)
OR (r.category = 'SHS' AND MAX(e.grade_level) BETWEEN 11 AND 12)
AND r.is_mandatory = 1
AND r.category IN (
SELECT DISTINCT CASE WHEN grade_level <= 10 THEN 'JHS' ELSE 'SHS' END
FROM enrollments WHERE student_id = s.id
)
AND NOT EXISTS (
SELECT 1 FROM documents d
WHERE d.student_id = s.id
AND d.requirement_id = r.id
AND d.status IN ('Completed', 'Archived')
AND (d.status = 'Completed' OR (d.status = 'Archived' AND s.status != 'Enrolled'))
AND d.deleted_at IS NULL
)
) as missing_count,
(
SELECT group_concat('[' || r.category || '] ' || r.name, ', ')
FROM document_requirements r
WHERE r.is_enabled = 1
AND (
(r.category = 'JHS' AND MAX(e.grade_level) BETWEEN 7 AND 10)
OR (r.category = 'SHS' AND MAX(e.grade_level) BETWEEN 11 AND 12)
AND r.is_mandatory = 1
AND r.category IN (
SELECT DISTINCT CASE WHEN grade_level <= 10 THEN 'JHS' ELSE 'SHS' END
FROM enrollments WHERE student_id = s.id
)
AND NOT EXISTS (
SELECT 1 FROM documents d
WHERE d.student_id = s.id
AND d.requirement_id = r.id
AND d.status IN ('Completed', 'Archived')
AND (d.status = 'Completed' OR (d.status = 'Archived' AND s.status != 'Enrolled'))
AND d.deleted_at IS NULL
)
) as missing_requirements
FROM students s
JOIN enrollments e ON s.id = e.student_id
LEFT JOIN sections sec ON e.section_id = sec.id
WHERE 1=1 ${studentFilterSql}
GROUP BY s.id
JOIN enrollments e_latest ON s.id = e_latest.student_id
AND e_latest.id = (
SELECT e2.id FROM enrollments e2
JOIN academic_years ay ON e2.academic_year_id = ay.id
WHERE e2.student_id = s.id
${academicYearId ? 'AND e2.academic_year_id = ' + Number(academicYearId) : ''}
ORDER BY e2.grade_level DESC, ay.year_range DESC, e2.id DESC LIMIT 1
)
LEFT JOIN sections sec ON e_latest.section_id = sec.id
WHERE 1=1 ${studentFilterSql.replace(/\be\./g, 'e_latest.')}
ORDER BY s.last_name ASC, s.first_name ASC
LIMIT 1000
`;
@@ -169,7 +206,7 @@ exports.getEnrollmentByGrade = (req, res) => {
exports.getDocumentStatus = (req, res) => {
try {
const rows = db.prepare(`
SELECT status, COUNT(*) as count FROM documents GROUP BY status
SELECT status, COUNT(*) as count FROM documents WHERE deleted_at IS NULL GROUP BY status
`).all();
const result = { Completed: 0, Archived: 0 };
rows.forEach(r => { if (r.status in result) result[r.status] = r.count; });
@@ -207,7 +244,7 @@ exports.getExportData = (req, res) => {
// Document status breakdown
const docStatusRows = db.prepare(`
SELECT status, COUNT(*) as count FROM documents GROUP BY status
SELECT status, COUNT(*) as count FROM documents WHERE deleted_at IS NULL GROUP BY status
`).all();
const documentStatus = { Completed: 0, Archived: 0 };
docStatusRows.forEach(r => { if (r.status in documentStatus) documentStatus[r.status] = r.count; });
@@ -221,7 +258,7 @@ exports.getExportData = (req, res) => {
COUNT(CASE WHEN d.status = 'Archived' THEN 1 END) as archived_docs
FROM students s
JOIN enrollments e ON s.id = e.student_id AND e.academic_year_id = ?
LEFT JOIN documents d ON s.id = d.student_id
LEFT JOIN documents d ON s.id = d.student_id AND d.deleted_at IS NULL
GROUP BY s.id
ORDER BY s.last_name ASC
LIMIT 500
@@ -229,12 +266,18 @@ exports.getExportData = (req, res) => {
} else {
students = db.prepare(`
SELECT s.lrn, s.first_name, s.last_name, s.sex,
MAX(e.grade_level) as grade_level,
e_latest.grade_level,
COUNT(CASE WHEN d.status = 'Completed' THEN 1 END) as verified_docs,
COUNT(CASE WHEN d.status = 'Archived' THEN 1 END) as archived_docs
FROM students s
LEFT JOIN enrollments e ON s.id = e.student_id
LEFT JOIN documents d ON s.id = d.student_id
LEFT JOIN enrollments e_latest ON s.id = e_latest.student_id
AND e_latest.id = (
SELECT e2.id FROM enrollments e2
JOIN academic_years ay ON e2.academic_year_id = ay.id
WHERE e2.student_id = s.id
ORDER BY e2.grade_level DESC, ay.year_range DESC, e2.id DESC LIMIT 1
)
LEFT JOIN documents d ON s.id = d.student_id AND d.deleted_at IS NULL
GROUP BY s.id
ORDER BY s.last_name ASC
LIMIT 500
@@ -254,6 +297,7 @@ exports.getYearlyComparison = (req, res) => {
SELECT
ay.year_range as year,
COUNT(DISTINCT CASE WHEN (CASE WHEN e.academic_year_id = sly.max_ay_id THEN s.status ELSE 'Enrolled' END) = 'Enrolled' THEN s.id END) as enrolled,
COUNT(DISTINCT CASE WHEN (CASE WHEN e.academic_year_id = sly.max_ay_id THEN s.status ELSE 'Enrolled' END) = 'Inactive' THEN s.id END) as inactive,
COUNT(DISTINCT CASE WHEN (CASE WHEN e.academic_year_id = sly.max_ay_id THEN s.status ELSE 'Enrolled' END) = 'Dropped' THEN s.id END) as dropped,
COUNT(DISTINCT CASE WHEN (CASE WHEN e.academic_year_id = sly.max_ay_id THEN s.status ELSE 'Enrolled' END) = 'Graduated' THEN s.id END) as graduated,
COUNT(DISTINCT CASE WHEN (CASE WHEN e.academic_year_id = sly.max_ay_id THEN s.status ELSE 'Enrolled' END) = 'Transferred' THEN s.id END) as transferred
@@ -273,13 +317,526 @@ exports.getYearlyComparison = (req, res) => {
)
) sly ON s.id = sly.student_id
GROUP BY ay.id, ay.year_range
ORDER BY ay.id DESC
LIMIT 5
ORDER BY ay.year_range ASC
`;
const data = db.prepare(query).all();
// Return oldest first for charting (left to right)
res.json(data.reverse());
res.json(data);
} catch (error) {
res.status(500).json({ message: 'Failed to fetch yearly comparison', error: error.message });
}
};
// GET /api/reports/storage
exports.getStorageUsed = async (req, res) => {
const fs = require('fs');
const path = require('path');
const { promisify } = require('util');
const stat = promisify(fs.stat);
try {
const rootDir = path.join(__dirname, '../../');
const dbPath = process.env.DB_PATH
? path.resolve(process.env.DB_PATH)
: path.join(rootDir, 'data', 'tis_rms.db');
const dbWalPath = `${dbPath}-wal`;
const dbShmPath = `${dbPath}-shm`;
const legacyDbPath = path.join(rootDir, 'tis_rms.db');
let totalSize = 0;
const addFileSize = async (filePath) => {
try {
const fileStat = await stat(filePath);
if (fileStat.isFile()) {
totalSize += fileStat.size;
}
} catch (e) {
// Ignore missing file
}
};
await addFileSize(dbPath);
await addFileSize(dbWalPath);
await addFileSize(dbShmPath);
if (legacyDbPath !== dbPath) {
await addFileSize(legacyDbPath);
}
res.json({ bytes: totalSize });
} catch (error) {
res.status(500).json({ message: 'Failed to fetch storage usage', error: error.message });
}
};
// GET /api/reports/transparency-board
function fetchTransparencyBoardPayload(query = {}) {
const { academicYearId, yearIds } = query;
let academicYears = [];
if (yearIds) {
const ids = yearIds.toString().split(',').map(id => Number(id.trim())).filter(id => !isNaN(id));
if (ids.length > 0) {
const placeholders = ids.map(() => '?').join(',');
academicYears = db.prepare(`
SELECT id, year_range
FROM academic_years
WHERE id IN (${placeholders})
ORDER BY year_range ASC
`).all(...ids);
}
} else if (academicYearId) {
const targetYear = db.prepare('SELECT id, year_range FROM academic_years WHERE id = ?').get(academicYearId);
if (targetYear) {
academicYears = db.prepare(`
SELECT id, year_range
FROM academic_years
WHERE year_range <= ?
ORDER BY year_range DESC
LIMIT 3
`).all(targetYear.year_range).reverse();
}
}
if (academicYears.length === 0) {
const activeAy = db.prepare("SELECT id, year_range FROM academic_years WHERE status = 'active' LIMIT 1").get()
|| db.prepare("SELECT id, year_range FROM academic_years ORDER BY year_range DESC LIMIT 1").get();
if (activeAy) {
academicYears = db.prepare(`
SELECT id, year_range
FROM academic_years
WHERE year_range <= ?
ORDER BY year_range DESC
LIMIT 3
`).all(activeAy.year_range).reverse();
} else {
academicYears = db.prepare(`
SELECT id, year_range
FROM academic_years
ORDER BY year_range DESC
LIMIT 3
`).all().reverse();
}
}
const yearsData = academicYears.map(ay => {
// Enrollment breakdown by sex & grade — COUNT DISTINCT students
// to avoid double-counting students with multiple enrollment rows.
const enrollRows = db.prepare(`
SELECT
e.grade_level,
COUNT(DISTINCT CASE WHEN s.sex = 'Male' THEN s.id END) as male,
COUNT(DISTINCT CASE WHEN s.sex = 'Female' THEN s.id END) as female,
COUNT(DISTINCT s.id) as total
FROM enrollments e
JOIN students s ON e.student_id = s.id
WHERE e.academic_year_id = ?
GROUP BY e.grade_level
`).all(ay.id);
const gradesEnrollment = [7, 8, 9, 10, 11, 12].map(g => {
const row = enrollRows.find(r => Number(r.grade_level) === g) || { male: 0, female: 0, total: 0 };
return {
gradeLevel: g,
male: Number(row.male) || 0,
female: Number(row.female) || 0,
total: Number(row.total) || 0
};
});
const jhsTotal = gradesEnrollment.filter(g => g.gradeLevel <= 10).reduce((acc, curr) => ({
male: acc.male + curr.male,
female: acc.female + curr.female,
total: acc.total + curr.total
}), { male: 0, female: 0, total: 0 });
const shsTotal = gradesEnrollment.filter(g => g.gradeLevel > 10).reduce((acc, curr) => ({
male: acc.male + curr.male,
female: acc.female + curr.female,
total: acc.total + curr.total
}), { male: 0, female: 0, total: 0 });
const overallTotal = {
male: jhsTotal.male + shsTotal.male,
female: jhsTotal.female + shsTotal.female,
total: jhsTotal.total + shsTotal.total
};
// Dropout breakdown by grade
const dropoutRows = db.prepare(`
SELECT
e.grade_level,
COUNT(DISTINCT s.id) as droppedCount
FROM enrollments e
JOIN students s ON e.student_id = s.id
WHERE e.academic_year_id = ?
AND s.status = 'Dropped'
AND e.academic_year_id = (SELECT MAX(e2.academic_year_id) FROM enrollments e2 WHERE e2.student_id = s.id)
GROUP BY e.grade_level
`).all(ay.id);
const gradesDropout = [7, 8, 9, 10, 11, 12].map(g => {
const row = dropoutRows.find(r => Number(r.grade_level) === g) || { droppedCount: 0 };
return {
gradeLevel: g,
droppedCount: Number(row.droppedCount) || 0
};
});
const totalDropped = gradesDropout.reduce((sum, g) => sum + g.droppedCount, 0);
// Transferee breakdown by grade
const transfereeRows = db.prepare(`
SELECT
e.grade_level,
COUNT(DISTINCT s.id) as transferredCount
FROM enrollments e
JOIN students s ON e.student_id = s.id
WHERE e.academic_year_id = ?
AND s.status = 'Transferred'
AND e.academic_year_id = (SELECT MAX(e2.academic_year_id) FROM enrollments e2 WHERE e2.student_id = s.id)
GROUP BY e.grade_level
`).all(ay.id);
const gradesTransferee = [7, 8, 9, 10, 11, 12].map(g => {
const row = transfereeRows.find(r => Number(r.grade_level) === g) || { transferredCount: 0 };
return {
gradeLevel: g,
transferredCount: Number(row.transferredCount) || 0
};
});
const totalTransferred = gradesTransferee.reduce((sum, g) => sum + g.transferredCount, 0);
// 4Ps breakdown by grade for this specific academic year
const fourPsRows = db.prepare(`
SELECT
e.grade_level,
COUNT(DISTINCT CASE WHEN s.is_4ps = 1 THEN s.id END) as fourPsCount,
COUNT(DISTINCT s.id) as totalStudents
FROM enrollments e
JOIN students s ON e.student_id = s.id
WHERE e.academic_year_id = ?
GROUP BY e.grade_level
`).all(ay.id);
const grades4Ps = [7, 8, 9, 10, 11, 12].map(g => {
const row = fourPsRows.find(r => Number(r.grade_level) === g) || { fourPsCount: 0, totalStudents: 0 };
const count = Number(row.fourPsCount) || 0;
const total = Number(row.totalStudents) || 0;
const percentage = total > 0 ? Number((count / total * 100).toFixed(1)) : 0;
return {
gradeLevel: g,
fourPsCount: count,
totalStudents: total,
percentage
};
});
const jhs4PsCount = grades4Ps.filter(g => g.gradeLevel <= 10).reduce((sum, g) => sum + g.fourPsCount, 0);
const jhs4PsTotalStudents = grades4Ps.filter(g => g.gradeLevel <= 10).reduce((sum, g) => sum + g.totalStudents, 0);
const jhs4PsPercentage = jhs4PsTotalStudents > 0 ? Number((jhs4PsCount / jhs4PsTotalStudents * 100).toFixed(1)) : 0;
const shs4PsCount = grades4Ps.filter(g => g.gradeLevel > 10).reduce((sum, g) => sum + g.fourPsCount, 0);
const shs4PsTotalStudents = grades4Ps.filter(g => g.gradeLevel > 10).reduce((sum, g) => sum + g.totalStudents, 0);
const shs4PsPercentage = shs4PsTotalStudents > 0 ? Number((shs4PsCount / shs4PsTotalStudents * 100).toFixed(1)) : 0;
const total4Ps = grades4Ps.reduce((sum, g) => sum + g.fourPsCount, 0);
const totalStudents4Ps = grades4Ps.reduce((sum, g) => sum + g.totalStudents, 0);
const overallPercentage = totalStudents4Ps > 0 ? Number((total4Ps / totalStudents4Ps * 100).toFixed(1)) : 0;
const fourPs = {
grades: grades4Ps,
jhsTotal: {
fourPsCount: jhs4PsCount,
totalStudents: jhs4PsTotalStudents,
percentage: jhs4PsPercentage
},
shsTotal: {
fourPsCount: shs4PsCount,
totalStudents: shs4PsTotalStudents,
percentage: shs4PsPercentage
},
overallTotal: {
fourPsCount: total4Ps,
totalStudents: totalStudents4Ps,
percentage: overallPercentage
}
};
return {
yearRange: ay.year_range,
enrollment: {
grades: gradesEnrollment,
jhsTotal,
shsTotal,
overallTotal
},
dropouts: {
grades: gradesDropout,
totalDropped
},
transferees: {
grades: gradesTransferee,
totalTransferred
},
fourPs
};
});
// Top level equity4Ps uses the active / selected year's 4Ps data
const latestYearData = yearsData.length > 0 ? yearsData[yearsData.length - 1] : null;
const equity4Ps = latestYearData?.fourPs
? {
grades: latestYearData.fourPs.grades,
total4Ps: latestYearData.fourPs.overallTotal.fourPsCount,
totalStudents: latestYearData.fourPs.overallTotal.totalStudents,
overallPercentage: latestYearData.fourPs.overallTotal.percentage
}
: {
grades: [7, 8, 9, 10, 11, 12].map(g => ({ gradeLevel: g, fourPsCount: 0, totalStudents: 0, percentage: 0 })),
total4Ps: 0,
totalStudents: 0,
overallPercentage: 0
};
return {
years: yearsData,
equity4Ps
};
}
// GET /api/reports/transparency-board
exports.getTransparencyBoardData = (req, res) => {
try {
const data = fetchTransparencyBoardPayload(req.query);
res.json(data);
} catch (error) {
res.status(500).json({ message: 'Failed to fetch transparency board data', error: error.message });
}
};
// GET /api/reports/transparency-board/pdf
exports.generateTransparencyBoardPdf = async (req, res) => {
try {
const data = fetchTransparencyBoardPayload(req.query);
const { schoolName, divisionName, regionName, category } = req.query;
const normalizedCategory = (category || 'all').toLowerCase();
const pdfBuffer = await pdfService.generateTransparencyBoardPdf(data, {
schoolName,
divisionName,
regionName,
category: normalizedCategory,
});
const latestYear = data.years && data.years.length > 0 ? data.years[data.years.length - 1] : null;
const yearLabel = latestYear ? latestYear.yearRange.replace(/-/g, '_') : 'All';
let filename = `DepEd_Transparency_Board_SY_${yearLabel}.pdf`;
if (normalizedCategory === 'enrollment') {
filename = `DepEd_Enrollment_Report_SY_${yearLabel}.pdf`;
} else if (normalizedCategory === 'dropouts_transferees' || normalizedCategory === 'dropouts') {
filename = `DepEd_Dropouts_Transferees_SY_${yearLabel}.pdf`;
} else if (normalizedCategory === '4ps' || normalizedCategory === 'four_ps' || normalizedCategory === 'equity4ps') {
filename = `DepEd_4Ps_Beneficiaries_SY_${yearLabel}.pdf`;
}
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
res.send(pdfBuffer);
} catch (error) {
console.error('generateTransparencyBoardPdf error:', error);
res.status(500).json({ message: 'Failed to generate transparency board PDF', error: error.message });
}
};
// Helper to fetch compliance stats data for Excel export
function fetchComplianceStatsPayload(query = {}, user = null) {
const { academicYearId, gradeLevel, sectionId, status } = query;
let params = [];
let enrollParams = [];
let whereClauses = [];
let enrollWhereClauses = [];
if (user?.role === 'teacher') {
whereClauses.push('e.section_id IN (SELECT section_id FROM teacher_sections WHERE teacher_id = ?)');
enrollWhereClauses.push('e.section_id IN (SELECT section_id FROM teacher_sections WHERE teacher_id = ?)');
params.push(user.id);
enrollParams.push(user.id);
}
if (academicYearId) {
whereClauses.push('e.academic_year_id = ?');
enrollWhereClauses.push('e.academic_year_id = ?');
params.push(academicYearId);
enrollParams.push(academicYearId);
}
if (gradeLevel) {
whereClauses.push('e.grade_level = ?');
enrollWhereClauses.push('e.grade_level = ?');
params.push(gradeLevel);
enrollParams.push(gradeLevel);
}
if (sectionId) {
whereClauses.push('e.section_id = ?');
enrollWhereClauses.push('e.section_id = ?');
params.push(sectionId);
enrollParams.push(sectionId);
}
const whereSql = whereClauses.length > 0 ? 'AND ' + whereClauses.join(' AND ') : '';
const enrollWhereSql = enrollWhereClauses.length > 0 ? 'AND ' + enrollWhereClauses.join(' AND ') : '';
const countsQuery = `
SELECT
COUNT(DISTINCT CASE WHEN s.status = 'Enrolled' THEN s.id END) as active,
COUNT(DISTINCT CASE WHEN s.status = 'Inactive' THEN s.id END) as inactive,
COUNT(DISTINCT CASE WHEN s.status = 'Dropped' THEN s.id END) as dropped,
COUNT(DISTINCT CASE WHEN s.status = 'Transferred' THEN s.id END) as transferee,
COUNT(DISTINCT CASE WHEN s.status = 'Graduated' THEN s.id END) as graduated,
COUNT(DISTINCT CASE WHEN s.is_4ps = 1 THEN s.id END) as fourPs
FROM students s
JOIN enrollments e ON s.id = e.student_id
AND e.id = (
SELECT e2.id FROM enrollments e2
JOIN academic_years ay ON e2.academic_year_id = ay.id
WHERE e2.student_id = s.id
${academicYearId ? 'AND e2.academic_year_id = ' + Number(academicYearId) : ''}
ORDER BY e2.grade_level DESC, ay.year_range DESC, e2.id DESC LIMIT 1
)
WHERE 1=1 ${enrollWhereSql}
`;
const studentCounts = db.prepare(countsQuery).get(enrollParams) || {};
const missingQuery = `
SELECT r.id as requirementId, r.category || ' - ' || r.name as name, COUNT(DISTINCT s.id) as count
FROM document_requirements r
CROSS JOIN students s
JOIN enrollments e ON s.id = e.student_id
AND e.id = (
SELECT e2.id FROM enrollments e2
JOIN academic_years ay ON e2.academic_year_id = ay.id
WHERE e2.student_id = s.id
${academicYearId ? 'AND e2.academic_year_id = ' + Number(academicYearId) : ''}
ORDER BY e2.grade_level DESC, ay.year_range DESC, e2.id DESC LIMIT 1
)
WHERE r.is_enabled = 1
AND r.is_mandatory = 1
AND r.category IN (
SELECT DISTINCT CASE WHEN grade_level <= 10 THEN 'JHS' ELSE 'SHS' END
FROM enrollments WHERE student_id = s.id
)
${whereSql}
AND NOT EXISTS (
SELECT 1 FROM documents d
WHERE d.student_id = s.id
AND d.requirement_id = r.id
AND (d.status = 'Completed' OR (d.status = 'Archived' AND s.status != 'Enrolled'))
AND d.deleted_at IS NULL
)
GROUP BY r.id, r.category, r.name
ORDER BY count DESC
`;
const missingDocsBreakdown = db.prepare(missingQuery).all(params);
let studentFilterSql = whereSql;
let studentParams = [...params];
if (status) {
studentFilterSql += " AND s.status = ?";
studentParams.push(status);
}
const studentsQuery = `
SELECT s.id, s.lrn, s.first_name, s.last_name, s.sex, s.status,
e_latest.grade_level, sec.name as section_name,
(
SELECT COUNT(*)
FROM document_requirements r
WHERE r.is_enabled = 1
AND r.is_mandatory = 1
AND r.category IN (
SELECT DISTINCT CASE WHEN grade_level <= 10 THEN 'JHS' ELSE 'SHS' END
FROM enrollments WHERE student_id = s.id
)
AND NOT EXISTS (
SELECT 1 FROM documents d
WHERE d.student_id = s.id
AND d.requirement_id = r.id
AND (d.status = 'Completed' OR (d.status = 'Archived' AND s.status != 'Enrolled'))
AND d.deleted_at IS NULL
)
) as missing_count,
(
SELECT group_concat('[' || r.category || '] ' || r.name, ', ')
FROM document_requirements r
WHERE r.is_enabled = 1
AND r.is_mandatory = 1
AND r.category IN (
SELECT DISTINCT CASE WHEN grade_level <= 10 THEN 'JHS' ELSE 'SHS' END
FROM enrollments WHERE student_id = s.id
)
AND NOT EXISTS (
SELECT 1 FROM documents d
WHERE d.student_id = s.id
AND d.requirement_id = r.id
AND (d.status = 'Completed' OR (d.status = 'Archived' AND s.status != 'Enrolled'))
AND d.deleted_at IS NULL
)
) as missing_requirements
FROM students s
JOIN enrollments e_latest ON s.id = e_latest.student_id
AND e_latest.id = (
SELECT e2.id FROM enrollments e2
JOIN academic_years ay ON e2.academic_year_id = ay.id
WHERE e2.student_id = s.id
${academicYearId ? 'AND e2.academic_year_id = ' + Number(academicYearId) : ''}
ORDER BY e2.grade_level DESC, ay.year_range DESC, e2.id DESC LIMIT 1
)
LEFT JOIN sections sec ON e_latest.section_id = sec.id
WHERE 1=1 ${studentFilterSql.replace(/\be\./g, 'e_latest.')}
ORDER BY s.last_name ASC, s.first_name ASC
LIMIT 1000
`;
const studentsList = db.prepare(studentsQuery).all(studentParams);
let yearLabel = 'All Years';
if (academicYearId) {
const ay = db.prepare('SELECT year_range FROM academic_years WHERE id = ?').get(academicYearId);
if (ay && ay.year_range) {
yearLabel = ay.year_range;
}
}
return {
studentCounts,
missingDocsBreakdown,
students: studentsList,
yearLabel
};
}
// GET or POST /api/reports/compliance/excel
exports.generateComplianceReportExcel = async (req, res) => {
try {
let payload;
if (req.body && req.body.students && req.body.studentCounts) {
payload = req.body;
} else {
payload = fetchComplianceStatsPayload(req.query, req.user);
}
const excelBuffer = await excelService.generateComplianceExcel(payload);
const yearSlug = (payload.yearLabel || 'All_Years').replace(/[^a-zA-Z0-9]/g, '_');
const filename = `TIS_RMS_Report_${yearSlug}_${Date.now()}.xlsx`;
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(excelBuffer);
} catch (error) {
console.error('generateComplianceReportExcel error:', error);
res.status(500).json({ message: 'Failed to generate compliance report Excel', error: error.message });
}
};
@@ -27,13 +27,13 @@ exports.getRequirements = (req, res) => {
if (search.trim()) {
conditions.push('(name LIKE ? OR description LIKE ?)');
params.push(`%${search.trim()}%`, `%${search.trim()}%`);
params.push(`%${search.trim().split('').join('%' )}%`, `%${search.trim().split('').join('%' )}%`);
}
const whereClause = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const sql = `
SELECT id, category || ' - ' || name as name, description, category, is_mandatory, is_enabled, due_date, accepted_file_types, school_levels, created_at, updated_at
SELECT id, category || ' - ' || name as name, description, category, is_mandatory, is_enabled, due_date, accepted_file_types, school_levels, max_files, created_at, updated_at
FROM document_requirements
${whereClause}
ORDER BY category ASC, name ASC
@@ -75,7 +75,9 @@ exports.createRequirement = (req, res) => {
isEnabled = true,
dueDate,
acceptedFileTypes = 'pdf,jpg,jpeg,png',
schoolLevels = 'JHS,SHS'
schoolLevels = 'JHS,SHS',
maxFiles,
max_files
} = req.body;
// Validation
@@ -94,13 +96,16 @@ exports.createRequirement = (req, res) => {
return res.status(409).json({ message: 'A requirement with this name already exists for this category' });
}
const parsedMax = parseInt(maxFiles ?? max_files, 10);
const finalMaxFiles = (!isNaN(parsedMax) && parsedMax >= 1) ? parsedMax : 1;
try {
const result = db.prepare(`
INSERT INTO document_requirements (
name, description, category, is_mandatory, is_enabled,
due_date, accepted_file_types, school_levels
due_date, accepted_file_types, school_levels, max_files
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
name.trim(),
description?.trim() || null,
@@ -109,7 +114,8 @@ exports.createRequirement = (req, res) => {
isEnabled ? 1 : 0,
dueDate || null,
acceptedFileTypes,
schoolLevels
schoolLevels,
finalMaxFiles
);
res.status(201).json({
@@ -135,7 +141,9 @@ exports.updateRequirement = (req, res) => {
isEnabled,
dueDate,
acceptedFileTypes,
schoolLevels
schoolLevels,
maxFiles,
max_files
} = req.body;
const requirement = db.prepare('SELECT * FROM document_requirements WHERE id = ?').get(id);
@@ -152,6 +160,11 @@ exports.updateRequirement = (req, res) => {
}
}
const parsedMax = (maxFiles !== undefined || max_files !== undefined)
? parseInt(maxFiles ?? max_files, 10)
: null;
const finalMaxFiles = (parsedMax !== null && !isNaN(parsedMax) && parsedMax >= 1) ? parsedMax : null;
try {
db.prepare(`
UPDATE document_requirements SET
@@ -163,6 +176,7 @@ exports.updateRequirement = (req, res) => {
due_date = ?,
accepted_file_types = COALESCE(?, accepted_file_types),
school_levels = COALESCE(?, school_levels),
max_files = COALESCE(?, max_files),
updated_at = (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
WHERE id = ?
`).run(
@@ -174,6 +188,7 @@ exports.updateRequirement = (req, res) => {
dueDate,
acceptedFileTypes,
schoolLevels,
finalMaxFiles,
id
);
@@ -261,9 +276,10 @@ exports.bulkUpdateRequirements = (req, res) => {
try {
const updateStmt = db.prepare(`
UPDATE document_requirements SET
is_mandatory = ?,
is_enabled = ?,
is_mandatory = COALESCE(?, is_mandatory),
is_enabled = COALESCE(?, is_enabled),
due_date = ?,
max_files = COALESCE(?, max_files),
updated_at = (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
WHERE id = ?
`);
@@ -271,10 +287,15 @@ exports.bulkUpdateRequirements = (req, res) => {
db.transaction(() => {
for (const req of requirements) {
if (req.id) {
const parsedMax = (req.maxFiles !== undefined || req.max_files !== undefined)
? parseInt(req.maxFiles ?? req.max_files, 10)
: null;
const finalMax = (parsedMax !== null && !isNaN(parsedMax) && parsedMax >= 1) ? parsedMax : null;
updateStmt.run(
req.isMandatory ? 1 : 0,
req.isEnabled ? 1 : 0,
req.isMandatory !== undefined ? (req.isMandatory ? 1 : 0) : null,
req.isEnabled !== undefined ? (req.isEnabled ? 1 : 0) : null,
req.dueDate || null,
finalMax,
req.id
);
}
@@ -297,9 +318,10 @@ exports.getMissingRequirements = (req, res) => {
try {
// Get student's current grade level (for highlighting "current level" on the frontend)
const enrollment = db.prepare(`
SELECT grade_level FROM enrollments
WHERE student_id = ?
ORDER BY id DESC LIMIT 1
SELECT e.grade_level FROM enrollments e
JOIN academic_years ay ON e.academic_year_id = ay.id
WHERE e.student_id = ?
ORDER BY e.grade_level DESC, ay.year_range DESC, e.id DESC LIMIT 1
`).get(studentId);
if (!enrollment) {
@@ -309,32 +331,48 @@ exports.getMissingRequirements = (req, res) => {
const currentCategory = enrollment.grade_level <= 10 ? 'JHS' : 'SHS';
// ── Fetch ALL categories so frontend can split into JHS and SHS panels ──
// Missing = mandatory requirements that have no Completed document yet
// Missing = requirements (mandatory and optional) that have no Completed or Archived document yet
const missing = db.prepare(`
SELECT dr.id, dr.name, dr.description, dr.category,
dr.is_mandatory, dr.is_enabled, dr.due_date,
dr.accepted_file_types, dr.school_levels, dr.created_at, dr.updated_at
FROM document_requirements dr
WHERE dr.is_mandatory = 1
AND dr.is_enabled = 1
WHERE dr.is_enabled = 1
AND dr.category IN (
SELECT DISTINCT CASE WHEN grade_level <= 10 THEN 'JHS' ELSE 'SHS' END
FROM enrollments WHERE student_id = ?
)
AND dr.id NOT IN (
SELECT requirement_id FROM documents
WHERE student_id = ? AND status IN ('Completed', 'Archived') AND requirement_id IS NOT NULL
WHERE student_id = ? AND status IN ('Completed', 'Archived') AND requirement_id IS NOT NULL AND deleted_at IS NULL
)
ORDER BY dr.category ASC, dr.name ASC
`).all(studentId);
ORDER BY dr.category ASC, dr.is_mandatory DESC, dr.name ASC
`).all(studentId, studentId);
// Verified = requirement has at least one Completed document
// Verified = requirement has at least one Completed or Archived document
// Prioritizes 'Completed' if a requirement has both active and archived copies
const verified = db.prepare(`
SELECT DISTINCT dr.id, dr.name, dr.description, dr.category,
SELECT dr.id, dr.name, dr.description, dr.category,
dr.is_mandatory, dr.is_enabled, dr.due_date,
dr.accepted_file_types, dr.school_levels, dr.created_at, dr.updated_at
dr.accepted_file_types, dr.school_levels, dr.created_at, dr.updated_at,
CASE WHEN SUM(CASE WHEN d.status = 'Completed' THEN 1 ELSE 0 END) > 0 THEN 'Completed' ELSE 'Archived' END AS document_status
FROM document_requirements dr
JOIN documents d ON d.requirement_id = dr.id
WHERE d.student_id = ?
AND d.status IN ('Completed', 'Archived')
ORDER BY dr.category ASC, dr.name ASC
`).all(studentId);
AND d.deleted_at IS NULL
AND dr.category IN (
SELECT DISTINCT CASE WHEN grade_level <= 10 THEN 'JHS' ELSE 'SHS' END
FROM enrollments WHERE student_id = ?
)
GROUP BY dr.id
ORDER BY dr.category ASC, dr.is_mandatory DESC, dr.name ASC
`).all(studentId, studentId);
const mandatoryMissing = missing.filter(r => r.is_mandatory === 1);
const mandatoryVerified = verified.filter(r => r.is_mandatory === 1);
const optionalMissing = missing.filter(r => r.is_mandatory === 0);
const optionalVerified = verified.filter(r => r.is_mandatory === 0);
res.json({
category: currentCategory,
@@ -342,11 +380,13 @@ exports.getMissingRequirements = (req, res) => {
missing,
pending: [],
verified,
totalRequired: missing.length + verified.length,
totalVerified: verified.length
totalRequired: mandatoryMissing.length + mandatoryVerified.length,
totalVerified: mandatoryVerified.length,
totalOptional: optionalMissing.length + optionalVerified.length,
totalOptionalVerified: optionalVerified.length
});
} catch (error) {
console.error('getMissingRequirements error:', error);
res.status(500).json({ message: 'Failed to fetch missing requirements', error: error.message });
}
};
};
@@ -0,0 +1,85 @@
const db = require('../config/db');
const autoArchiveService = require('../services/autoArchiveService');
const { checkAndRunTeacherDueDateReminders } = require('../services/teacherReminderService');
exports.getSettings = (req, res) => {
try {
const rows = db.prepare('SELECT key, value FROM system_settings').all();
const settings = {
auto_update_enrollment_from_sf: 'false',
enrollment_grace_period_days: '30',
remind_teachers_due_date_enabled: 'false',
remind_teachers_due_date_days: '3'
};
for (const row of rows) {
settings[row.key] = row.value;
}
res.json({
success: true,
data: settings
});
} catch (error) {
console.error('getSettings error:', error);
res.status(500).json({
success: false,
message: 'Failed to retrieve system settings',
error: error.message
});
}
};
exports.updateSettings = (req, res) => {
try {
const updates = req.body.settings || req.body;
if (!updates || typeof updates !== 'object') {
return res.status(400).json({
success: false,
message: 'Invalid settings payload'
});
}
const stmt = db.prepare(`
INSERT INTO system_settings (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = CURRENT_TIMESTAMP
`);
const insertOrUpdate = db.transaction((settingsObj) => {
for (const [key, value] of Object.entries(settingsObj)) {
stmt.run(key, String(value));
}
});
insertOrUpdate(updates);
if (updates.enrollment_grace_period_days !== undefined || updates.auto_archive_datetime !== undefined || updates.auto_archive_enabled !== undefined) {
autoArchiveService.checkAndRunAutoArchive(req.user?.id);
autoArchiveService.scheduleAutoArchiveTimer();
}
if (updates.remind_teachers_due_date_enabled !== undefined || updates.remind_teachers_due_date_days !== undefined) {
checkAndRunTeacherDueDateReminders();
}
const rows = db.prepare('SELECT key, value FROM system_settings').all();
const updatedSettings = {};
for (const row of rows) {
updatedSettings[row.key] = row.value;
}
res.json({
success: true,
message: 'System settings updated successfully',
data: updatedSettings
});
} catch (error) {
console.error('updateSettings error:', error);
res.status(500).json({
success: false,
message: 'Failed to update system settings',
error: error.message
});
}
};
+320 -18
View File
@@ -1,4 +1,47 @@
const db = require('../config/db');
const autoGraduationService = require('../services/autoGraduationService');
const autoArchiveService = require('../services/autoArchiveService');
// ============================================================
// ACADEMIC YEAR AUTOMATION
// ============================================================
// Auto-seeds the current school year if it doesn't exist,
// and ensures exactly one year (the most recent) stays active.
exports.ensureCurrentAcademicYears = () => {
try {
const now = new Date();
const currentStartYear = now.getFullYear();
const currentYearRange = `${currentStartYear}-${currentStartYear + 1}`;
const insertYear = db.prepare(
'INSERT OR IGNORE INTO academic_years (year_range, status) VALUES (?, \'inactive\')'
);
// Insert current year only if missing
insertYear.run(currentYearRange);
// Check if any year is currently active
const activeYear = db.prepare("SELECT id FROM academic_years WHERE status = 'active'").get();
if (!activeYear) {
// Activate the current school year
const currentRow = db.prepare("SELECT id FROM academic_years WHERE year_range = ?").get(currentYearRange);
if (currentRow) {
db.prepare("UPDATE academic_years SET status = 'active' WHERE id = ?").run(currentRow.id);
console.log(`[AcademicYear] Activated ${currentYearRange}`);
}
}
console.log(`[AcademicYear] Ensured year: ${currentYearRange}`);
} catch (err) {
console.error('[AcademicYear] Failed to ensure current academic years:', err.message);
}
};
// Deactivates all academic years except the given id
const deactivateOtherYears = (activeId) => {
db.prepare("UPDATE academic_years SET status = 'inactive' WHERE id != ?").run(activeId);
};
// ============================================================
// ACADEMIC YEARS CRUD
@@ -14,17 +57,45 @@ exports.getAllAcademicYears = (req, res) => {
};
exports.createAcademicYear = (req, res) => {
const { yearRange, status } = req.body;
const { yearRange, status, startDate, endDate, start_date, end_date } = req.body;
const sDate = startDate || start_date || null;
const eDate = endDate || end_date || null;
if (!yearRange || !yearRange.trim()) {
return res.status(400).json({ message: 'yearRange is required' });
}
const trimmedRange = yearRange.trim();
const regex = /^(\d{4})\s*-\s*(\d{4})$/;
const match = trimmedRange.match(regex);
if (!match) {
return res.status(400).json({ message: 'Invalid year range format. Must be YYYY-YYYY (e.g. 2025-2026)' });
}
const startYear = parseInt(match[1], 10);
const endYear = parseInt(match[2], 10);
if (endYear !== startYear + 1) {
return res.status(400).json({ message: `Invalid year range. End year must be start year + 1 (${startYear}-${startYear + 1})` });
}
const currentYear = new Date().getFullYear();
if (startYear > currentYear) {
return res.status(400).json({ message: `Cannot add future academic year beyond ${currentYear}-${currentYear + 1}` });
}
const normalizedRange = `${startYear}-${endYear}`;
try {
const result = db.prepare('INSERT INTO academic_years (year_range, status) VALUES (?, ?)')
.run(yearRange.trim(), status || 'active');
// Old academic years for manual adds default to inactive
const finalStatus = status || 'inactive';
const result = db.prepare('INSERT INTO academic_years (year_range, status, start_date, end_date) VALUES (?, ?, ?, ?)')
.run(normalizedRange, finalStatus, sDate, eDate);
// If the new year is active, deactivate all others and check graduation & archiving
if (finalStatus === 'active') {
deactivateOtherYears(result.lastInsertRowid);
autoGraduationService.checkAndRunAutoGraduation(req.user?.id);
autoArchiveService.checkAndRunAutoArchive(req.user?.id);
}
res.status(201).json({ id: result.lastInsertRowid, message: 'Academic year created successfully' });
} catch (error) {
if (error.message && error.message.includes('UNIQUE')) {
return res.status(409).json({ message: `Academic year "${yearRange}" already exists.` });
return res.status(409).json({ message: `Academic year "${normalizedRange}" already exists.` });
}
res.status(500).json({ message: 'Failed to create academic year', error: error.message });
}
@@ -32,7 +103,9 @@ exports.createAcademicYear = (req, res) => {
exports.updateAcademicYear = (req, res) => {
const { id } = req.params;
const { yearRange, status } = req.body;
const { yearRange, status, startDate, endDate, start_date, end_date } = req.body;
const sDate = startDate !== undefined ? (startDate || null) : (start_date !== undefined ? (start_date || null) : undefined);
const eDate = endDate !== undefined ? (endDate || null) : (end_date !== undefined ? (end_date || null) : undefined);
if (!yearRange || !yearRange.trim()) {
return res.status(400).json({ message: 'yearRange is required' });
@@ -41,21 +114,65 @@ exports.updateAcademicYear = (req, res) => {
return res.status(400).json({ message: 'Invalid status value. Must be active or inactive' });
}
const trimmedRange = yearRange.trim();
const regex = /^(\d{4})\s*-\s*(\d{4})$/;
const match = trimmedRange.match(regex);
if (!match) {
return res.status(400).json({ message: 'Invalid year range format. Must be YYYY-YYYY (e.g. 2025-2026)' });
}
const startYear = parseInt(match[1], 10);
const endYear = parseInt(match[2], 10);
if (endYear !== startYear + 1) {
return res.status(400).json({ message: `Invalid year range. End year must be start year + 1 (${startYear}-${startYear + 1})` });
}
const currentYear = new Date().getFullYear();
if (startYear > currentYear) {
return res.status(400).json({ message: `Cannot add future academic year beyond ${currentYear}-${currentYear + 1}` });
}
const normalizedRange = `${startYear}-${endYear}`;
try {
const year = db.prepare('SELECT id FROM academic_years WHERE id = ?').get(id);
const year = db.prepare('SELECT * FROM academic_years WHERE id = ?').get(id);
if (!year) return res.status(404).json({ message: 'Academic year not found' });
db.prepare('UPDATE academic_years SET year_range = ?, status = ? WHERE id = ?')
.run(yearRange.trim(), status || 'active', id);
const finalStatus = status || 'inactive';
const finalStartDate = sDate !== undefined ? sDate : (year.start_date || null);
const finalEndDate = eDate !== undefined ? eDate : (year.end_date || null);
db.prepare('UPDATE academic_years SET year_range = ?, status = ?, start_date = ?, end_date = ? WHERE id = ?')
.run(normalizedRange, finalStatus, finalStartDate, finalEndDate, id);
// If activated, deactivate all other years automatically
if (finalStatus === 'active') {
deactivateOtherYears(parseInt(id));
autoGraduationService.checkAndRunAutoGraduation(req.user?.id);
autoArchiveService.checkAndRunAutoArchive(req.user?.id);
}
res.json({ message: 'Academic year updated successfully' });
} catch (error) {
if (error.message && error.message.includes('UNIQUE')) {
return res.status(409).json({ message: `Academic year "${yearRange}" already exists.` });
return res.status(409).json({ message: `Academic year "${normalizedRange}" already exists.` });
}
res.status(500).json({ message: 'Failed to update academic year', error: error.message });
}
};
exports.checkAutoGraduation = (req, res) => {
try {
const result = autoGraduationService.checkAndRunAutoGraduation(req.user?.id);
res.json(result);
} catch (error) {
res.status(500).json({ message: 'Failed to check auto graduation', error: error.message });
}
};
exports.checkAutoArchive = (req, res) => {
try {
const result = autoArchiveService.checkAndRunAutoArchive(req.user?.id);
res.json(result);
} catch (error) {
res.status(500).json({ message: 'Failed to check auto archive', error: error.message });
}
};
exports.deleteAcademicYear = (req, res) => {
const { id } = req.params;
try {
@@ -76,9 +193,14 @@ exports.deleteAcademicYear = (req, res) => {
exports.getAllSections = (req, res) => {
try {
const sections = db.prepare(`
SELECT s.*, ay.year_range as academic_year_range
SELECT s.*, ay.year_range as academic_year_range,
ts.teacher_id,
u.first_name as teacher_first_name,
u.last_name as teacher_last_name
FROM sections s
LEFT JOIN academic_years ay ON s.academic_year_id = ay.id
LEFT JOIN teacher_sections ts ON s.id = ts.section_id
LEFT JOIN users u ON ts.teacher_id = u.id
ORDER BY ay.year_range DESC, s.grade_level ASC, s.name ASC
`).all();
res.json(sections);
@@ -89,7 +211,18 @@ exports.getAllSections = (req, res) => {
exports.getSectionsByYear = (req, res) => {
try {
const sections = db.prepare('SELECT * FROM sections WHERE academic_year_id = ? ORDER BY grade_level ASC, name ASC').all(req.params.yearId);
const sections = db.prepare(`
SELECT s.*, ay.year_range as academic_year_range,
ts.teacher_id,
u.first_name as teacher_first_name,
u.last_name as teacher_last_name
FROM sections s
LEFT JOIN academic_years ay ON s.academic_year_id = ay.id
LEFT JOIN teacher_sections ts ON s.id = ts.section_id
LEFT JOIN users u ON ts.teacher_id = u.id
WHERE s.academic_year_id = ?
ORDER BY s.grade_level ASC, s.name ASC
`).all(req.params.yearId);
res.json(sections);
} catch (error) {
res.status(500).json({ message: 'Failed to fetch sections', error: error.message });
@@ -97,14 +230,21 @@ exports.getSectionsByYear = (req, res) => {
};
exports.createSection = (req, res) => {
const { name, gradeLevel, academicYearId } = req.body;
const { name, gradeLevel, academicYearId, teacherId } = req.body;
if (!name || !name.trim() || !gradeLevel || !academicYearId) {
return res.status(400).json({ message: 'name, gradeLevel, and academicYearId are required' });
}
try {
const result = db.prepare('INSERT INTO sections (name, grade_level, academic_year_id) VALUES (?, ?, ?)')
.run(name.trim(), gradeLevel, academicYearId);
res.status(201).json({ id: result.lastInsertRowid, message: 'Section created successfully' });
let sectionId;
db.transaction(() => {
const result = db.prepare('INSERT INTO sections (name, grade_level, academic_year_id) VALUES (?, ?, ?)')
.run(name.trim(), gradeLevel, academicYearId);
sectionId = result.lastInsertRowid;
if (teacherId) {
db.prepare('INSERT INTO teacher_sections (teacher_id, section_id) VALUES (?, ?)').run(teacherId, sectionId);
}
})();
res.status(201).json({ id: sectionId, message: 'Section created successfully' });
} catch (error) {
res.status(500).json({ message: 'Failed to create section', error: error.message });
}
@@ -112,7 +252,7 @@ exports.createSection = (req, res) => {
exports.updateSection = (req, res) => {
const { id } = req.params;
const { name, gradeLevel, academicYearId } = req.body;
const { name, gradeLevel, academicYearId, teacherId } = req.body;
if (!name || !name.trim() || !gradeLevel || !academicYearId) {
return res.status(400).json({ message: 'name, gradeLevel, and academicYearId are required' });
@@ -122,14 +262,41 @@ exports.updateSection = (req, res) => {
const section = db.prepare('SELECT id FROM sections WHERE id = ?').get(id);
if (!section) return res.status(404).json({ message: 'Section not found' });
db.prepare('UPDATE sections SET name = ?, grade_level = ?, academic_year_id = ? WHERE id = ?')
.run(name.trim(), gradeLevel, academicYearId, id);
db.transaction(() => {
db.prepare('UPDATE sections SET name = ?, grade_level = ?, academic_year_id = ? WHERE id = ?')
.run(name.trim(), gradeLevel, academicYearId, id);
if (teacherId !== undefined) {
db.prepare('DELETE FROM teacher_sections WHERE section_id = ?').run(id);
if (teacherId) {
db.prepare('INSERT INTO teacher_sections (teacher_id, section_id) VALUES (?, ?)').run(teacherId, id);
}
}
})();
res.json({ message: 'Section updated successfully' });
} catch (error) {
res.status(500).json({ message: 'Failed to update section', error: error.message });
}
};
exports.setSectionAdviser = (req, res) => {
const { id } = req.params;
const { teacherId } = req.body;
try {
const section = db.prepare('SELECT id FROM sections WHERE id = ?').get(id);
if (!section) return res.status(404).json({ message: 'Section not found' });
db.transaction(() => {
db.prepare('DELETE FROM teacher_sections WHERE section_id = ?').run(id);
if (teacherId) {
db.prepare('INSERT INTO teacher_sections (teacher_id, section_id) VALUES (?, ?)').run(teacherId, id);
}
})();
res.json({ message: 'Section adviser updated successfully' });
} catch (error) {
res.status(500).json({ message: 'Failed to update section adviser', error: error.message });
}
};
exports.deleteSection = (req, res) => {
const { id } = req.params;
try {
@@ -210,3 +377,138 @@ exports.deleteGradeLevel = (req, res) => {
res.status(500).json({ message: 'Failed to delete grade level', error: error.message });
}
};
// POST /api/setup/bulk-academic-structure (admin only)
exports.bulkCreateAcademicStructure = (req, res) => {
const { rows } = req.body;
if (!rows || !Array.isArray(rows) || rows.length === 0) {
return res.status(400).json({ message: 'rows array is required and must not be empty' });
}
const currentYear = new Date().getFullYear();
const regex = /^(\d{4})\s*-\s*(\d{4})$/;
const results = {
totalRows: rows.length,
createdYears: [],
createdSections: [],
existingSections: [],
errors: []
};
const processTransaction = db.transaction(() => {
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rawYear = (row.academicYear || row.yearRange || '').toString().trim();
const rawGrade = parseInt(row.gradeLevel, 10);
const rawSection = (row.sectionName || row.section || '').toString().trim();
if (!rawYear || isNaN(rawGrade) || !rawSection) {
results.errors.push({
row: i + 1,
data: row,
error: 'Academic Year, Grade Level (numeric), and Section Name are required'
});
continue;
}
// Validate year format YYYY-YYYY
const match = rawYear.match(regex);
if (!match) {
results.errors.push({
row: i + 1,
data: row,
error: `Invalid academic year format "${rawYear}". Must be YYYY-YYYY (e.g. 2024-2025)`
});
continue;
}
const startYear = parseInt(match[1], 10);
const endYear = parseInt(match[2], 10);
if (endYear !== startYear + 1) {
results.errors.push({
row: i + 1,
data: row,
error: `Invalid academic year range "${rawYear}". End year must be start year + 1`
});
continue;
}
if (startYear > currentYear) {
results.errors.push({
row: i + 1,
data: row,
error: `Cannot add future academic year "${rawYear}" beyond ${currentYear}-${currentYear + 1}`
});
continue;
}
if (rawGrade < 7 || rawGrade > 12) {
results.errors.push({
row: i + 1,
data: row,
error: `Grade level ${rawGrade} must be between 7 and 12`
});
continue;
}
const normalizedRange = `${startYear}-${endYear}`;
// 1. Find or create academic year
let yearRecord = db.prepare('SELECT id, year_range, status FROM academic_years WHERE year_range = ?').get(normalizedRange);
if (!yearRecord) {
const newYear = db.prepare('INSERT INTO academic_years (year_range, status) VALUES (?, ?)')
.run(normalizedRange, 'inactive');
yearRecord = { id: newYear.lastInsertRowid, year_range: normalizedRange, status: 'inactive' };
if (!results.createdYears.includes(normalizedRange)) {
results.createdYears.push(normalizedRange);
}
}
// 2. Ensure grade level exists in grade_levels table
const gradeExists = db.prepare('SELECT id FROM grade_levels WHERE level = ?').get(rawGrade);
if (!gradeExists) {
db.prepare('INSERT OR IGNORE INTO grade_levels (level, name) VALUES (?, ?)')
.run(rawGrade, `Grade ${rawGrade}`);
}
// 3. Find or create section
const existingSection = db.prepare(
'SELECT id FROM sections WHERE LOWER(name) = LOWER(?) AND grade_level = ? AND academic_year_id = ?'
).get(rawSection, rawGrade, yearRecord.id);
if (existingSection) {
results.existingSections.push({
academicYear: normalizedRange,
gradeLevel: rawGrade,
sectionName: rawSection
});
} else {
const newSec = db.prepare('INSERT INTO sections (name, grade_level, academic_year_id) VALUES (?, ?, ?)')
.run(rawSection, rawGrade, yearRecord.id);
results.createdSections.push({
id: newSec.lastInsertRowid,
academicYear: normalizedRange,
gradeLevel: rawGrade,
sectionName: rawSection
});
}
}
});
try {
processTransaction();
res.json({
success: true,
message: `Processed ${results.totalRows} rows. Created ${results.createdSections.length} section(s) across ${results.createdYears.length} new academic year(s).`,
data: results
});
} catch (error) {
console.error('bulkCreateAcademicStructure error:', error);
res.status(500).json({
success: false,
message: 'Failed to process bulk academic structure import',
error: error.message
});
}
};
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,322 @@
const db = require('../config/db');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
// ── Storage root for template files ──────────────────────────────────────────
const TEMPLATES_DIR = process.env.TEMPLATES_DIR
? path.resolve(process.env.TEMPLATES_DIR)
: path.resolve(__dirname, '../../../data/templates');
if (!fs.existsSync(TEMPLATES_DIR)) {
fs.mkdirSync(TEMPLATES_DIR, { recursive: true });
}
// ── Multer config ─────────────────────────────────────────────────────────────
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const templateId = req.params.id || req.body.templateId || 'new';
const dir = path.join(TEMPLATES_DIR, String(templateId));
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
req._templateUploadDir = dir;
cb(null, dir);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
const base = path.basename(file.originalname, ext).replace(/[<>:"/\\|?*\x00-\x1F]/g, '').trim();
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
cb(null, `${ts}_${base}${ext}`);
},
});
const upload = multer({ storage });
exports.uploadMiddleware = upload.single('file');
// ── Helper: log activity ──────────────────────────────────────────────────────
const logActivity = (userId, action, entityType, entityId, description) => {
try {
db.prepare(
'INSERT INTO activity_log (user_id, action, entity_type, entity_id, description) VALUES (?, ?, ?, ?, ?)'
).run(userId ?? null, action, entityType, entityId ?? null, description);
} catch (_) {}
};
// ── GET /api/templates ────────────────────────────────────────────────────────
exports.getTemplates = (req, res) => {
try {
const { requirementId, isActive } = req.query;
const conditions = [];
const params = [];
if (requirementId) {
conditions.push('t.requirement_id = ?');
params.push(requirementId);
}
if (isActive !== undefined && isActive !== '') {
conditions.push('t.is_active = ?');
params.push(isActive === 'true' ? 1 : 0);
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const templates = db.prepare(`
SELECT
t.id, t.requirement_id, t.name, t.description, t.is_active,
t.created_by, t.created_at, t.updated_at,
r.name AS requirement_name,
r.category AS requirement_category,
(SELECT COUNT(*) FROM template_versions tv WHERE tv.template_id = t.id) AS version_count,
(SELECT tv2.id FROM template_versions tv2 WHERE tv2.template_id = t.id AND tv2.is_current = 1 LIMIT 1) AS current_version_id,
(SELECT tv2.version_number FROM template_versions tv2 WHERE tv2.template_id = t.id AND tv2.is_current = 1 LIMIT 1) AS current_version_number,
(SELECT tv2.file_name FROM template_versions tv2 WHERE tv2.template_id = t.id AND tv2.is_current = 1 LIMIT 1) AS current_file_name
FROM document_templates t
LEFT JOIN document_requirements r ON t.requirement_id = r.id
${where}
ORDER BY t.name ASC
`).all(params);
res.json(templates);
} catch (err) {
console.error('getTemplates error:', err);
res.status(500).json({ message: 'Failed to fetch templates', error: err.message });
}
};
// ── GET /api/templates/:id ────────────────────────────────────────────────────
exports.getTemplateById = (req, res) => {
try {
const template = db.prepare(`
SELECT
t.id, t.requirement_id, t.name, t.description, t.is_active,
t.created_by, t.created_at, t.updated_at,
r.name AS requirement_name, r.category AS requirement_category
FROM document_templates t
LEFT JOIN document_requirements r ON t.requirement_id = r.id
WHERE t.id = ?
`).get(req.params.id);
if (!template) return res.status(404).json({ message: 'Template not found' });
const versions = db.prepare(`
SELECT tv.id, tv.version_number, tv.file_name, tv.file_size, tv.is_current,
tv.uploaded_by, tv.created_at,
u.first_name || ' ' || u.last_name AS uploaded_by_name
FROM template_versions tv
LEFT JOIN users u ON tv.uploaded_by = u.id
WHERE tv.template_id = ?
ORDER BY tv.version_number DESC
`).all(req.params.id);
res.json({ ...template, versions });
} catch (err) {
console.error('getTemplateById error:', err);
res.status(500).json({ message: 'Failed to fetch template', error: err.message });
}
};
// ── GET /api/templates/:id/versions ──────────────────────────────────────────
exports.getTemplateVersions = (req, res) => {
try {
const versions = db.prepare(`
SELECT tv.id, tv.version_number, tv.file_name, tv.file_size, tv.is_current,
tv.uploaded_by, tv.created_at,
u.first_name || ' ' || u.last_name AS uploaded_by_name
FROM template_versions tv
LEFT JOIN users u ON tv.uploaded_by = u.id
WHERE tv.template_id = ?
ORDER BY tv.version_number DESC
`).all(req.params.id);
res.json(versions);
} catch (err) {
console.error('getTemplateVersions error:', err);
res.status(500).json({ message: 'Failed to fetch versions', error: err.message });
}
};
// ── POST /api/templates ───────────────────────────────────────────────────────
exports.createTemplate = (req, res) => {
const { name, description, requirementId, isActive = true } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({ message: 'Template name is required' });
}
try {
const result = db.prepare(`
INSERT INTO document_templates (requirement_id, name, description, is_active, created_by)
VALUES (?, ?, ?, ?, ?)
`).run(
requirementId ?? null,
name.trim(),
description?.trim() ?? null,
isActive ? 1 : 0,
req.user?.id ?? null
);
const templateId = result.lastInsertRowid;
// If a file was uploaded, create Version 1
if (req.file) {
// Move file from 'new' dir to real template dir
const newDir = path.join(TEMPLATES_DIR, String(templateId));
if (!fs.existsSync(newDir)) fs.mkdirSync(newDir, { recursive: true });
const destPath = path.join(newDir, req.file.filename);
fs.renameSync(req.file.path, destPath);
const fileSize = fs.existsSync(destPath) ? fs.statSync(destPath).size : null;
db.prepare(`
INSERT INTO template_versions (template_id, version_number, file_name, file_path, file_size, uploaded_by, is_current)
VALUES (?, 1, ?, ?, ?, ?, 1)
`).run(templateId, req.file.originalname, destPath, fileSize, req.user?.id ?? null);
}
logActivity(req.user?.id, 'CREATE', 'template', templateId, `Created template: ${name}`);
res.status(201).json({ id: templateId, message: 'Template created successfully' });
} catch (err) {
console.error('createTemplate error:', err);
if (req.file && fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path);
res.status(500).json({ message: 'Failed to create template', error: err.message });
}
};
// ── PATCH /api/templates/:id ──────────────────────────────────────────────────
exports.updateTemplate = (req, res) => {
const { name, description, requirementId, isActive } = req.body;
try {
const existing = db.prepare('SELECT id FROM document_templates WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ message: 'Template not found' });
const fields = [];
const params = [];
if (name !== undefined) { fields.push('name = ?'); params.push(name.trim()); }
if (description !== undefined) { fields.push('description = ?'); params.push(description?.trim() ?? null); }
if (requirementId !== undefined) { fields.push('requirement_id = ?'); params.push(requirementId ?? null); }
if (isActive !== undefined) { fields.push('is_active = ?'); params.push(isActive ? 1 : 0); }
if (fields.length === 0) return res.status(400).json({ message: 'No fields to update' });
fields.push("updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')");
params.push(req.params.id);
db.prepare(`UPDATE document_templates SET ${fields.join(', ')} WHERE id = ?`).run(...params);
logActivity(req.user?.id, 'UPDATE', 'template', req.params.id, `Updated template id ${req.params.id}`);
res.json({ message: 'Template updated successfully' });
} catch (err) {
console.error('updateTemplate error:', err);
res.status(500).json({ message: 'Failed to update template', error: err.message });
}
};
// ── POST /api/templates/:id/upload-version ────────────────────────────────────
exports.uploadTemplateVersion = (req, res) => {
if (!req.file) return res.status(400).json({ message: 'No file uploaded' });
try {
const templateId = parseInt(req.params.id, 10);
const template = db.prepare('SELECT id FROM document_templates WHERE id = ?').get(templateId);
if (!template) {
fs.unlinkSync(req.file.path);
return res.status(404).json({ message: 'Template not found' });
}
// Get next version number
const lastVersion = db.prepare(
'SELECT MAX(version_number) AS max_v FROM template_versions WHERE template_id = ?'
).get(templateId);
const nextVersion = (lastVersion?.max_v ?? 0) + 1;
// Move file to correct location
const destDir = path.join(TEMPLATES_DIR, String(templateId));
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
const destPath = path.join(destDir, req.file.filename);
if (req.file.path !== destPath) fs.renameSync(req.file.path, destPath);
const fileSize = fs.existsSync(destPath) ? fs.statSync(destPath).size : null;
db.transaction(() => {
// Unset current flag on previous versions
db.prepare('UPDATE template_versions SET is_current = 0 WHERE template_id = ?').run(templateId);
// Insert new version
db.prepare(`
INSERT INTO template_versions (template_id, version_number, file_name, file_path, file_size, uploaded_by, is_current)
VALUES (?, ?, ?, ?, ?, ?, 1)
`).run(templateId, nextVersion, req.file.originalname, destPath, fileSize, req.user?.id ?? null);
// Update template updated_at
db.prepare("UPDATE document_templates SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?").run(templateId);
})();
logActivity(req.user?.id, 'CREATE', 'template_version', templateId,
`Uploaded version ${nextVersion} for template id ${templateId}`);
res.status(201).json({ message: `Template version ${nextVersion} uploaded successfully`, version_number: nextVersion });
} catch (err) {
console.error('uploadTemplateVersion error:', err);
if (req.file && fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path);
res.status(500).json({ message: 'Failed to upload template version', error: err.message });
}
};
// ── GET /api/templates/:id/download ──────────────────────────────────────────
// Downloads the current (latest) version of the template file
exports.downloadTemplate = (req, res) => {
try {
const version = db.prepare(`
SELECT tv.file_path, tv.file_name
FROM template_versions tv
WHERE tv.template_id = ? AND tv.is_current = 1
LIMIT 1
`).get(req.params.id);
if (!version) return res.status(404).json({ message: 'No current template version found' });
if (!fs.existsSync(version.file_path)) {
return res.status(404).json({ message: 'Template file not found on disk' });
}
res.download(version.file_path, version.file_name);
} catch (err) {
console.error('downloadTemplate error:', err);
res.status(500).json({ message: 'Failed to download template', error: err.message });
}
};
// ── GET /api/template-versions/:versionId/download ───────────────────────────
exports.downloadTemplateVersion = (req, res) => {
try {
const version = db.prepare(
'SELECT file_path, file_name FROM template_versions WHERE id = ?'
).get(req.params.versionId);
if (!version) return res.status(404).json({ message: 'Template version not found' });
if (!fs.existsSync(version.file_path)) {
return res.status(404).json({ message: 'File not found on disk' });
}
res.download(version.file_path, version.file_name);
} catch (err) {
console.error('downloadTemplateVersion error:', err);
res.status(500).json({ message: 'Failed to download template version', error: err.message });
}
};
// ── DELETE /api/templates/:id ─────────────────────────────────────────────────
exports.deleteTemplate = (req, res) => {
try {
const template = db.prepare('SELECT id, name FROM document_templates WHERE id = ?').get(req.params.id);
if (!template) return res.status(404).json({ message: 'Template not found' });
// Remove all version files from disk
const versions = db.prepare('SELECT file_path FROM template_versions WHERE template_id = ?').all(req.params.id);
for (const v of versions) {
if (v.file_path && fs.existsSync(v.file_path)) {
try { fs.unlinkSync(v.file_path); } catch (_) {}
}
}
db.prepare('DELETE FROM document_templates WHERE id = ?').run(req.params.id);
logActivity(req.user?.id, 'DELETE', 'template', req.params.id, `Deleted template: ${template.name}`);
res.json({ message: 'Template deleted' });
} catch (err) {
console.error('deleteTemplate error:', err);
res.status(500).json({ message: 'Failed to delete template', error: err.message });
}
};
+556 -31
View File
@@ -1,6 +1,91 @@
const db = require('../config/db');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const os = require('os');
const { createNotification } = require('./notificationController');
const {
sendPasswordResetLink,
sendTeacherAttentionReminder,
sendAccountCreatedEmail,
sendAccountStatusEmail,
} = require('../services/emailService');
const { getDetectedTunnelUrl } = require('../services/tunnelService');
// validateEmailMEV is lazy-required to avoid circular dependency (authController → userController → authController)
const getMEV = () => require('./authController').validateEmailMEV;
const getQEV = getMEV;
// ── Base URL Helper (Auto-detects Tunnel vs LAN IP vs Domain) ────────────────
const getBestServerBaseUrl = (req) => {
// 1. Explicit environment public URLs (PUBLIC_URL, APP_URL, CLOUDFLARE_TUNNEL_PUBLIC_URL)
const envPublicUrl = process.env.APP_URL || process.env.CLOUDFLARE_TUNNEL_PUBLIC_URL || process.env.PUBLIC_URL;
if (envPublicUrl && envPublicUrl.trim()) {
return envPublicUrl.trim().replace(/\/api\/?$/, '').replace(/\/+$/, '');
}
// 2. If live Cloudflare Tunnel is connected and detected
const activeTunnelUrl = getDetectedTunnelUrl();
if (activeTunnelUrl) {
return activeTunnelUrl;
}
const forwardedProto = req.headers['x-forwarded-proto'];
const forwardedHost = req.headers['x-forwarded-host'];
const proto = forwardedProto || req.protocol || 'http';
const host = forwardedHost || req.get('host') || `localhost:${process.env.PORT || 18484}`;
// 3. Helper to detect private LAN/localhost IPs
const isPrivateOrLocal = (h) => {
if (!h) return true;
const cleanHost = h.split(':')[0].toLowerCase();
return (
cleanHost === 'localhost' ||
cleanHost === '127.0.0.1' ||
cleanHost.startsWith('192.168.') ||
cleanHost.startsWith('10.') ||
cleanHost.startsWith('172.16.') ||
cleanHost.startsWith('172.17.') ||
cleanHost.startsWith('172.18.') ||
cleanHost.startsWith('172.19.') ||
cleanHost.startsWith('172.20.') ||
cleanHost.startsWith('172.21.') ||
cleanHost.startsWith('172.22.') ||
cleanHost.startsWith('172.23.') ||
cleanHost.startsWith('172.24.') ||
cleanHost.startsWith('172.25.') ||
cleanHost.startsWith('172.26.') ||
cleanHost.startsWith('172.27.') ||
cleanHost.startsWith('172.28.') ||
cleanHost.startsWith('172.29.') ||
cleanHost.startsWith('172.30.') ||
cleanHost.startsWith('172.31.')
);
};
// 4. If caller is connecting via a public domain name
if (!isPrivateOrLocal(host)) {
return `${proto}://${host}`;
}
// 5. If caller is on LAN (e.g. 192.168.x.x) and no public domain is configured
if (!host.startsWith('localhost') && !host.startsWith('127.0.0.1')) {
return `${proto}://${host}`;
}
// 6. Fallback: Automatically detect primary local IPv4 address of this server on the LAN
const port = process.env.PORT || 18484;
const interfaces = os.networkInterfaces();
for (const name of Object.keys(interfaces)) {
for (const iface of interfaces[name]) {
if (iface.family === 'IPv4' && !iface.internal) {
return `http://${iface.address}:${port}`;
}
}
}
// 7. Ultimate fallback
return `${proto}://${host}`;
};
// ── Helpers ──────────────────────────────────────────────────────────────────
const logActivity = (userId, action, entityType, entityId, description) => {
@@ -21,9 +106,17 @@ const logUserHistory = (performedBy, targetUserId, action, username, fullName, r
// GET /api/users - List all users
exports.getUsers = (req, res) => {
try {
const users = db.prepare(
'SELECT id, username, first_name, middle_name, last_name, extension, role, email, phone, created_at FROM users ORDER BY role, last_name'
).all();
const users = db.prepare(`
SELECT u.id, u.username, u.first_name, u.middle_name, u.last_name, u.extension, u.role, u.email, u.phone, u.is_active, u.created_at,
COALESCE(creator.username, creator_del.username, 'System') as added_by_username,
COALESCE(creator.first_name || ' ' || creator.last_name, creator_del.full_name, 'System') as added_by_name
FROM users u
LEFT JOIN user_history uh ON u.id = uh.target_user_id AND uh.action = 'created'
LEFT JOIN users creator ON uh.performed_by = creator.id
LEFT JOIN deleted_users_history creator_del ON uh.performed_by = creator_del.deleted_user_id
WHERE COALESCE(u.is_hidden, 0) = 0
ORDER BY u.role, u.last_name
`).all();
res.json(users);
} catch (error) {
res.status(500).json({ message: 'Failed to fetch users', error: error.message });
@@ -31,7 +124,7 @@ exports.getUsers = (req, res) => {
};
// POST /api/users - Create a new user
exports.createUser = (req, res) => {
exports.createUser = async (req, res) => {
const { username, firstName, middleName, lastName, extension, role, email, phone } = req.body;
// Password is optional — auto-generated if not provided
let providedPassword = req.body.password;
@@ -45,17 +138,42 @@ exports.createUser = (req, res) => {
return res.status(400).json({ message: 'Invalid role. Must be admin or teacher.' });
}
// Auto-generate a secure temporary password if none was provided
const generatePassword = () => {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#';
let pwd = '';
for (let i = 0; i < 10; i++) pwd += chars[Math.floor(Math.random() * chars.length)];
return pwd;
};
// Validate email with MyEmailVerifier before creating user
if (email && email.trim()) {
try {
const mevResult = await getMEV()(email.trim());
if (!mevResult.valid) {
return res.status(400).json({
message: 'Invalid or undeliverable email address.',
reason: mevResult.reason,
});
}
} catch (mevErr) {
console.warn('[UserController] MEV pre-check skipped due to error:', mevErr.message);
}
}
const temporaryPassword = (providedPassword && providedPassword.trim().length >= 6)
? providedPassword.trim()
: generatePassword();
// Generate secure randomized temporary password for all users if not explicitly provided
let temporaryPassword;
if (providedPassword && providedPassword.trim()) {
temporaryPassword = providedPassword.trim();
} else {
const uppercase = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
const lowercase = 'abcdefghjkmnpqrstuvwxyz';
const digits = '23456789';
const symbols = '!@#$%&*';
const allChars = uppercase + lowercase + digits + symbols;
const pwdParts = [
uppercase[crypto.randomInt(0, uppercase.length)],
lowercase[crypto.randomInt(0, lowercase.length)],
digits[crypto.randomInt(0, digits.length)],
symbols[crypto.randomInt(0, symbols.length)],
];
for (let i = 4; i < 10; i++) {
pwdParts.push(allChars[crypto.randomInt(0, allChars.length)]);
}
temporaryPassword = pwdParts.sort(() => crypto.randomInt(-1, 2)).join('');
}
try {
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
@@ -73,10 +191,33 @@ exports.createUser = (req, res) => {
const newUser = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
const uid = newUser?.id ?? result.lastInsertRowid;
const fullName = [firstName, lastName].filter(Boolean).join(' ');
logActivity(req.user?.id, 'CREATE', 'user', uid, `Created user "${username}" (${role})`);
logActivity(req.user?.id, 'CREATE', 'user', uid, `Added user: ${username} as ${role}`);
logUserHistory(req.user?.id, uid, 'created', username, fullName, role);
createNotification(null, 'User Registered', `New ${role} user "${username}" (${fullName}) was registered.`, 'user');
// Asynchronously send welcome email with credentials if email is provided
if (email && email.trim()) {
// QEV: validate email before sending (fire-and-forget with validation)
(async () => {
try {
const qevResult = await getQEV()(email.trim());
if (!qevResult.valid) {
console.warn(`[UserController] Skipping welcome email to ${email}: QEV says ${qevResult.reason}`);
return;
}
await sendAccountCreatedEmail({
to: email.trim(),
username,
fullName,
role,
temporaryPassword,
});
} catch (emailErr) {
console.error(`[UserController] Failed to send account created email to ${email}:`, emailErr.message);
}
})();
}
res.status(201).json({
message: 'User created successfully',
userId: result.lastInsertRowid,
@@ -89,7 +230,7 @@ exports.createUser = (req, res) => {
// PUT /api/users/:id - Update a user
exports.updateUser = (req, res) => {
exports.updateUser = async (req, res) => {
const { id } = req.params;
const { firstName, middleName, lastName, extension, role, email, phone } = req.body;
@@ -99,9 +240,24 @@ exports.updateUser = (req, res) => {
}
try {
const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(id);
const user = db.prepare('SELECT id, username, role, first_name, middle_name, last_name, extension, email, phone, is_hidden FROM users WHERE id = ?').get(id);
if (!user) return res.status(404).json({ message: 'User not found.' });
// Validate email with MyEmailVerifier if changed and not empty
if (email !== undefined && email !== null && email.trim() !== '' && email.trim().toLowerCase() !== (user.email || '').trim().toLowerCase()) {
try {
const mevResult = await getMEV()(email.trim());
if (!mevResult.valid) {
return res.status(400).json({
message: 'Invalid or undeliverable email address.',
reason: mevResult.reason,
});
}
} catch (mevErr) {
console.warn('[UserController] MEV pre-check skipped due to error:', mevErr.message);
}
}
// Prevent self-demotion (locked role for self)
const effectiveRole = user.id === req.user.id ? user.role : (role || user.role);
@@ -112,11 +268,22 @@ exports.updateUser = (req, res) => {
`).run(firstName, middleName || null, lastName, extension || null, effectiveRole, email || null, phone || null, id);
const updatedUser = db.prepare('SELECT username FROM users WHERE id = ?').get(id);
const updatedUsername = updatedUser?.username ?? `#${id}`;
const updatedUsername = updatedUser?.username ?? user.username ?? `#${id}`;
const fullName = [firstName, lastName].filter(Boolean).join(' ');
logActivity(req.user?.id, 'UPDATE', 'user', id, `Updated user "${updatedUsername}" (${effectiveRole})`);
const changes = [];
if (firstName !== undefined && firstName !== user.first_name) changes.push(`First Name: "${user.first_name || ''}" → "${firstName}"`);
if (middleName !== undefined && middleName !== (user.middle_name || '')) changes.push(`Middle Name: "${user.middle_name || ''}" → "${middleName || ''}"`);
if (lastName !== undefined && lastName !== user.last_name) changes.push(`Last Name: "${user.last_name || ''}" → "${lastName}"`);
if (extension !== undefined && extension !== (user.extension || '')) changes.push(`Extension: "${user.extension || ''}" → "${extension || ''}"`);
if (role !== undefined && effectiveRole !== user.role) changes.push(`Role: "${user.role}" → "${effectiveRole}"`);
if (email !== undefined && email !== (user.email || '')) changes.push(`Email: "${user.email || ''}" → "${email || ''}"`);
if (phone !== undefined && phone !== (user.phone || '')) changes.push(`Phone: "${user.phone || ''}" → "${phone || ''}"`);
const changesText = changes.length ? `\n• ${changes.join('\n• ')}` : '';
logActivity(req.user?.id, 'UPDATE', 'user', id, `Updated user: ${updatedUsername}${changesText}`);
logUserHistory(req.user?.id, id, 'updated',
db.prepare('SELECT username FROM users WHERE id = ?').get(id)?.username ?? '',
updatedUsername,
fullName, effectiveRole);
res.json({ message: 'User updated successfully' });
@@ -126,24 +293,114 @@ exports.updateUser = (req, res) => {
};
// PUT /api/users/:id/reset-password - Reset a user's password
exports.resetPassword = (req, res) => {
// PUT /api/users/:id/reset-password - Send password reset link to user's email with expiration time
exports.resetPassword = async (req, res) => {
const { id } = req.params;
const newPassword = 'changeme123';
const { adminPassword, expirationMinutes } = req.body;
const adminId = req.user.id;
const expMinutes = Math.min(1440, Math.max(5, parseInt(expirationMinutes, 10) || 15));
try {
const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(id);
if (!adminPassword) {
return res.status(400).json({ message: 'Admin password is required to reset a password.' });
}
const adminUser = db.prepare('SELECT id, username, password FROM users WHERE id = ?').get(adminId);
if (!adminUser || !bcrypt.compareSync(adminPassword, adminUser.password)) {
return res.status(401).json({ message: 'Incorrect Admin Password.' });
}
const user = db.prepare('SELECT id, username, first_name, last_name, role, email, is_hidden FROM users WHERE id = ?').get(id);
if (!user) return res.status(404).json({ message: 'User not found.' });
if (user.id === req.user.id) {
return res.status(403).json({ message: 'Cannot reset your own password via this route. Use the Change Password profile setting.' });
}
const hashed = bcrypt.hashSync(newPassword, 10);
db.prepare("UPDATE users SET password = ?, updated_at = (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) WHERE id = ?").run(hashed, id);
if (!user.email || !user.email.trim()) {
return res.status(400).json({
message: `User @${user.username} does not have a registered email address. Please edit the user profile and add an email address first.`
});
}
res.json({ message: `Password has been reset to "${newPassword}".` });
// QEV: validate recipient email before sending reset link
const qevCheck = await getQEV()(user.email.trim());
if (!qevCheck.valid) {
return res.status(422).json({
message: `Cannot send password reset email: the registered address "${user.email}" appears to be invalid or undeliverable (${qevCheck.reason}). Please update the user's email address first.`
});
}
// Invalidate any previous pending reset links for this user
db.prepare(`
UPDATE password_reset_links
SET status = 'expired', expired_notified = 1
WHERE user_id = ? AND status = 'pending'
`).run(user.id);
// Generate cryptographically secure reset token
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + expMinutes * 60 * 1000).toISOString();
// Insert new reset link record
db.prepare(`
INSERT INTO password_reset_links (user_id, admin_id, token, email, status, expires_at)
VALUES (?, ?, ?, ?, 'pending', ?)
`).run(user.id, adminId, token, user.email.trim(), expiresAt);
// Construct reset link URL (auto-detects Cloudflare Tunnel vs LAN IP)
const baseUrl = getBestServerBaseUrl(req);
const resetLink = `${baseUrl}/api/auth/reset-password-web?token=${token}`;
const fullName = [user.first_name, user.last_name].filter(Boolean).join(' ');
// Send Email with reset link
try {
await sendPasswordResetLink({
to: user.email.trim(),
username: user.username,
resetLink: resetLink,
expiresMinutes: expMinutes,
});
// Log activity and history
logActivity(adminId, 'UPDATE', 'user', id, `Sent password reset email link to @${user.username} (${user.email}) - expires in ${expMinutes}m`);
logUserHistory(adminId, id, 'reset_password_link', user.username, fullName, user.role);
// Push notification to the initiating Admin
createNotification(
adminId,
'Password Reset Link Sent',
`Password reset link was successfully sent to @${user.username} (${user.email}). The link will expire in ${expMinutes} minutes.`,
'user',
'user',
user.id
);
return res.json({
message: `Password reset link sent to ${user.email} (expires in ${expMinutes} minutes).`
});
} catch (emailErr) {
// Update link status as failed
db.prepare("UPDATE password_reset_links SET status = 'failed' WHERE token = ?").run(token);
// Push notification to Admin that delivery failed
createNotification(
adminId,
'Password Reset Email Failed',
`Failed to send password reset email to @${user.username} (${user.email}): ${emailErr.message}`,
'user',
'user',
user.id
);
return res.status(500).json({
message: `Failed to deliver reset email to ${user.email}. Please verify SMTP settings.`,
error: emailErr.message
});
}
} catch (error) {
res.status(500).json({ message: 'Failed to reset password', error: error.message });
res.status(500).json({ message: 'Failed to initiate password reset', error: error.message });
}
};
@@ -167,7 +424,7 @@ exports.deleteUser = (req, res) => {
}
// 3. Fetch target user details (including name fields for snapshot)
const user = db.prepare('SELECT id, username, first_name, middle_name, last_name, role FROM users WHERE id = ?').get(id);
const user = db.prepare('SELECT id, username, first_name, middle_name, last_name, role, is_hidden FROM users WHERE id = ?').get(id);
if (!user) return res.status(404).json({ message: 'User not found.' });
if (user.id === adminId) return res.status(403).json({ message: 'Cannot delete your own account.' });
@@ -199,9 +456,59 @@ exports.deleteUser = (req, res) => {
}
};
// GET /api/users/:teacherId/sections
// PUT /api/users/:id/status - Toggle user active/inactive
exports.toggleUserStatus = (req, res) => {
const { id } = req.params;
const adminId = req.user.id;
if (parseInt(id) === adminId) {
return res.status(403).json({ message: 'You cannot deactivate your own account.' });
}
try {
const user = db.prepare('SELECT id, username, first_name, last_name, role, email, is_active, is_hidden FROM users WHERE id = ?').get(id);
if (!user) return res.status(404).json({ message: 'User not found.' });
const newStatus = user.is_active === 1 ? 0 : 1;
db.prepare("UPDATE users SET is_active = ?, updated_at = (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) WHERE id = ?")
.run(newStatus, id);
if (newStatus === 0) {
// Revoke all FCM push tokens immediately upon deactivation
db.prepare("DELETE FROM fcm_tokens WHERE user_id = ?").run(id);
}
const statusLabel = newStatus === 1 ? 'Activated' : 'Deactivated';
const fullName = [user.first_name, user.last_name].filter(Boolean).join(' ');
logActivity(adminId, 'UPDATE', 'user', id, `${statusLabel} user: ${user.username}`);
logUserHistory(adminId, id, statusLabel.toLowerCase(), user.username, fullName, user.role);
// Asynchronously send status change email if user has a registered email
if (user.email && user.email.trim()) {
sendAccountStatusEmail({
to: user.email.trim(),
username: user.username,
fullName,
role: user.role,
isActive: newStatus === 1,
}).catch(emailErr => {
console.error(`[UserController] Failed to send account status email to ${user.email}:`, emailErr.message);
});
}
res.json({ message: `User "${user.username}" has been ${statusLabel.toLowerCase()}.`, is_active: newStatus });
} catch (error) {
res.status(500).json({ message: 'Failed to update user status', error: error.message });
}
};
exports.getTeacherSections = (req, res) => {
const { teacherId } = req.params;
const isTeacher = req.user?.role?.toLowerCase() === 'teacher';
if (isTeacher && req.user.id !== parseInt(teacherId)) {
return res.status(403).json({ message: 'Unauthorized access to teacher sections.' });
}
try {
const sections = db.prepare(`
SELECT s.*, ay.year_range as academic_year_range
@@ -251,16 +558,53 @@ exports.getUserHistory = (req, res) => {
const offset = (page - 1) * limit;
const dateFrom = req.query.date_from || '';
const dateTo = req.query.date_to || '';
const action = (req.query.action || '').trim();
const role = req.query.role || '';
const search = (req.query.search || '').trim();
const conditions = [];
const params = [];
if (dateFrom) { conditions.push("DATE(h.created_at) >= DATE(?)"); params.push(dateFrom); }
if (dateTo) { conditions.push("DATE(h.created_at) <= DATE(?)"); params.push(dateTo); }
if (action) {
const actLower = action.toLowerCase();
if (actLower === 'all' || actLower === 'all actions') {
// no action filter
} else if (actLower === 'add' || actLower === 'create' || actLower === 'created') {
conditions.push("LOWER(h.action) IN ('created', 'create', 'add')");
} else if (actLower === 'update' || actLower === 'updated') {
conditions.push("LOWER(h.action) IN ('updated', 'update')");
} else if (actLower === 'delete' || actLower === 'deleted') {
conditions.push("LOWER(h.action) IN ('deleted', 'delete')");
} else if (actLower === 'archive' || actLower === 'archived') {
conditions.push("(LOWER(h.action) IN ('archived', 'archive') OR h.action LIKE '%archive%')");
} else {
conditions.push("LOWER(h.action) = ?");
params.push(actLower);
}
}
if (role) {
conditions.push("LOWER(h.role) = LOWER(?)");
params.push(role);
}
if (search) {
conditions.push("(h.username LIKE ? OR h.full_name LIKE ? OR COALESCE(u.username, dh.username, 'System') LIKE ? OR COALESCE(u.first_name || ' ' || u.last_name, dh.full_name, 'System') LIKE ?)");
params.push(`%${search}%`, `%${search}%`, `%${search}%`, `%${search}%`);
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const total = db.prepare(`SELECT COUNT(*) as count FROM user_history h ${where}`).get(params).count;
const total = db.prepare(`
SELECT COUNT(*) as count
FROM user_history h
LEFT JOIN users u ON h.performed_by = u.id
LEFT JOIN deleted_users_history dh ON h.performed_by = dh.deleted_user_id
${where}
`).get(params).count;
const rows = db.prepare(`
SELECT
@@ -283,3 +627,184 @@ exports.getUserHistory = (req, res) => {
res.status(500).json({ message: 'Failed to fetch user history', error: error.message });
}
};
// POST /api/users/remind-teachers (admin only)
exports.remindTeachers = async (req, res) => {
try {
// 1. Get all active teachers who have an email address
const teachers = db.prepare(`
SELECT id, username, first_name, middle_name, last_name, email
FROM users
WHERE role = 'teacher' AND is_active = 1 AND email IS NOT NULL AND TRIM(email) != ''
`).all();
if (teachers.length === 0) {
return res.json({
message: 'No active teachers with valid email addresses found.',
count: 0
});
}
let sentCount = 0;
let skippedCount = 0;
const results = [];
for (const teacher of teachers) {
const teacherName = `${teacher.first_name} ${teacher.last_name}`.trim();
// 2. Get sections assigned to this teacher
const sections = db.prepare(`
SELECT s.id, s.name, s.grade_level
FROM teacher_sections ts
JOIN sections s ON ts.section_id = s.id
WHERE ts.teacher_id = ?
ORDER BY s.grade_level ASC, s.name ASC
`).all(teacher.id);
if (sections.length === 0) {
skippedCount++;
continue;
}
const sectionsWithAttentionStudents = [];
for (const section of sections) {
// Find enrolled students in this section who have missing mandatory requirements
const students = db.prepare(`
SELECT s.id, s.lrn, s.first_name, s.middle_name, s.last_name, s.extension
FROM enrollments e
JOIN students s ON e.student_id = s.id
WHERE e.section_id = ? AND s.status = 'Enrolled'
ORDER BY s.last_name ASC, s.first_name ASC
`).all(section.id);
const sectionAttentionStudents = [];
for (const student of students) {
const studentFullName = [student.last_name, student.first_name, student.middle_name, student.extension]
.filter(Boolean)
.join(' ');
// Query missing mandatory docs for this student
const missingDocs = db.prepare(`
SELECT dr.name
FROM document_requirements dr
WHERE dr.is_mandatory = 1
AND dr.is_enabled = 1
AND dr.category IN (
SELECT DISTINCT CASE WHEN grade_level <= 10 THEN 'JHS' ELSE 'SHS' END
FROM enrollments WHERE student_id = ?
)
AND dr.id NOT IN (
SELECT requirement_id FROM documents
WHERE student_id = ? AND status = 'Completed' AND requirement_id IS NOT NULL AND deleted_at IS NULL
)
`).all(student.id, student.id).map(r => r.name);
if (missingDocs.length > 0) {
sectionAttentionStudents.push({
id: student.id,
lrn: student.lrn,
name: studentFullName,
missingDocs
});
}
}
if (sectionAttentionStudents.length > 0) {
sectionsWithAttentionStudents.push({
sectionId: section.id,
sectionName: section.name,
gradeLevel: section.grade_level,
students: sectionAttentionStudents
});
}
}
if (sectionsWithAttentionStudents.length > 0) {
try {
// QEV: validate teacher email before sending reminder
const qevResult = await getQEV()(teacher.email);
if (!qevResult.valid) {
console.warn(`[RemindTeachers] Skipping ${teacher.email}: QEV says ${qevResult.reason}`);
results.push({
teacherId: teacher.id,
teacherName,
email: teacher.email,
status: 'skipped',
error: `Invalid email: ${qevResult.reason}`
});
skippedCount++;
continue;
}
await sendTeacherAttentionReminder({
to: teacher.email,
teacherName,
sectionsWithStudents: sectionsWithAttentionStudents
});
sentCount++;
results.push({
teacherId: teacher.id,
teacherName,
email: teacher.email,
status: 'sent',
sectionsCount: sectionsWithAttentionStudents.length
});
} catch (err) {
console.error(`[RemindTeachers] Failed to send email to ${teacher.email}:`, err.message);
results.push({
teacherId: teacher.id,
teacherName,
email: teacher.email,
status: 'failed',
error: err.message
});
}
} else {
skippedCount++;
}
}
// Activity log
logActivity(
req.user?.id,
'NOTIFY',
'user',
null,
`Sent document attention reminder emails to ${sentCount} teacher(s)`
);
createNotification(
null,
'Teacher Reminders Sent',
`Sent document attention reminder emails to ${sentCount} teacher(s).`,
'user'
);
res.json({
message: `Sent attention reminders to ${sentCount} teacher${sentCount !== 1 ? 's' : ''}.`,
sentCount,
skippedCount,
results
});
} catch (error) {
console.error('remindTeachers error:', error);
res.status(500).json({ message: 'Failed to send reminders to teachers', error: error.message });
}
};
exports.getBestServerBaseUrl = getBestServerBaseUrl;
// POST /api/users/validate-email — validate a single email via QEV (admin only)
exports.validateEmail = async (req, res) => {
const { email } = req.body;
if (!email || !email.trim()) {
return res.status(400).json({ valid: false, reason: 'Email is required' });
}
try {
const result = await getQEV()(email.trim());
res.json(result);
} catch (error) {
res.status(500).json({ valid: false, reason: 'Validation service error', error: error.message });
}
};
+22 -3
View File
@@ -1,5 +1,6 @@
const jwt = require('jsonwebtoken');
require('dotenv').config();
const db = require('../config/db');
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
@@ -9,12 +10,30 @@ const authenticateToken = (req, res, next) => {
token = req.query.token;
}
if (!token) return res.status(401).json({ message: 'Authentication token required' });
if (!token || token === 'null' || token === 'undefined') {
return res.status(401).json({ message: 'Authentication token required' });
}
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) return res.status(403).json({ message: 'Invalid or expired token' });
req.user = user;
next();
try {
const dbUser = db.prepare('SELECT id, username, role, is_active FROM users WHERE id = ?').get(user.id);
if (!dbUser) {
return res.status(401).json({ message: 'User account no longer exists.' });
}
if (dbUser.is_active === 0) {
return res.status(403).json({
message: 'Your account has been deactivated. Access revoked.',
isDeactivated: true
});
}
req.user = { ...user, role: dbUser.role, is_active: dbUser.is_active };
next();
} catch (dbErr) {
return res.status(500).json({ message: 'Authentication verification error', error: dbErr.message });
}
});
};
+403 -5
View File
@@ -1,5 +1,6 @@
const db = require('../config/db');
const bcrypt = require('bcrypt');
const fs = require('fs');
const initSchema = () => {
// ── Migration: Document status rename ──────────────────────────────────
@@ -299,20 +300,44 @@ const initSchema = () => {
role TEXT CHECK(role IN ('admin', 'teacher')) NOT NULL,
email TEXT,
phone TEXT,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at DATETIME DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
)
`).run();
// Migration: add is_active column to users if missing
const userCols = db.prepare("PRAGMA table_info(users)").all();
if (!userCols.some(c => c.name === 'is_active')) {
db.prepare("ALTER TABLE users ADD COLUMN is_active INTEGER DEFAULT 1").run();
console.log('Migration: added is_active column to users table');
}
if (!userCols.some(c => c.name === 'is_hidden')) {
db.prepare("ALTER TABLE users ADD COLUMN is_hidden INTEGER DEFAULT 0").run();
console.log('Migration: added is_hidden column to users table');
}
// 2. AcademicYears Table
db.prepare(`
CREATE TABLE IF NOT EXISTS academic_years (
id INTEGER PRIMARY KEY AUTOINCREMENT,
year_range TEXT UNIQUE NOT NULL, -- e.g., "2023-2024"
status TEXT CHECK(status IN ('active', 'inactive')) DEFAULT 'active'
status TEXT CHECK(status IN ('active', 'inactive')) DEFAULT 'active',
start_date TEXT DEFAULT NULL,
end_date TEXT DEFAULT NULL
)
`).run();
const ayCols = db.prepare("PRAGMA table_info(academic_years)").all();
if (!ayCols.some(c => c.name === 'start_date')) {
db.prepare("ALTER TABLE academic_years ADD COLUMN start_date TEXT DEFAULT NULL").run();
console.log('Migration: added start_date column to academic_years table');
}
if (!ayCols.some(c => c.name === 'end_date')) {
db.prepare("ALTER TABLE academic_years ADD COLUMN end_date TEXT DEFAULT NULL").run();
console.log('Migration: added end_date column to academic_years table');
}
// 2b. Sections Table
db.prepare(`
CREATE TABLE IF NOT EXISTS sections (
@@ -365,12 +390,52 @@ const initSchema = () => {
last_name TEXT NOT NULL,
extension TEXT,
sex TEXT CHECK(sex IN ('Male', 'Female')) NOT NULL,
birth_date DATE NOT NULL,
birth_date DATE,
status TEXT DEFAULT 'Enrolled', -- Enrolled, Graduated, Transferred, Dropped
is_4ps INTEGER DEFAULT 0, -- 1 = 4Ps beneficiary, 0 = not
created_at DATETIME DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
)
`).run();
// Migration: add is_4ps column to students if missing
const studentCols = db.prepare("PRAGMA table_info(students)").all();
if (!studentCols.some(c => c.name === 'is_4ps')) {
db.prepare("ALTER TABLE students ADD COLUMN is_4ps INTEGER DEFAULT 0").run();
console.log('Migration: added is_4ps column to students table');
}
// Migration: make birth_date optional in students table
const birthDateCol = studentCols.find(c => c.name === 'birth_date');
if (birthDateCol && birthDateCol.notnull === 1) {
console.log('Migration: making birth_date optional in students table');
db.pragma('foreign_keys = OFF');
db.transaction(() => {
db.prepare(`
CREATE TABLE students_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lrn TEXT UNIQUE NOT NULL,
first_name TEXT NOT NULL,
middle_name TEXT,
last_name TEXT NOT NULL,
extension TEXT,
sex TEXT CHECK(sex IN ('Male', 'Female')) NOT NULL,
birth_date DATE,
status TEXT DEFAULT 'Enrolled',
is_4ps INTEGER DEFAULT 0,
created_at DATETIME DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
)
`).run();
db.prepare(`
INSERT INTO students_new (id, lrn, first_name, middle_name, last_name, extension, sex, birth_date, status, is_4ps, created_at)
SELECT id, lrn, first_name, middle_name, last_name, extension, sex, birth_date, status, is_4ps, created_at FROM students
`).run();
db.prepare('DROP TABLE students').run();
db.prepare('ALTER TABLE students_new RENAME TO students').run();
})();
db.pragma('foreign_keys = ON');
console.log('Migration: birth_date is now optional');
}
// 4. Enrollments Table
db.prepare(`
CREATE TABLE IF NOT EXISTS enrollments (
@@ -398,11 +463,18 @@ const initSchema = () => {
due_date DATE,
accepted_file_types TEXT DEFAULT 'pdf,jpg,jpeg,png',
school_levels TEXT DEFAULT 'JHS,SHS',
max_files INTEGER DEFAULT 1,
created_at DATETIME DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at DATETIME DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
)
`).run();
// Safe non-destructive column migration for existing databases
const reqCols = db.prepare("PRAGMA table_info(document_requirements)").all();
if (!reqCols.some(c => c.name === 'max_files')) {
db.prepare("ALTER TABLE document_requirements ADD COLUMN max_files INTEGER DEFAULT 1").run();
}
// 8b. DocumentFolders Table (for manual folder management)
db.prepare(`
CREATE TABLE IF NOT EXISTS document_folders (
@@ -432,6 +504,7 @@ const initSchema = () => {
status TEXT CHECK(status IN ('Completed','Archived')) DEFAULT 'Completed',
retention_date DATE,
uploaded_by INTEGER,
file_size INTEGER DEFAULT NULL,
created_at DATETIME DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
deleted_at DATETIME DEFAULT NULL,
FOREIGN KEY (student_id) REFERENCES students(id) ON DELETE CASCADE,
@@ -440,6 +513,102 @@ const initSchema = () => {
)
`).run();
// ── New columns on documents for template origin tracking ─────────────
const docColsV2 = db.prepare("PRAGMA table_info(documents)").all();
if (!docColsV2.some(c => c.name === 'template_id')) {
db.prepare("ALTER TABLE documents ADD COLUMN template_id INTEGER DEFAULT NULL").run();
console.log('Migration: added template_id column to documents table');
}
if (!docColsV2.some(c => c.name === 'template_version_id')) {
db.prepare("ALTER TABLE documents ADD COLUMN template_version_id INTEGER DEFAULT NULL").run();
console.log('Migration: added template_version_id column to documents table');
}
// 7b. DocumentTemplates Table
db.prepare(`
CREATE TABLE IF NOT EXISTS document_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
requirement_id INTEGER,
name TEXT NOT NULL,
description TEXT,
is_active INTEGER DEFAULT 1,
created_by INTEGER,
created_at DATETIME DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at DATETIME DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
FOREIGN KEY (requirement_id) REFERENCES document_requirements(id) ON DELETE SET NULL,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
)
`).run();
// 7c. TemplateVersions Table
db.prepare(`
CREATE TABLE IF NOT EXISTS template_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
template_id INTEGER NOT NULL,
version_number INTEGER NOT NULL DEFAULT 1,
file_name TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER DEFAULT NULL,
uploaded_by INTEGER,
is_current INTEGER DEFAULT 0,
created_at DATETIME DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
FOREIGN KEY (template_id) REFERENCES document_templates(id) ON DELETE CASCADE,
FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL
)
`).run();
// 7d. DocumentVersions Table
db.prepare(`
CREATE TABLE IF NOT EXISTS document_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id INTEGER NOT NULL,
version_number INTEGER NOT NULL DEFAULT 1,
file_name TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER DEFAULT NULL,
template_id INTEGER DEFAULT NULL,
template_version_id INTEGER DEFAULT NULL,
uploaded_by INTEGER,
notes TEXT,
created_at DATETIME DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE,
FOREIGN KEY (template_id) REFERENCES document_templates(id) ON DELETE SET NULL,
FOREIGN KEY (template_version_id) REFERENCES template_versions(id) ON DELETE SET NULL,
FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL
)
`).run();
// Migration: auto-seed Version 1 for every existing document that has no versions yet
try {
const docsWithoutVersions = db.prepare(`
SELECT d.id, d.file_name, d.file_path, d.file_size, d.uploaded_by, d.created_at,
d.template_id, d.template_version_id
FROM documents d
WHERE d.id NOT IN (SELECT DISTINCT document_id FROM document_versions)
AND d.deleted_at IS NULL
`).all();
if (docsWithoutVersions.length > 0) {
const seedVersionStmt = db.prepare(`
INSERT INTO document_versions
(document_id, version_number, file_name, file_path, file_size, template_id, template_version_id, uploaded_by, created_at)
VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?)
`);
db.transaction(() => {
for (const doc of docsWithoutVersions) {
seedVersionStmt.run(
doc.id, doc.file_name, doc.file_path, doc.file_size ?? null,
doc.template_id ?? null, doc.template_version_id ?? null,
doc.uploaded_by ?? null, doc.created_at
);
}
})();
console.log(`[Migration] Seeded document Version 1 for ${docsWithoutVersions.length} existing documents.`);
}
} catch (verErr) {
console.error('[Migration Error] Failed to seed document_versions:', verErr.message);
}
// 9. PrintQueue & Notifications
db.prepare(`
CREATE TABLE IF NOT EXISTS print_queue (
@@ -536,16 +705,103 @@ const initSchema = () => {
message TEXT NOT NULL,
is_read INTEGER DEFAULT 0,
category TEXT DEFAULT 'system', -- 'student', 'document', 'user', 'system'
entity_type TEXT DEFAULT NULL,
entity_id INTEGER DEFAULT NULL,
created_at DATETIME DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`).run();
// Migration: add category column to notifications if missing
db.prepare(`
CREATE TABLE IF NOT EXISTS fcm_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token TEXT NOT NULL UNIQUE,
updated_at DATETIME DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`).run();
// ── User Sessions (active logged-in device tracking) ──────────────────
// Populated on login, cleaned on logout / password-change / deactivation.
// token_hash is SHA-256 of the JWT so we never store the raw token.
db.prepare(`
CREATE TABLE IF NOT EXISTS user_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
device_name TEXT DEFAULT NULL,
platform TEXT DEFAULT NULL,
ip_address TEXT DEFAULT NULL,
created_at DATETIME DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
last_seen_at DATETIME DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`).run();
// Safe additive column migrations for user_sessions (if table pre-exists without some columns)
const sessionCols = db.prepare('PRAGMA table_info(user_sessions)').all();
if (!sessionCols.some(c => c.name === 'device_name')) {
db.prepare('ALTER TABLE user_sessions ADD COLUMN device_name TEXT DEFAULT NULL').run();
}
if (!sessionCols.some(c => c.name === 'platform')) {
db.prepare('ALTER TABLE user_sessions ADD COLUMN platform TEXT DEFAULT NULL').run();
}
if (!sessionCols.some(c => c.name === 'ip_address')) {
db.prepare('ALTER TABLE user_sessions ADD COLUMN ip_address TEXT DEFAULT NULL').run();
}
if (!sessionCols.some(c => c.name === 'last_seen_at')) {
db.prepare("ALTER TABLE user_sessions ADD COLUMN last_seen_at DATETIME DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))").run();
}
// ── User Login Logs (login/logout timestamp history) ──────────────────
// Append-only log: login_at always set, logout_at filled on explicit logout
// or null for sessions terminated by token expiry / deactivation.
db.prepare(`
CREATE TABLE IF NOT EXISTS user_login_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
username TEXT NOT NULL,
full_name TEXT NOT NULL,
role TEXT NOT NULL,
platform TEXT DEFAULT NULL,
ip_address TEXT DEFAULT NULL,
login_at DATETIME DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
logout_at DATETIME DEFAULT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`).run();
// Safe additive column migrations for user_login_logs
const loginLogCols = db.prepare('PRAGMA table_info(user_login_logs)').all();
if (!loginLogCols.some(c => c.name === 'platform')) {
db.prepare('ALTER TABLE user_login_logs ADD COLUMN platform TEXT DEFAULT NULL').run();
}
if (!loginLogCols.some(c => c.name === 'ip_address')) {
db.prepare('ALTER TABLE user_login_logs ADD COLUMN ip_address TEXT DEFAULT NULL').run();
}
if (!loginLogCols.some(c => c.name === 'logout_at')) {
db.prepare('ALTER TABLE user_login_logs ADD COLUMN logout_at DATETIME DEFAULT NULL').run();
}
// Backfill legacy NULL / empty / unknown platform values to default 'windows'
try {
db.prepare("UPDATE user_sessions SET platform = 'windows' WHERE platform IS NULL OR platform = '' OR platform = 'unknown'").run();
db.prepare("UPDATE user_login_logs SET platform = 'windows' WHERE platform IS NULL OR platform = '' OR platform = 'unknown'").run();
db.prepare("UPDATE user_sessions SET device_name = 'Windows PC' WHERE device_name IS NULL OR device_name = '' OR device_name = 'Unknown Device'").run();
} catch (_) {}
// Migration: add category, entity_type, entity_id column to notifications if missing
const notifCols = db.prepare("PRAGMA table_info(notifications)").all();
if (!notifCols.some(c => c.name === 'category')) {
db.prepare("ALTER TABLE notifications ADD COLUMN category TEXT DEFAULT 'system'").run();
}
if (!notifCols.some(c => c.name === 'entity_type')) {
db.prepare("ALTER TABLE notifications ADD COLUMN entity_type TEXT DEFAULT NULL").run();
}
if (!notifCols.some(c => c.name === 'entity_id')) {
db.prepare("ALTER TABLE notifications ADD COLUMN entity_id INTEGER DEFAULT NULL").run();
}
// Migration: add deleted_at column to documents if missing
const docCols = db.prepare("PRAGMA table_info(documents)").all();
@@ -554,6 +810,32 @@ const initSchema = () => {
console.log('Migration: added deleted_at column to documents table');
}
// Migration: add file_size column to documents if missing & backfill from disk
if (!docCols.some(c => c.name === 'file_size')) {
db.prepare("ALTER TABLE documents ADD COLUMN file_size INTEGER DEFAULT NULL").run();
console.log('Migration: added file_size column to documents table');
}
try {
const docsWithoutSize = db.prepare("SELECT id, file_path FROM documents WHERE file_size IS NULL OR file_size = 0").all();
if (docsWithoutSize.length > 0) {
const updateSizeStmt = db.prepare("UPDATE documents SET file_size = ? WHERE id = ?");
db.transaction(() => {
for (const doc of docsWithoutSize) {
if (doc.file_path && fs.existsSync(doc.file_path)) {
try {
const stats = fs.statSync(doc.file_path);
updateSizeStmt.run(stats.size, doc.id);
} catch (_) {}
}
}
})();
console.log(`[Migration] Backfilled file_size for ${docsWithoutSize.length} documents from disk.`);
}
} catch (sizeErr) {
console.error('[Migration Error] Failed to backfill file_size:', sizeErr.message);
}
// Migration: Backfill any documents that are soft-deleted but missing from recent_deleted
try {
const orphanDeleted = db.prepare(`
@@ -596,6 +878,72 @@ const initSchema = () => {
)
`).run();
// 10b. Password Reset OTPs Table (Email and Phone OTP verification)
db.prepare(`
CREATE TABLE IF NOT EXISTS password_reset_otps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
otp_hash TEXT NOT NULL,
delivery_method TEXT CHECK(delivery_method IN ('email', 'phone')) NOT NULL,
target TEXT NOT NULL,
is_used INTEGER DEFAULT 0,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`).run();
// 10c. Password Reset Links Table (Admin-initiated Email Reset Links with Expiration)
db.prepare(`
CREATE TABLE IF NOT EXISTS password_reset_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
admin_id INTEGER,
token TEXT UNIQUE NOT NULL,
email TEXT NOT NULL,
status TEXT CHECK(status IN ('pending', 'completed', 'expired', 'failed')) DEFAULT 'pending',
expires_at DATETIME NOT NULL,
completed_at DATETIME,
expired_notified INTEGER DEFAULT 0,
created_at DATETIME DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (admin_id) REFERENCES users(id) ON DELETE SET NULL
)
`).run();
// 10d. Account Deletion Requests Table
db.prepare(`
CREATE TABLE IF NOT EXISTS account_deletion_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token TEXT UNIQUE NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`).run();
// 11. System Settings Table
db.prepare(`
CREATE TABLE IF NOT EXISTS system_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`).run();
db.prepare(`
INSERT OR IGNORE INTO system_settings (key, value)
VALUES ('auto_update_enrollment_from_sf', 'false')
`).run();
db.prepare(`
INSERT OR IGNORE INTO system_settings (key, value)
VALUES ('remind_teachers_due_date_enabled', 'false')
`).run();
db.prepare(`
INSERT OR IGNORE INTO system_settings (key, value)
VALUES ('remind_teachers_due_date_days', '3')
`).run();
// Seed Admin if not exists
const adminUser = db.prepare("SELECT * FROM users WHERE username = 'admin' OR role = 'admin'").get();
if (!adminUser) {
@@ -607,6 +955,51 @@ const initSchema = () => {
console.log('Default Admin created: admin / admin123');
}
// Remove legacy hidden developer account if it still exists in the database
const devAdminUser = db.prepare("SELECT id FROM users WHERE username = 'developer'").get();
if (devAdminUser) {
const fallbackAdmin = db.prepare("SELECT id FROM users WHERE role = 'admin' AND username != 'developer' ORDER BY id ASC LIMIT 1").get();
const fallbackAdminId = fallbackAdmin ? fallbackAdmin.id : null;
if (fallbackAdminId) {
try { db.prepare("UPDATE documents SET uploaded_by = ? WHERE uploaded_by = ?").run(fallbackAdminId, devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE document_versions SET uploaded_by = ? WHERE uploaded_by = ?").run(fallbackAdminId, devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE template_versions SET uploaded_by = ? WHERE uploaded_by = ?").run(fallbackAdminId, devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE document_templates SET created_by = ? WHERE created_by = ?").run(fallbackAdminId, devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE document_folders SET created_by = ? WHERE created_by = ?").run(fallbackAdminId, devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE activity_log SET user_id = ? WHERE user_id = ?").run(fallbackAdminId, devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE user_history SET performed_by = ? WHERE performed_by = ?").run(fallbackAdminId, devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE printed_document_history SET user_id = ? WHERE user_id = ?").run(fallbackAdminId, devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE recent_deleted SET deleted_by = ? WHERE deleted_by = ?").run(fallbackAdminId, devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE deleted_users_history SET deleted_by = ? WHERE deleted_by = ?").run(fallbackAdminId, devAdminUser.id); } catch (_) {}
} else {
try { db.prepare("UPDATE documents SET uploaded_by = NULL WHERE uploaded_by = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE document_versions SET uploaded_by = NULL WHERE uploaded_by = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE template_versions SET uploaded_by = NULL WHERE uploaded_by = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE document_templates SET created_by = NULL WHERE created_by = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE document_folders SET created_by = NULL WHERE created_by = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE activity_log SET user_id = NULL WHERE user_id = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE user_history SET performed_by = NULL WHERE performed_by = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE printed_document_history SET user_id = NULL WHERE user_id = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE recent_deleted SET deleted_by = NULL WHERE deleted_by = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE deleted_users_history SET deleted_by = NULL WHERE deleted_by = ?").run(devAdminUser.id); } catch (_) {}
}
try { db.prepare("DELETE FROM user_sessions WHERE user_id = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("DELETE FROM user_login_logs WHERE user_id = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("DELETE FROM fcm_tokens WHERE user_id = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("DELETE FROM notifications WHERE user_id = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("DELETE FROM password_reset_requests WHERE user_id = ? OR reviewed_by = ?").run(devAdminUser.id, devAdminUser.id); } catch (_) {}
try { db.prepare("DELETE FROM password_reset_otps WHERE user_id = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("DELETE FROM password_reset_links WHERE user_id = ? OR admin_id = ?").run(devAdminUser.id, devAdminUser.id); } catch (_) {}
try { db.prepare("DELETE FROM account_deletion_requests WHERE user_id = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("DELETE FROM print_queue WHERE user_id = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("DELETE FROM teacher_sections WHERE teacher_id = ?").run(devAdminUser.id); } catch (_) {}
db.prepare("DELETE FROM users WHERE id = ?").run(devAdminUser.id);
console.log('Removed legacy developer account from database.');
}
// Seed default document requirements if none exist
const existingReqs = db.prepare("SELECT COUNT(*) as count FROM document_requirements").get();
if (existingReqs.count === 0) {
@@ -625,8 +1018,8 @@ const initSchema = () => {
{ name: '2x2 Photo', description: 'Two pieces of 2x2 colored ID photos', category: 'SHS', is_mandatory: 1 },
];
const insertReq = db.prepare(`
INSERT INTO document_requirements (name, description, category, is_mandatory, is_enabled, accepted_file_types, school_levels)
VALUES (?, ?, ?, ?, 1, 'pdf,jpg,jpeg,png', 'JHS,SHS')
INSERT INTO document_requirements (name, description, category, is_mandatory, is_enabled, accepted_file_types, school_levels, max_files)
VALUES (?, ?, ?, ?, 1, 'pdf,jpg,jpeg,png', 'JHS,SHS', 1)
`);
for (const req of defaultRequirements) {
insertReq.run(req.name, req.description, req.category, req.is_mandatory);
@@ -634,6 +1027,11 @@ const initSchema = () => {
console.log('Default document requirements seeded');
}
})();
// Auto-seed current and next academic years (e.g. 2025-2026 and 2026-2027)
// and ensure exactly one year is active
const { ensureCurrentAcademicYears } = require('../controllers/setupController');
ensureCurrentAcademicYears();
};
module.exports = { initSchema };
+1 -2
View File
@@ -10,8 +10,7 @@ router.get('/', authenticateToken, archiveController.getArchivedStudents);
router.get('/documents', authenticateToken, archiveController.getArchivedDocuments);
router.get('/student-folders', authenticateToken, archiveController.getArchivedStudentFolders);
// Restore and Purge restricted to admin
// Restore restricted to admin
router.post('/:id/restore', authenticateToken, authorizeRoles('admin'), archiveController.restoreArchive);
router.delete('/:id', authenticateToken, authorizeRoles('admin'), archiveController.purgeArchive);
module.exports = router;
+29 -4
View File
@@ -7,11 +7,36 @@ router.post('/login', authController.login);
router.get('/profile', authenticateToken, authController.getProfile);
router.put('/profile', authenticateToken, authController.updateProfile);
router.put('/change-password', authenticateToken, authController.changePassword);
router.post('/forgot-password', authController.requestPasswordReset); // Public — no token needed
router.get('/reset-requests', authenticateToken, authorizeRoles('admin'), authController.getResetRequests);
router.put('/reset-requests/:id/approve', authenticateToken, authorizeRoles('admin'), authController.approveResetRequest);
router.put('/reset-requests/:id/reject', authenticateToken, authorizeRoles('admin'), authController.rejectResetRequest);
// Self-Service Password Reset via Email OTP
router.post('/lookup-reset-options', authController.lookupResetOptions);
router.post('/send-email-otp', authController.sendEmailOtp);
router.post('/reset-password-email-otp', authController.resetPasswordEmailOtp);
// Admin-initiated Email Password Reset (Web Form & Completion)
router.get('/reset-password-web', authController.resetPasswordWebPage);
router.post('/complete-password-reset', authController.completePasswordReset);
router.post('/verify-password', authenticateToken, authController.verifyPassword);
// Self-Service Account Deletion via Email Confirmation Link
router.post('/request-delete-account', authenticateToken, authController.requestAccountDeletion);
router.get('/confirm-delete-account-web', authController.confirmDeleteAccountWebPage);
router.post('/confirm-delete-account', authController.confirmDeleteAccount);
// Self-Service Account Deactivation (immediate, 2-step client confirmation)
router.post('/self-deactivate', authenticateToken, authController.selfDeactivateAccount);
// Session management (logged-in devices)
router.get('/sessions', authenticateToken, authController.getSessions);
router.delete('/sessions', authenticateToken, authController.revokeAllOtherSessions);
router.delete('/sessions/:id', authenticateToken, authController.revokeSession);
// Logout (stamps logout_at + removes session)
router.post('/logout', authenticateToken, authController.logout);
// Login/logout history (admin only)
router.get('/login-logs', authenticateToken, authorizeRoles('admin'), authController.getLoginLogs);
module.exports = router;
+57
View File
@@ -0,0 +1,57 @@
const express = require('express');
const router = express.Router();
const backupController = require('../controllers/backupController');
const { authenticateToken, authorizeRoles } = require('../middleware/auth');
const multer = require('multer');
const fs = require('fs');
const path = require('path');
const tempUploadsDir = path.resolve('./temp_backup');
if (!fs.existsSync(tempUploadsDir)) {
fs.mkdirSync(tempUploadsDir, { recursive: true });
}
const maxBackupSizeMB = parseInt(process.env.MAX_BACKUP_SIZE_MB || '2048', 10);
const upload = multer({
dest: tempUploadsDir,
limits: { fileSize: maxBackupSizeMB * 1024 * 1024 } // allow up to 2 GB (2048 MB) by default
});
const os = require('os');
function isLocalHostOrServerIP(ip) {
if (!ip) return false;
const cleanIp = ip.replace(/^.*:/, ''); // strip ipv6 prefix like ::ffff:
if (cleanIp === '127.0.0.1' || cleanIp === '::1' || cleanIp === 'localhost') {
return true;
}
const interfaces = os.networkInterfaces();
for (const name of Object.keys(interfaces)) {
for (const iface of (interfaces[name] || [])) {
if (iface.address === cleanIp) {
return true;
}
}
}
return false;
}
function requireServerHostOnly(req, res, next) {
const ip = req.ip || req.connection?.remoteAddress || req.socket?.remoteAddress;
if (isLocalHostOrServerIP(ip)) {
return next();
}
console.warn(`[Backup] Blocked backup/restore attempt from remote IP: ${ip}`);
return res.status(403).json({
message: 'Access denied: Backup and Restore operations can only be performed from the TIS RMS server machine.'
});
}
router.get('/info', authenticateToken, authorizeRoles('admin', 'superadmin'), backupController.getBackupInfo);
router.get('/download', authenticateToken, authorizeRoles('admin', 'superadmin'), requireServerHostOnly, backupController.downloadBackup);
router.post('/restore', authenticateToken, authorizeRoles('admin', 'superadmin'), requireServerHostOnly, (req, res, next) => {
console.log('[Backup] Incoming restore request from client, starting file upload...');
next();
}, upload.single('backup'), backupController.restoreBackup);
module.exports = router;
+1
View File
@@ -5,5 +5,6 @@ const { authenticateToken } = require('../middleware/auth');
router.get('/stats', authenticateToken, dashboardController.getStats);
router.get('/recent-activities', authenticateToken, dashboardController.getRecentActivities);
router.get('/kpis', authenticateToken, dashboardController.getKpis);
module.exports = router;
+24 -15
View File
@@ -6,33 +6,42 @@ const { authenticateToken, authorizeRoles } = require('../middleware/auth');
router.get('/', authenticateToken, documentController.getAllDocuments);
router.get('/requirements', authenticateToken, documentController.getRequirements);
router.get('/statuses', authenticateToken, documentController.getStatuses);
router.get('/types', authenticateToken, documentController.getDocumentTypes);
// Print Queue routes — must be BEFORE /:id routes to avoid conflict
router.get('/print-queue', authenticateToken, documentController.getPrintQueue);
router.post('/print-queue', authenticateToken, documentController.addToPrintQueue);
router.post('/print-queue/print', authenticateToken, documentController.executePrintQueue);
router.delete('/print-queue/clear', authenticateToken, documentController.clearPrintQueue);
router.delete('/print-queue/:queueId', authenticateToken, documentController.removeFromPrintQueue);
router.get('/print-queue', authenticateToken, authorizeRoles('admin', 'teacher'), documentController.getPrintQueue);
router.get('/print-history', authenticateToken, authorizeRoles('admin', 'teacher'), documentController.getPrintHistory);
router.delete('/print-history/clear', authenticateToken, authorizeRoles('admin'), documentController.clearPrintHistory);
router.post('/print-queue', authenticateToken, authorizeRoles('admin', 'teacher'), documentController.addToPrintQueue);
router.post('/print-queue/print', authenticateToken, authorizeRoles('admin', 'teacher'), documentController.executePrintQueue);
router.post('/print-queue/merge-pdf', authenticateToken, authorizeRoles('admin', 'teacher'), documentController.mergePrintQueuePdf);
router.delete('/print-queue/clear', authenticateToken, authorizeRoles('admin', 'teacher'), documentController.clearPrintQueue);
router.delete('/print-queue/:queueId', authenticateToken, authorizeRoles('admin', 'teacher'), documentController.removeFromPrintQueue);
router.post('/print-notify-email', authenticateToken, authorizeRoles('admin', 'teacher'), documentController.sendPrintPickupNotification);
router.post('/upload', authenticateToken, documentController.uploadMiddleware, documentController.uploadDocument);
router.post('/upload', authenticateToken, authorizeRoles('admin', 'teacher'), documentController.uploadMiddleware, documentController.uploadDocument);
router.get('/student/:studentId', authenticateToken, documentController.getDocumentsByStudent);
// Recycle Bin / Trash routes — must be BEFORE /:id routes
router.get('/trash', authenticateToken, documentController.getTrashDocuments);
router.post('/bulk-restore', authenticateToken, authorizeRoles('admin'), documentController.bulkRestore);
router.post('/bulk-restore', authenticateToken, authorizeRoles('admin', 'teacher'), documentController.bulkRestore);
router.post('/bulk-permanent-delete', authenticateToken, authorizeRoles('admin'), documentController.bulkPermanentDelete);
// Bulk routes — must be BEFORE /:id routes to avoid conflict
router.post('/bulk-delete', authenticateToken, authorizeRoles('admin'), documentController.bulkDelete);
router.post('/bulk-status', authenticateToken, authorizeRoles('admin'), documentController.bulkStatus);
router.post('/bulk-print', authenticateToken, documentController.bulkAddToPrintQueue);
router.post('/bulk-copy', authenticateToken, documentController.bulkCopy);
router.post('/bulk-delete', authenticateToken, authorizeRoles('admin', 'teacher'), documentController.bulkDelete);
router.post('/bulk-status', authenticateToken, authorizeRoles('admin', 'teacher'), documentController.bulkStatus);
router.post('/bulk-print', authenticateToken, authorizeRoles('admin', 'teacher'), documentController.bulkAddToPrintQueue);
router.post('/bulk-copy', authenticateToken, authorizeRoles('admin', 'teacher'), documentController.bulkCopy);
router.get('/:id/view', authenticateToken, documentController.viewDocument);
router.post('/:id/copy', authenticateToken, documentController.copyDocument);
router.patch('/:id/status', authenticateToken, authorizeRoles('admin'), documentController.updateStatus);
router.post('/:id/restore', authenticateToken, authorizeRoles('admin'), documentController.restoreDocument);
router.get('/:id/versions', authenticateToken, documentController.getDocumentVersions);
router.post('/:id/upload-version', authenticateToken, authorizeRoles('admin', 'teacher'), documentController.uploadMiddleware, documentController.uploadDocumentVersion);
router.get('/:id/download', authenticateToken, documentController.downloadDocumentVersion);
router.get('/:id/thumbnail', authenticateToken, documentController.getThumbnail);
router.post('/:id/copy', authenticateToken, authorizeRoles('admin', 'teacher'), documentController.copyDocument);
router.patch('/:id/status', authenticateToken, authorizeRoles('admin', 'teacher'), documentController.updateStatus);
router.post('/:id/restore', authenticateToken, authorizeRoles('admin', 'teacher'), documentController.restoreDocument);
router.delete('/:id/permanent', authenticateToken, authorizeRoles('admin'), documentController.permanentDeleteDocument);
router.delete('/:id', authenticateToken, authorizeRoles('admin'), documentController.deleteDocument);
router.delete('/:id', authenticateToken, authorizeRoles('admin', 'teacher'), documentController.deleteDocument);
module.exports = router;
+4
View File
@@ -4,8 +4,12 @@ const notificationController = require('../controllers/notificationController');
const { authenticateToken } = require('../middleware/auth');
router.get('/', authenticateToken, notificationController.getNotifications);
router.post('/fcm-token', authenticateToken, notificationController.registerFcmToken);
router.delete('/fcm-token', authenticateToken, notificationController.unregisterFcmToken);
router.post('/fcm-token/unregister', authenticateToken, notificationController.unregisterFcmToken);
router.put('/mark-all-read', authenticateToken, notificationController.markAllRead);
router.put('/:id/read', authenticateToken, notificationController.markRead);
router.delete('/clear', authenticateToken, notificationController.clearNotifications);
router.delete('/:id', authenticateToken, notificationController.deleteNotification);
module.exports = router;
+6
View File
@@ -9,5 +9,11 @@ router.get('/enrollment-by-grade', authenticateToken, reportsController.getEnrol
router.get('/document-status', authenticateToken, reportsController.getDocumentStatus);
router.get('/export-data', authenticateToken, reportsController.getExportData);
router.get('/yearly-comparison', authenticateToken, reportsController.getYearlyComparison);
router.get('/storage', authenticateToken, reportsController.getStorageUsed);
router.get('/transparency-board', authenticateToken, reportsController.getTransparencyBoardData);
router.get('/transparency-board/pdf', authenticateToken, reportsController.generateTransparencyBoardPdf);
router.get('/compliance/excel', authenticateToken, reportsController.generateComplianceReportExcel);
router.post('/compliance/excel', authenticateToken, reportsController.generateComplianceReportExcel);
module.exports = router;
+23
View File
@@ -0,0 +1,23 @@
const express = require('express');
const router = express.Router();
const { authenticateToken } = require('../middleware/auth');
const {
heartbeat,
logout,
getConnectedUsers,
getServerStatus,
} = require('../controllers/connectedUsersController');
// POST /api/server/heartbeat — upsert caller into connected-users list
router.post('/heartbeat', authenticateToken, heartbeat);
// POST /api/server/logout — remove caller from connected-users list
router.post('/logout', authenticateToken, logout);
// GET /api/server/connected-users — list all currently connected users
router.get('/connected-users', authenticateToken, getConnectedUsers);
// GET /api/server/status — cpu%, mem, uptime, user count
router.get('/status', authenticateToken, getServerStatus);
module.exports = router;
+9
View File
@@ -0,0 +1,9 @@
const express = require('express');
const router = express.Router();
const settingsController = require('../controllers/settingsController');
const { authenticateToken, authorizeRoles } = require('../middleware/auth');
router.get('/', authenticateToken, authorizeRoles('admin'), settingsController.getSettings);
router.put('/', authenticateToken, authorizeRoles('admin'), settingsController.updateSettings);
module.exports = router;
+4
View File
@@ -8,6 +8,8 @@ const { authenticateToken, authorizeRoles } = require('../middleware/auth');
// ==========================================
router.get('/academic-years', authenticateToken, setupController.getAllAcademicYears);
router.post('/academic-years', authenticateToken, authorizeRoles('admin'), setupController.createAcademicYear);
router.post('/academic-years/check-auto-graduation', authenticateToken, authorizeRoles('admin'), setupController.checkAutoGraduation);
router.post('/academic-years/check-auto-archive', authenticateToken, authorizeRoles('admin'), setupController.checkAutoArchive);
router.put('/academic-years/:id', authenticateToken, authorizeRoles('admin'), setupController.updateAcademicYear);
router.delete('/academic-years/:id', authenticateToken, authorizeRoles('admin'), setupController.deleteAcademicYear);
@@ -17,7 +19,9 @@ router.delete('/academic-years/:id', authenticateToken, authorizeRoles('admin'),
router.get('/sections', authenticateToken, setupController.getAllSections);
router.get('/academic-years/:yearId/sections', authenticateToken, setupController.getSectionsByYear);
router.post('/sections', authenticateToken, authorizeRoles('admin'), setupController.createSection);
router.post('/bulk-academic-structure', authenticateToken, authorizeRoles('admin'), setupController.bulkCreateAcademicStructure);
router.put('/sections/:id', authenticateToken, authorizeRoles('admin'), setupController.updateSection);
router.put('/sections/:id/adviser', authenticateToken, authorizeRoles('admin'), setupController.setSectionAdviser);
router.delete('/sections/:id', authenticateToken, authorizeRoles('admin'), setupController.deleteSection);
// ==========================================
+11 -1
View File
@@ -9,9 +9,19 @@ router.get('/:id', authenticateToken, studentController.getStudentById);
// Create / Update / Delete restricted to super_admin & admin
router.post('/', authenticateToken, authorizeRoles('admin'), studentController.createStudent);
router.post('/verify-lrns', authenticateToken, authorizeRoles('admin'), studentController.verifyLrns);
router.post('/bulk-enroll', authenticateToken, authorizeRoles('admin'), studentController.bulkEnrollStudents);
router.post('/bulk-ocr-import', authenticateToken, authorizeRoles('admin'), studentController.bulkCreateStudents);
router.put('/bulk-graduate', authenticateToken, authorizeRoles('admin'), studentController.bulkGraduate);
router.put('/:id', authenticateToken, authorizeRoles('admin'), studentController.updateStudent);
router.post('/bulk-status', authenticateToken, authorizeRoles('admin'), studentController.bulkStatusStudents);
router.put('/:id', authenticateToken, authorizeRoles('admin', 'teacher'), studentController.updateStudent);
router.delete('/:id', authenticateToken, authorizeRoles('admin'), studentController.deleteStudent);
router.post('/:id/enrollments', authenticateToken, authorizeRoles('admin'), studentController.addEnrollment);
router.post('/:id/verify-enrollments', authenticateToken, authorizeRoles('admin'), studentController.verifyStudentEnrollments);
router.post('/:id/bulk-enrollments', authenticateToken, authorizeRoles('admin'), studentController.bulkAddStudentEnrollments);
router.post('/:id/ocr-enrollment', authenticateToken, authorizeRoles('admin'), studentController.scanEnrollmentFromSF);
router.put('/enrollments/:enrollmentId', authenticateToken, authorizeRoles('admin'), studentController.updateEnrollment);
router.delete('/enrollments/:enrollmentId', authenticateToken, authorizeRoles('admin'), studentController.deleteEnrollment);
module.exports = router;
+40
View File
@@ -0,0 +1,40 @@
const express = require('express');
const router = express.Router();
const templateController = require('../controllers/templateController');
const { authenticateToken, authorizeRoles } = require('../middleware/auth');
// ── Read (all authenticated) ──────────────────────────────────────────────────
router.get('/', authenticateToken, templateController.getTemplates);
router.get('/:id', authenticateToken, templateController.getTemplateById);
router.get('/:id/versions', authenticateToken, templateController.getTemplateVersions);
router.get('/:id/download', authenticateToken, templateController.downloadTemplate);
// ── Admin only ────────────────────────────────────────────────────────────────
router.post(
'/',
authenticateToken,
authorizeRoles('admin'),
templateController.uploadMiddleware,
templateController.createTemplate
);
router.patch(
'/:id',
authenticateToken,
authorizeRoles('admin'),
templateController.updateTemplate
);
router.post(
'/:id/upload-version',
authenticateToken,
authorizeRoles('admin'),
templateController.uploadMiddleware,
templateController.uploadTemplateVersion
);
router.delete(
'/:id',
authenticateToken,
authorizeRoles('admin'),
templateController.deleteTemplate
);
module.exports = router;
+6 -2
View File
@@ -6,16 +6,20 @@ const { authenticateToken, authorizeRoles } = require('../middleware/auth');
// All routes require authentication + role guard
router.get('/', authenticateToken, authorizeRoles('admin'), userController.getUsers);
router.post('/', authenticateToken, authorizeRoles('admin'), userController.createUser);
router.post('/remind-teachers', authenticateToken, authorizeRoles('admin'), userController.remindTeachers);
router.put('/:id', authenticateToken, authorizeRoles('admin'), userController.updateUser);
router.put('/:id/reset-password',authenticateToken, authorizeRoles('admin'), userController.resetPassword);
router.put('/:id/status', authenticateToken, authorizeRoles('admin'), userController.toggleUserStatus);
router.delete('/:id', authenticateToken, authorizeRoles('admin'), userController.deleteUser);
// Teacher Sections
router.get('/:teacherId/sections', authenticateToken, authorizeRoles('admin'), userController.getTeacherSections);
router.get('/:teacherId/sections', authenticateToken, authorizeRoles('admin', 'teacher'), userController.getTeacherSections);
router.post('/:teacherId/sections',authenticateToken, authorizeRoles('admin'), userController.updateTeacherSections);
// User History (admin only — dashboard access)
router.get('/history', authenticateToken, authorizeRoles('admin'), userController.getUserHistory);
module.exports = router;
// Email validation via QEV (admin only)
router.post('/validate-email', authenticateToken, authorizeRoles('admin'), userController.validateEmail);
module.exports = router;
+110
View File
@@ -0,0 +1,110 @@
const db = require('./config/db');
function seedData() {
console.log('Seeding random data...');
db.pragma('foreign_keys = OFF');
db.transaction(() => {
// 1. Add Academic Years
const years = ['2022-2023', '2023-2024', '2024-2025', '2025-2026', '2026-2027'];
const insertYear = db.prepare('INSERT OR IGNORE INTO academic_years (year_range, status) VALUES (?, ?)');
const yearIds = [];
for (let i = 0; i < years.length; i++) {
const status = years[i] === '2023-2024' ? 'active' : 'inactive'; // one active year
const res = insertYear.run(years[i], status);
let id = res.lastInsertRowid;
if (id === 0) {
// Was ignored, get ID
id = db.prepare('SELECT id FROM academic_years WHERE year_range = ?').get(years[i]).id;
}
yearIds.push({ id, year_range: years[i] });
}
// 2. Add Sections for each Grade (7 to 12) across different academic years
const sections = ['Aristotle', 'Socrates', 'Plato', 'Archimedes', 'Pythagoras', 'Galileo', 'Newton', 'Einstein'];
const insertSection = db.prepare('INSERT INTO sections (name, grade_level, academic_year_id) VALUES (?, ?, ?)');
const sectionIds = []; // store {id, grade_level, academic_year_id}
for (const year of yearIds) {
for (let grade = 7; grade <= 12; grade++) {
// 2 random sections per grade per year
const shuffledSections = [...sections].sort(() => 0.5 - Math.random());
for (let s = 0; s < 2; s++) {
const res = insertSection.run(shuffledSections[s], grade, year.id);
sectionIds.push({ id: res.lastInsertRowid, grade_level: grade, academic_year_id: year.id });
}
}
}
// 3. Add Random Students
const firstNamesMale = ['James', 'John', 'Robert', 'Michael', 'William', 'David', 'Richard', 'Joseph', 'Thomas', 'Charles', 'Christopher', 'Daniel', 'Matthew', 'Anthony', 'Mark', 'Donald', 'Steven', 'Paul', 'Andrew', 'Joshua'];
const firstNamesFemale = ['Mary', 'Patricia', 'Jennifer', 'Linda', 'Elizabeth', 'Barbara', 'Susan', 'Jessica', 'Sarah', 'Karen', 'Lisa', 'Nancy', 'Betty', 'Margaret', 'Sandra', 'Ashley', 'Kimberly', 'Emily', 'Donna', 'Michelle'];
const lastNames = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez', 'Hernandez', 'Lopez', 'Gonzalez', 'Wilson', 'Anderson', 'Thomas', 'Taylor', 'Moore', 'Jackson', 'Martin', 'Lee', 'Perez', 'Thompson', 'White', 'Harris', 'Sanchez', 'Clark', 'Ramirez', 'Lewis', 'Robinson', 'Walker', 'Young', 'Allen', 'King', 'Wright', 'Scott', 'Torres', 'Nguyen', 'Hill', 'Flores'];
const insertStudent = db.prepare(`
INSERT INTO students (lrn, first_name, middle_name, last_name, sex, birth_date, status)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const insertEnrollment = db.prepare(`
INSERT INTO enrollments (student_id, academic_year_id, section_id, grade_level, track_strand)
VALUES (?, ?, ?, ?, ?)
`);
// Generate 500 students
for (let i = 1; i <= 500; i++) {
const isMale = Math.random() > 0.5;
const firstName = isMale ? firstNamesMale[Math.floor(Math.random() * firstNamesMale.length)] : firstNamesFemale[Math.floor(Math.random() * firstNamesFemale.length)];
const lastName = lastNames[Math.floor(Math.random() * lastNames.length)];
const middleName = lastNames[Math.floor(Math.random() * lastNames.length)];
const sex = isMale ? 'Male' : 'Female';
const lrn = '101' + Math.floor(100000000 + Math.random() * 900000000).toString(); // 12 digit LRN
// Random birth date between 12 and 18 years ago
const age = 12 + Math.floor(Math.random() * 6);
const birthYear = 2024 - age;
const birthMonth = Math.floor(Math.random() * 12) + 1;
const birthDay = Math.floor(Math.random() * 28) + 1;
const birthDate = `${birthYear}-${birthMonth.toString().padStart(2, '0')}-${birthDay.toString().padStart(2, '0')}`;
// Status distribution: mostly Enrolled, some Graduated, few Transferred/Dropped
const randStatus = Math.random();
let status = 'Enrolled';
if (randStatus > 0.9) status = 'Graduated';
else if (randStatus > 0.85) status = 'Transferred';
else if (randStatus > 0.8) status = 'Dropped';
try {
const res = insertStudent.run(lrn, firstName, middleName, lastName, sex, birthDate, status);
const studentId = res.lastInsertRowid;
// Enroll the student in 1 to 3 random years sequentially
// Just to give them some history
const studentStartYearIndex = Math.floor(Math.random() * (yearIds.length - 2));
let currentGrade = 7 + Math.floor(Math.random() * 4); // Start at grade 7-10
for (let y = studentStartYearIndex; y < yearIds.length && currentGrade <= 12; y++) {
const year = yearIds[y];
// Find a section for this year and grade
const availableSections = sectionIds.filter(s => s.academic_year_id === year.id && s.grade_level === currentGrade);
if (availableSections.length > 0) {
const section = availableSections[Math.floor(Math.random() * availableSections.length)];
const trackStrand = currentGrade >= 11 ? 'STEM' : null; // simplified track
insertEnrollment.run(studentId, year.id, section.id, currentGrade, trackStrand);
}
currentGrade++;
}
} catch (err) {
// Ignore unique LRN constraint errors
if (!err.message.includes('UNIQUE constraint failed')) {
console.error(err);
}
}
}
})();
db.pragma('foreign_keys = ON');
console.log('Seeding completed successfully!');
}
seedData();
+75
View File
@@ -0,0 +1,75 @@
const db = require('./config/db');
function seedDocuments() {
console.log('Seeding random documents for students...');
db.pragma('foreign_keys = OFF');
db.transaction(() => {
// Get all students
const students = db.prepare('SELECT id FROM students').all();
if (students.length === 0) {
console.log('No students found! Run seed.js first.');
return;
}
// Get admin user to assign as uploaded_by
const adminUser = db.prepare("SELECT id FROM users WHERE role = 'admin' LIMIT 1").get();
const uploadedBy = adminUser ? adminUser.id : null;
// Get document requirements
const reqs = db.prepare('SELECT id, name FROM document_requirements').all();
if (reqs.length === 0) {
console.log('No document requirements found!');
return;
}
const insertDoc = db.prepare(`
INSERT INTO documents (student_id, requirement_id, file_name, file_path, document_type, status, uploaded_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const startTimestamp = Date.now() - (365 * 24 * 60 * 60 * 1000); // Up to 1 year ago
let docCount = 0;
for (const student of students) {
// Give each student 2 to 6 random documents
const numDocs = 2 + Math.floor(Math.random() * 5);
// Pick random unique requirements for this student
const studentReqs = [...reqs].sort(() => 0.5 - Math.random()).slice(0, numDocs);
for (const req of studentReqs) {
// Generate a dummy file name
const randomId = Math.floor(1000 + Math.random() * 9000);
const isPdf = Math.random() > 0.3;
const ext = isPdf ? '.pdf' : '.jpg';
const fileName = `${req.name.replace(/\s+/g, '_')}_${randomId}${ext}`;
const filePath = `/uploads/dummy/${fileName}`; // Just a dummy path
const mimeType = isPdf ? 'application/pdf' : 'image/jpeg';
// Random past date for created_at
const randomTime = startTimestamp + Math.random() * (Date.now() - startTimestamp);
const createdAt = new Date(randomTime).toISOString().replace(/\.\d{3}Z$/, 'Z');
// Status mostly Completed, sometimes Archived
const status = Math.random() > 0.9 ? 'Archived' : 'Completed';
insertDoc.run(
student.id,
req.id,
fileName,
filePath,
mimeType,
status,
uploadedBy,
createdAt
);
docCount++;
}
}
console.log(`Successfully seeded ${docCount} documents across ${students.length} students!`);
})();
db.pragma('foreign_keys = ON');
}
seedDocuments();
+188
View File
@@ -0,0 +1,188 @@
const db = require('../config/db');
const logActivity = (userId, action, entityType, entityId, description) => {
try {
let uid = userId;
const userCheck = uid ? db.prepare("SELECT id FROM users WHERE id = ?").get(uid) : null;
if (!userCheck) {
const anyUser = db.prepare("SELECT id FROM users LIMIT 1").get();
uid = anyUser ? anyUser.id : null;
}
db.prepare(`
INSERT INTO activity_log (user_id, action, entity_type, entity_id, description)
VALUES (?, ?, ?, ?, ?)
`).run(uid, action, entityType, entityId, description);
} catch (err) {
console.error('[autoArchiveService] logActivity error:', err.message);
}
};
/**
* Checks the active academic year start_date.
* If not set or no active year, does not run auto-archive.
* Grace period (in days) is fetched from system_settings ('enrollment_grace_period_days', default 30).
* If today >= (start_date + grace_period_days):
* ONLY targets students with status = 'Enrolled' (never touches Graduated, Transferred, Dropped, or Inactive).
* Checks which of these 'Enrolled' students do NOT have an enrollment record in the active academic year.
* Updates their status to 'Inactive'.
* Archives their active documents (status = 'Archived').
* Logs the activity to activity_log.
*/
const checkAndRunAutoArchive = (userId = 1) => {
try {
const enabledRow = db.prepare("SELECT value FROM system_settings WHERE key = 'auto_archive_enabled'").get();
if (enabledRow?.value === 'false') {
return { executed: false, reason: 'Automatic archiving is disabled in system settings.' };
}
const activeYear = db.prepare("SELECT * FROM academic_years WHERE status = 'active' LIMIT 1").get();
if (!activeYear) {
return { executed: false, reason: 'No active academic year found.' };
}
let scheduleDesc = '';
// Check if an explicit auto_archive_datetime is set in system_settings
const dtRow = db.prepare("SELECT value FROM system_settings WHERE key = 'auto_archive_datetime'").get();
if (dtRow?.value && dtRow.value.trim().length > 0) {
const scheduledDt = new Date(dtRow.value);
if (!isNaN(scheduledDt.getTime())) {
const now = new Date();
if (now < scheduledDt) {
return {
executed: false,
reason: `Auto-archive scheduled date and time (${dtRow.value}) has not been reached yet.`
};
}
scheduleDesc = `scheduled cutoff (${dtRow.value})`;
}
}
if (!scheduleDesc) {
if (!activeYear.start_date) {
return {
executed: false,
reason: 'Active academic year has no start_date configured.'
};
}
// Fetch grace period days from system_settings (defaults to 30 days)
const graceRow = db.prepare("SELECT value FROM system_settings WHERE key = 'enrollment_grace_period_days'").get();
const graceDays = Math.max(1, parseInt(graceRow?.value || '30', 10));
// Calculate cutoff date: start_date + graceDays
const startDate = new Date(activeYear.start_date + 'T00:00:00');
if (isNaN(startDate.getTime())) {
return { executed: false, reason: 'Invalid start_date format in active academic year.' };
}
const cutoffDate = new Date(startDate);
cutoffDate.setDate(cutoffDate.getDate() + graceDays);
const cutoffDateStr = cutoffDate.toISOString().slice(0, 10);
const todayStr = new Date().toISOString().slice(0, 10);
if (todayStr < cutoffDateStr) {
return {
executed: false,
reason: `Enrollment grace period (${graceDays} days) from academic year start (${activeYear.start_date}) has not been reached yet. Cutoff date is ${cutoffDateStr} (today: ${todayStr}).`
};
}
scheduleDesc = `${graceDays}-day grace period`;
}
// STRICT REQUIREMENT: ONLY TARGET STUDENTS WITH status = 'Enrolled'
// Never alters students who are already Graduated, Transferred, Dropped, or Inactive.
const unEnrolledStudents = db.prepare(`
SELECT DISTINCT s.id, s.lrn, s.first_name, s.last_name
FROM students s
WHERE s.status = 'Enrolled'
AND s.id NOT IN (
SELECT student_id FROM enrollments WHERE academic_year_id = ?
)
ORDER BY s.id ASC
`).all(activeYear.id);
if (unEnrolledStudents.length === 0) {
return {
executed: true,
archivedCount: 0,
message: `All enrolled students are registered in active academic year ${activeYear.year_range}. No students auto-archived.`
};
}
const updateStudentStatus = db.prepare("UPDATE students SET status = 'Inactive' WHERE id = ?");
const archiveStudentDocs = db.prepare(`
UPDATE documents
SET status = 'Archived'
WHERE student_id = ? AND deleted_at IS NULL AND status != 'Archived'
`);
db.transaction(() => {
for (const st of unEnrolledStudents) {
updateStudentStatus.run(st.id);
archiveStudentDocs.run(st.id);
}
})();
const studentSummary = unEnrolledStudents.slice(0, 20).map(s => `${s.lrn || ''} (${s.first_name} ${s.last_name})`.trim()).join(', ');
const extraText = unEnrolledStudents.length > 20 ? ` and ${unEnrolledStudents.length - 20} more` : '';
logActivity(
userId,
'ARCHIVE',
'student',
null,
`Auto-archived ${unEnrolledStudents.length} student(s) to 'Inactive' (no enrollment in AY ${activeYear.year_range} after ${scheduleDesc}): ${studentSummary}${extraText}`
);
return {
executed: true,
archivedCount: unEnrolledStudents.length,
message: `Successfully auto-archived ${unEnrolledStudents.length} student(s) to 'Inactive'.`
};
} catch (err) {
console.error('[autoArchiveService] checkAndRunAutoArchive error:', err);
return { executed: false, error: err.message };
}
};
let archiveTimer = null;
const scheduleAutoArchiveTimer = () => {
try {
if (archiveTimer) {
clearTimeout(archiveTimer);
archiveTimer = null;
}
const enabledRow = db.prepare("SELECT value FROM system_settings WHERE key = 'auto_archive_enabled'").get();
if (enabledRow?.value === 'false') {
console.log('[autoArchiveService] Auto-archiving is disabled in system settings. Timer not scheduled.');
return;
}
const dtRow = db.prepare("SELECT value FROM system_settings WHERE key = 'auto_archive_datetime'").get();
if (dtRow?.value && dtRow.value.trim().length > 0) {
const scheduledDt = new Date(dtRow.value);
if (!isNaN(scheduledDt.getTime())) {
const now = Date.now();
const diff = scheduledDt.getTime() - now;
if (diff > 0 && diff < 2147483647) {
console.log(`[autoArchiveService] Precision timer set: auto-archiving will execute in ${Math.round(diff / 1000)}s (${scheduledDt.toLocaleString()})`);
archiveTimer = setTimeout(() => {
console.log('[autoArchiveService] Scheduled time arrived! Executing auto-archive check...');
checkAndRunAutoArchive(1);
}, diff);
} else if (diff <= 0) {
console.log(`[autoArchiveService] Scheduled cutoff (${scheduledDt.toLocaleString()}) is now or in past, checking auto-archive immediately.`);
checkAndRunAutoArchive(1);
}
}
}
} catch (err) {
console.error('[autoArchiveService] scheduleAutoArchiveTimer error:', err.message);
}
};
module.exports = {
checkAndRunAutoArchive,
scheduleAutoArchiveTimer
};
@@ -0,0 +1,115 @@
const db = require('../config/db');
const logActivity = (userId, action, entityType, entityId, description) => {
try {
let uid = userId;
const userCheck = uid ? db.prepare("SELECT id FROM users WHERE id = ?").get(uid) : null;
if (!userCheck) {
const anyUser = db.prepare("SELECT id FROM users LIMIT 1").get();
uid = anyUser ? anyUser.id : null;
}
db.prepare(`
INSERT INTO activity_log (user_id, action, entity_type, entity_id, description)
VALUES (?, ?, ?, ?, ?)
`).run(uid, action, entityType, entityId, description);
} catch (err) {
console.error('logActivity error:', err.message);
}
};
/**
* Checks the active academic year start_date and end_date.
* If not set, returns { executed: false, reason: 'if not set the start day, month, end year, do not auto graduate' }.
* If set and today >= end_date, sequentially graduates Grade 10 and Grade 12 students in the active academic year.
* Generates ONE compiled log entry for Grade 10, and ONE compiled log entry for Grade 12.
*/
const checkAndRunAutoGraduation = (userId = 1) => {
try {
const activeYear = db.prepare("SELECT * FROM academic_years WHERE status = 'active' LIMIT 1").get();
if (!activeYear) {
return { executed: false, reason: 'No active academic year found.' };
}
if (!activeYear.start_date || !activeYear.end_date) {
return {
executed: false,
reason: 'if not set the start day, month, end year, do not auto graduate (start_date or end_date is missing).'
};
}
// Compare today's date (YYYY-MM-DD) with end_date
const todayStr = new Date().toISOString().slice(0, 10);
if (todayStr < activeYear.end_date) {
return {
executed: false,
reason: `Active academic year (${activeYear.year_range}) end date (${activeYear.end_date}) has not been reached yet (today: ${todayStr}).`
};
}
// --- Sequentially process Grade 10 students ---
const g10Students = db.prepare(`
SELECT DISTINCT s.id, s.lrn, s.first_name, s.last_name
FROM enrollments e
JOIN students s ON e.student_id = s.id
WHERE e.academic_year_id = ? AND e.grade_level = 10 AND s.status != 'Graduated'
ORDER BY s.id ASC
`).all(activeYear.id);
const updateStatus = db.prepare("UPDATE students SET status = 'Graduated' WHERE id = ?");
if (g10Students.length > 0) {
db.transaction(() => {
for (const st of g10Students) {
updateStatus.run(st.id);
}
})();
const studentNames = g10Students.map(s => `${s.lrn || ''} (${s.first_name} ${s.last_name})`.trim()).join(', ');
logActivity(
userId,
'UPDATE',
'student',
null,
`Auto-graduated ${g10Students.length} Grade 10 student(s) for Academic Year ${activeYear.year_range}: ${studentNames}`
);
}
// --- Sequentially process Grade 12 students ---
const g12Students = db.prepare(`
SELECT DISTINCT s.id, s.lrn, s.first_name, s.last_name
FROM enrollments e
JOIN students s ON e.student_id = s.id
WHERE e.academic_year_id = ? AND e.grade_level = 12 AND s.status != 'Graduated'
ORDER BY s.id ASC
`).all(activeYear.id);
if (g12Students.length > 0) {
db.transaction(() => {
for (const st of g12Students) {
updateStatus.run(st.id);
}
})();
const studentNames12 = g12Students.map(s => `${s.lrn || ''} (${s.first_name} ${s.last_name})`.trim()).join(', ');
logActivity(
userId,
'UPDATE',
'student',
null,
`Auto-graduated ${g12Students.length} Grade 12 student(s) for Academic Year ${activeYear.year_range}: ${studentNames12}`
);
}
return {
executed: true,
grade10Graduated: g10Students.length,
grade12Graduated: g12Students.length,
message: `Auto-graduation completed sequentially for active AY ${activeYear.year_range}. Graduated ${g10Students.length} Grade 10 and ${g12Students.length} Grade 12 student(s).`
};
} catch (err) {
console.error('[autoGraduationService] checkAndRunAutoGraduation error:', err);
return { executed: false, error: err.message };
}
};
module.exports = {
checkAndRunAutoGraduation
};
File diff suppressed because it is too large. Load diff
+227
View File
@@ -0,0 +1,227 @@
'use strict';
const ExcelJS = require('exceljs');
/**
* Generate Compliance Report Excel (.xlsx) buffer matching TIS RMS design and layout.
*
* @param {Object} data
* @param {Object} data.studentCounts - { active, dropped, transferee, graduated, inactive, fourPs }
* @param {Array} data.missingDocsBreakdown - [ { name, count } ]
* @param {Array} data.students - [ { lrn, first_name, last_name, fullName, sex, grade_level, gradeLevel, section_name, sectionName, status, missing_count, missingCount, missing_requirements, missingRequirements } ]
* @param {string} data.yearLabel - e.g. "2025-2026" or "All Years"
* @returns {Promise<Buffer>}
*/
async function generateComplianceExcel({ studentCounts = {}, missingDocsBreakdown = [], students = [], yearLabel = 'All Years' }) {
const workbook = new ExcelJS.Workbook();
workbook.creator = 'TIS RMS';
workbook.lastModifiedBy = 'TIS RMS';
workbook.created = new Date();
workbook.modified = new Date();
const borderThin = {
top: { style: 'thin', color: { argb: 'FFE2E8F0' } },
left: { style: 'thin', color: { argb: 'FFE2E8F0' } },
bottom: { style: 'thin', color: { argb: 'FFE2E8F0' } },
right: { style: 'thin', color: { argb: 'FFE2E8F0' } }
};
// ── SHEET 1: Summary ────────────────────────────────────────────────────────
const summarySheet = workbook.addWorksheet('Summary', {
views: [{ showGridLines: true }]
});
summarySheet.columns = [
{ width: 44 },
{ width: 18 }
];
// Title & Metadata
const titleRow = summarySheet.addRow(['TALISAY INTEGRATED SCHOOL - TIS RMS']);
titleRow.font = { name: 'Arial', size: 14, bold: true, color: { argb: 'FF166534' } };
const subTitleRow = summarySheet.addRow([`Annual Report Summary: ${yearLabel}`]);
subTitleRow.font = { name: 'Arial', size: 11, bold: true, color: { argb: 'FF334155' } };
const genDateStr = new Date().toISOString().replace('T', ' ').substring(0, 19);
const dateRow = summarySheet.addRow([`Generated: ${genDateStr}`]);
dateRow.font = { name: 'Arial', size: 9, italic: true, color: { argb: 'FF64748B' } };
summarySheet.addRow([]); // Blank spacer
// Section 1: Student Statistics
const sec1Header = summarySheet.addRow(['STUDENT STATISTICS', '']);
sec1Header.font = { name: 'Arial', size: 11, bold: true, color: { argb: 'FF166534' } };
sec1Header.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF0FDF4' } };
sec1Header.getCell(2).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF0FDF4' } };
const statHeader = summarySheet.addRow(['Student Status', 'Total Count']);
statHeader.font = { name: 'Arial', size: 10, bold: true, color: { argb: 'FF1E293B' } };
statHeader.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF1F5F9' } };
statHeader.getCell(2).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF1F5F9' } };
statHeader.getCell(2).alignment = { horizontal: 'right' };
statHeader.getCell(1).border = borderThin;
statHeader.getCell(2).border = borderThin;
const statsRowsData = [
['Active (Enrolled)', Number(studentCounts.active || 0)],
['Dropouts (Dropped)', Number(studentCounts.dropped || 0)],
['Transferees (Transferred)', Number(studentCounts.transferee || 0)],
['Graduated', Number(studentCounts.graduated || 0)],
];
for (const [label, count] of statsRowsData) {
const r = summarySheet.addRow([label, count]);
r.font = { name: 'Arial', size: 10, color: { argb: 'FF334155' } };
r.getCell(1).border = borderThin;
r.getCell(2).border = borderThin;
r.getCell(2).alignment = { horizontal: 'right' };
}
summarySheet.addRow([]); // Blank spacer
// Section 2: Missing Documents Per Requirement Type
const sec2Header = summarySheet.addRow(['MISSING DOCUMENTS PER REQUIREMENT TYPE', '']);
sec2Header.font = { name: 'Arial', size: 11, bold: true, color: { argb: 'FF166534' } };
sec2Header.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF0FDF4' } };
sec2Header.getCell(2).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF0FDF4' } };
const docHeader = summarySheet.addRow(['Document Type', 'Missing Count']);
docHeader.font = { name: 'Arial', size: 10, bold: true, color: { argb: 'FF1E293B' } };
docHeader.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF1F5F9' } };
docHeader.getCell(2).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF1F5F9' } };
docHeader.getCell(2).alignment = { horizontal: 'right' };
docHeader.getCell(1).border = borderThin;
docHeader.getCell(2).border = borderThin;
if (missingDocsBreakdown.length === 0) {
const r = summarySheet.addRow(['All student requirements complete', 0]);
r.font = { name: 'Arial', size: 10, color: { argb: 'FF64748B' } };
r.getCell(1).border = borderThin;
r.getCell(2).border = borderThin;
r.getCell(2).alignment = { horizontal: 'right' };
} else {
for (const row of missingDocsBreakdown) {
const r = summarySheet.addRow([row.name, Number(row.count || 0)]);
r.font = { name: 'Arial', size: 10, color: { argb: 'FF334155' } };
r.getCell(1).border = borderThin;
r.getCell(2).border = borderThin;
r.getCell(2).alignment = { horizontal: 'right' };
}
}
// ── SHEET 2: Student Compliance List ────────────────────────────────────────
const listSheet = workbook.addWorksheet('Student Compliance List', {
views: [{ state: 'frozen', xSplit: 0, ySplit: 1, showGridLines: true }]
});
listSheet.columns = [
{ header: '#', key: 'index', width: 6 },
{ header: 'LRN', key: 'lrn', width: 18 },
{ header: 'Student Name', key: 'student_name', width: 28 },
{ header: 'Sex', key: 'sex', width: 10 },
{ header: 'Grade Level', key: 'grade_level', width: 14 },
{ header: 'Section', key: 'section', width: 18 },
{ header: 'Status', key: 'status', width: 14 },
{ header: 'Missing Count', key: 'missing_count', width: 15 },
{ header: 'Missing Documents', key: 'missing_docs', width: 50 },
];
// Style the header row
const listHeaderRow = listSheet.getRow(1);
listHeaderRow.height = 26;
listHeaderRow.font = { name: 'Arial', size: 10, bold: true, color: { argb: 'FFFFFFFF' } };
listHeaderRow.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FF166534' } // Forest Green
};
listHeaderRow.alignment = { vertical: 'middle', horizontal: 'center' };
listHeaderRow.getCell('student_name').alignment = { vertical: 'middle', horizontal: 'left' };
listHeaderRow.getCell('missing_docs').alignment = { vertical: 'middle', horizontal: 'left' };
for (let c = 1; c <= 9; c++) {
listHeaderRow.getCell(c).border = borderThin;
}
// Populate Student Data Rows
for (let i = 0; i < students.length; i++) {
const s = students[i];
const fullName = s.fullName ||
([s.last_name, s.first_name].filter(Boolean).join(', ') || 'N/A');
const gradeVal = s.grade_level != null ? s.grade_level : s.gradeLevel;
const gradeStr = gradeVal != null ? `Grade ${gradeVal}` : 'N/A';
const sectionStr = s.section_name || s.sectionName || 'N/A';
const missingCount = s.missing_count != null ? Number(s.missing_count) : Number(s.missingCount || 0);
const missingDocsStr = s.missing_requirements || s.missingRequirements ||
(missingCount === 0 ? 'None (Complete)' : 'N/A');
const row = listSheet.addRow({
index: i + 1,
lrn: s.lrn || '',
student_name: fullName,
sex: s.sex || 'N/A',
grade_level: gradeStr,
section: sectionStr,
status: s.status || 'N/A',
missing_count: missingCount,
missing_docs: missingDocsStr,
});
row.height = 20;
row.font = { name: 'Arial', size: 9.5, color: { argb: 'FF1F2937' } };
const isEven = i % 2 === 1;
const rowBg = isEven ? 'FFF8FAFC' : 'FFFFFFFF';
for (let colIdx = 1; colIdx <= 9; colIdx++) {
const cell = row.getCell(colIdx);
cell.border = borderThin;
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: rowBg } };
cell.alignment = { vertical: 'middle' };
}
// Specific cell alignments
row.getCell('index').alignment = { vertical: 'middle', horizontal: 'center' };
row.getCell('lrn').alignment = { vertical: 'middle', horizontal: 'center' };
row.getCell('sex').alignment = { vertical: 'middle', horizontal: 'center' };
row.getCell('grade_level').alignment = { vertical: 'middle', horizontal: 'center' };
row.getCell('status').alignment = { vertical: 'middle', horizontal: 'center' };
row.getCell('missing_count').alignment = { vertical: 'middle', horizontal: 'right' };
row.getCell('student_name').alignment = { vertical: 'middle', horizontal: 'left' };
row.getCell('missing_docs').alignment = { vertical: 'middle', horizontal: 'left' };
// Highlight missing count if > 0
if (missingCount > 0) {
row.getCell('missing_count').font = {
name: 'Arial',
size: 9.5,
bold: true,
color: { argb: 'FFDC2626' }
};
} else {
row.getCell('missing_count').font = {
name: 'Arial',
size: 9.5,
color: { argb: 'FF166534' }
};
}
}
// Auto-filter on student list
if (students.length > 0) {
listSheet.autoFilter = {
from: { row: 1, column: 1 },
to: { row: students.length + 1, column: 9 }
};
}
const buffer = await workbook.xlsx.writeBuffer();
return Buffer.from(buffer);
}
module.exports = {
generateComplianceExcel,
};
+221
View File
@@ -0,0 +1,221 @@
'use strict';
const path = require('path');
const fs = require('fs');
const { initializeApp, getApps, cert } = require('firebase-admin/app');
const { getMessaging } = require('firebase-admin/messaging');
let messaging = null;
let initialized = false;
const init = () => {
if (initialized) return;
initialized = true;
const serviceAccountPath = process.env.FIREBASE_SERVICE_ACCOUNT;
if (!serviceAccountPath) {
console.log('[FCM] FIREBASE_SERVICE_ACCOUNT not set — push notifications disabled');
return;
}
try {
let resolvedPath = path.isAbsolute(serviceAccountPath)
? serviceAccountPath
: path.resolve(process.cwd(), serviceAccountPath);
if (!fs.existsSync(resolvedPath)) {
const candidates = [
path.join(__dirname, '..', '..', serviceAccountPath),
path.join(__dirname, '..', '..', '..', serviceAccountPath)
];
for (const cand of candidates) {
if (fs.existsSync(cand)) {
resolvedPath = cand;
break;
}
}
}
if (!fs.existsSync(resolvedPath)) {
console.error('[FCM] Service account file not found at:', resolvedPath);
return;
}
const serviceAccount = JSON.parse(fs.readFileSync(resolvedPath, 'utf8'));
if (!getApps().length) {
initializeApp({ credential: cert(serviceAccount) });
}
messaging = getMessaging();
console.log('[FCM] Firebase Admin initialized successfully');
} catch (err) {
console.error('[FCM] Init failed:', err.message);
messaging = null;
}
};
const _cleanInvalidTokens = (tokens, responses) => {
if (!tokens || !responses) return;
const db = require('../config/db');
const invalidCodes = [
'messaging/registration-token-not-registered',
'messaging/invalid-registration-token',
];
const stmt = db.prepare('DELETE FROM fcm_tokens WHERE token = ?');
responses.forEach((res, i) => {
if (!res.success && invalidCodes.includes(res.error?.code)) {
try {
stmt.run(tokens[i]);
} catch (_) {}
}
});
};
const _sendMulticast = async (tokens, title, body, category = 'system', notificationId = null) => {
if (!messaging || !tokens || tokens.length === 0) return;
// Deduplicate tokens
const uniqueTokens = Array.from(new Set(tokens.filter(Boolean)));
if (uniqueTokens.length === 0) return;
for (let i = 0; i < uniqueTokens.length; i += 500) {
const chunk = uniqueTokens.slice(i, i + 500);
try {
const result = await messaging.sendEachForMulticast({
tokens: chunk,
notification: {
title: title || 'TIS RMS',
body: body || ''
},
data: {
id: String(notificationId || ''),
title: String(title || 'TIS RMS'),
body: String(body || ''),
category: String(category || 'system'),
click_action: 'FLUTTER_NOTIFICATION_CLICK'
},
android: {
priority: 'high',
notification: {
channelId: 'tis_rms_activities_channel',
sound: 'default',
priority: 'high',
defaultSound: true,
defaultVibrateTimings: true,
visibility: 'public',
icon: 'ic_launcher_foreground',
clickAction: 'FLUTTER_NOTIFICATION_CLICK',
tag: notificationId ? String(notificationId) : undefined
}
}
});
_cleanInvalidTokens(chunk, result.responses);
} catch (err) {
console.error('[FCM] Send batch error:', err.message);
}
}
};
/**
* Sends push notification to the appropriate audience in real-time.
* - Direct: user_id provided
* - Broadcast / Scoped: all admins + relevant section teachers (for student/document) or all users
*/
const sendNotification = async ({ userId = null, title, body, category = 'system', entityType = null, entityId = null, notificationId = null }) => {
if (!messaging) return;
const db = require('../config/db');
try {
let tokens = [];
if (userId) {
// Direct message to a specific active user
tokens = db.prepare(`
SELECT ft.token
FROM fcm_tokens ft
JOIN users u ON ft.user_id = u.id
WHERE ft.user_id = ? AND u.is_active = 1
`)
.all(userId)
.map(r => r.token);
} else {
// 1. All admins always receive system & activity updates
const adminTokens = db.prepare(`
SELECT ft.token
FROM fcm_tokens ft
JOIN users u ON ft.user_id = u.id
WHERE u.role = 'admin' AND u.is_active = 1
`).all().map(r => r.token);
tokens.push(...adminTokens);
// 2. Target assigned teachers if scoped to student, document, or section
if (entityType === 'student' && entityId) {
const teacherTokens = db.prepare(`
SELECT DISTINCT ft.token
FROM fcm_tokens ft
JOIN users u ON ft.user_id = u.id
JOIN teacher_sections ts ON u.id = ts.teacher_id
JOIN enrollments e ON ts.section_id = e.section_id
WHERE e.student_id = ? AND u.role = 'teacher' AND u.is_active = 1
AND e.id = (
SELECT e2.id FROM enrollments e2
JOIN academic_years ay ON e2.academic_year_id = ay.id
WHERE e2.student_id = ?
ORDER BY e2.grade_level DESC, ay.year_range DESC, e2.id DESC LIMIT 1
)
`).all(entityId, entityId).map(r => r.token);
tokens.push(...teacherTokens);
} else if (entityType === 'document' && entityId) {
const teacherTokens = db.prepare(`
SELECT DISTINCT ft.token
FROM fcm_tokens ft
JOIN users u ON ft.user_id = u.id
JOIN teacher_sections ts ON u.id = ts.teacher_id
JOIN enrollments e ON ts.section_id = e.section_id
JOIN documents d ON e.student_id = d.student_id
WHERE d.id = ? AND u.role = 'teacher' AND u.is_active = 1
AND e.id = (
SELECT e2.id FROM enrollments e2
JOIN academic_years ay ON e2.academic_year_id = ay.id
WHERE e2.student_id = d.student_id
ORDER BY e2.grade_level DESC, ay.year_range DESC, e2.id DESC LIMIT 1
)
`).all(entityId).map(r => r.token);
tokens.push(...teacherTokens);
} else if (entityType === 'section' && entityId) {
const teacherTokens = db.prepare(`
SELECT DISTINCT ft.token
FROM fcm_tokens ft
JOIN users u ON ft.user_id = u.id
JOIN teacher_sections ts ON u.id = ts.teacher_id
WHERE ts.section_id = ? AND u.role = 'teacher' AND u.is_active = 1
`).all(entityId).map(r => r.token);
tokens.push(...teacherTokens);
} else if (!entityType) {
// Broadcast to all active teachers for unscoped system notifications
const allTeacherTokens = db.prepare(`
SELECT ft.token
FROM fcm_tokens ft
JOIN users u ON ft.user_id = u.id
WHERE u.role = 'teacher' AND u.is_active = 1
`).all().map(r => r.token);
tokens.push(...allTeacherTokens);
}
}
await _sendMulticast(tokens, title, body, category, notificationId);
} catch (err) {
console.error('[FCM] sendNotification error:', err.message);
}
};
const sendToUser = async (userId, title, body) => {
return sendNotification({ userId, title, body });
};
const sendToAll = async (title, body) => {
return sendNotification({ title, body });
};
module.exports = { init, sendNotification, sendToUser, sendToAll };
+578 -66
View File
@@ -1,3 +1,10 @@
const fs = require('fs');
const path = require('path');
const ExcelJS = require('exceljs');
const { execFile } = require('child_process');
const util = require('util');
const execFileAsync = util.promisify(execFile);
// ==========================================
// SF9 PARSER (Report Card)
// ==========================================
@@ -31,40 +38,57 @@ exports.parseSF9 = (text) => {
if (lrnMatch) {
extracted.lrn = lrnMatch[1].replace(/[\s\-\.]/g, '');
} else {
const fallbackLrn = processedText.match(/\b\d{12}\b/);
if (fallbackLrn) extracted.lrn = fallbackLrn[0];
}
// 2. Extract Name (Format: "Name: LASTNAME, FIRSTNAME MIDDLENAME")
const sf9NameMatch = text.match(/Name[:\s]+([^,\n]+),\s*([^\n]+)/i);
if (sf9NameMatch) {
extracted.lastName = sf9NameMatch[1].trim();
const firstMiddle = sf9NameMatch[2].trim().split(/\s+/);
if (firstMiddle.length > 1) {
extracted.middleName = firstMiddle.pop();
extracted.firstName = firstMiddle.join(' ');
} else {
extracted.firstName = firstMiddle[0];
}
}
// 3. Extract Extension Name from First Name or Last Name
extracted = extractExtension(extracted);
// 2. Extract Name fields
const nameData = extractStudentName(text);
extracted.lastName = nameData.lastName;
extracted.firstName = nameData.firstName;
extracted.middleName = nameData.middleName;
extracted.extension = nameData.extension;
// 4. Extract SF9 Specifics
const sexMatch = text.match(/(?:Sex|Gender)[:\s]*(MALE|FEMALE|M\b|F\b)/i);
extracted.dob = extractDob(text);
const sexMatch = text.match(/(?:Sex|Gender)[:\s,]*(MALE|FEMALE|M\b|F\b)/i);
if (sexMatch) extracted.sex = sexMatch[1].toUpperCase().startsWith('M') ? 'Male' : 'Female';
const gradeMatch = text.match(/Grade[:\s]*(\d+)/i);
const gradeMatch = text.match(/(?:Grade|Grade\s*Level)[:\s,]*(\d+)/i);
if (gradeMatch) extracted.gradeLevel = gradeMatch[1];
const sectionMatch = text.match(/Section[:\s]*([^\n]+)/i);
if (sectionMatch) extracted.section = sectionMatch[1].trim();
const sectionMatch = text.match(/Section[:\s,]*([^\r\n,]+)/i);
if (sectionMatch) {
let sec = sectionMatch[1].trim();
sec = sec.replace(/^[^A-Za-z0-9]+/, '').trim();
extracted.section = sec;
}
const syMatch = text.match(/School Year[:\s]*(\d{4}\s*-\s*\d{4})/i);
const syMatch = text.match(/(?:School\s*Year|S\.?Y\.?)[:\s,]*(\d{4}\s*-\s*\d{4})/i);
if (syMatch) extracted.schoolYear = syMatch[1].replace(/\s/g, '');
const trackMatch = text.match(/TRACK\/STRAND[:\s]*([^\n]+)/i);
const trackMatch = text.match(/(?:TRACK\s*\/?\s*STRAND|TRACK|STRAND)[:\s,]*([A-Za-z0-9\/\-\s]+?)(?=\s*(?:Admitted|Grade|Section|School\s*Year|S\.?Y\.?|LRN|SEX|DOB|DATE|\r|\n|$))/i);
if (trackMatch) extracted.trackStrand = trackMatch[1].trim();
const advMatch = text.match(/(?:Teacher|Adviser|Name\s*of\s*Adviser\/?Teacher)[:\s,]*([A-Za-z\s\-\.]+?)(?=\s*(?:Principal|Signature|Date|\r|\n|$))/i);
if (advMatch) {
let adv = advMatch[1].replace(/[,:]/g, '').trim();
adv = adv.replace(/\s*(?:Signature|Date).*$/i, '').trim();
extracted.adviserName = adv;
}
extracted.scholasticRecords = exports.extractAllScholasticRecords(text);
if (extracted.scholasticRecords.length > 0) {
const latest = extracted.scholasticRecords[extracted.scholasticRecords.length - 1];
if (!extracted.gradeLevel) extracted.gradeLevel = latest.gradeLevel;
if (!extracted.section) extracted.section = latest.section;
if (!extracted.schoolYear) extracted.schoolYear = latest.schoolYear;
if (!extracted.adviserName) extracted.adviserName = latest.adviserName || '';
extracted.semester = latest.semester || '';
}
return extracted;
};
@@ -99,71 +123,559 @@ exports.parseSF10 = (text) => {
if (fallbackLrn) extracted.lrn = fallbackLrn[0];
}
// 2. Extract Name (Format: "LAST NAME: ... FIRST NAME: ...")
const lastNameMatch = text.match(/LAST\s*NAME[:\s]*([A-Za-z\-\s,\.]+?)(?=\s*(FIRST|MIDDLE|\n|$))/i);
if (lastNameMatch) extracted.lastName = lastNameMatch[1].trim();
const firstNameMatch = text.match(/FIRST\s*NAME[:\s]*([A-Za-z\-\s,\.]+?)(?=\s*(LAST|MIDDLE|\n|$))/i);
if (firstNameMatch) extracted.firstName = firstNameMatch[1].trim();
const middleNameMatch = text.match(/MIDDLE\s*NAME[:\s]*([A-Za-z\-\s,\.]+?)(?=\s*(LRN|SEX|DOB|DATE|\n|$))/i);
if (middleNameMatch) extracted.middleName = middleNameMatch[1].trim();
// Fallback if specific labels fail
if (!extracted.lastName && !extracted.firstName) {
const fallbackNameMatch = text.match(/Name[:\s]+([A-Za-z]+),\s+([A-Za-z]+)\s*([A-Za-z]+)?/i);
if (fallbackNameMatch) {
extracted.lastName = fallbackNameMatch[1] || '';
extracted.firstName = fallbackNameMatch[2] || '';
extracted.middleName = fallbackNameMatch[3] || '';
}
}
// 3. Extract Extension Name
extracted = extractExtension(extracted);
// 2. Extract Name fields
const nameData = extractStudentName(text);
extracted.lastName = nameData.lastName;
extracted.firstName = nameData.firstName;
extracted.middleName = nameData.middleName;
extracted.extension = nameData.extension;
// 4. Extract SF10 Specifics
const dobMatch = text.match(/\b(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})\b/);
if (dobMatch) {
const month = dobMatch[1].padStart(2, '0');
const day = dobMatch[2].padStart(2, '0');
const year = dobMatch[3];
extracted.dob = `${year}-${month}-${day}`;
}
extracted.dob = extractDob(text);
const sexMatch = text.match(/(?:Sex|Gender)[:\s]*(MALE|FEMALE|M\b|F\b)/i);
const sexMatch = text.match(/(?:Sex|Gender)[:\s,]*(MALE|FEMALE|M\b|F\b)/i);
if (sexMatch) extracted.sex = sexMatch[1].toUpperCase().startsWith('M') ? 'Male' : 'Female';
const gradeMatch = text.match(/GRADE LEVEL[:\s]*(\d+)/i);
if (gradeMatch) extracted.gradeLevel = gradeMatch[1];
const sectionMatch = text.match(/SECTION[:\s]*([^\n]+)/i);
if (sectionMatch) extracted.section = sectionMatch[1].trim();
const syMatch = text.match(/S\.?Y\.?[:\s]*(\d{4}\s*-\s*\d{4})/i);
if (syMatch) extracted.schoolYear = syMatch[1].replace(/\s/g, '');
extracted.scholasticRecords = exports.extractAllScholasticRecords(text);
if (extracted.scholasticRecords.length > 0) {
const latest = extracted.scholasticRecords[extracted.scholasticRecords.length - 1];
extracted.gradeLevel = latest.gradeLevel;
extracted.section = latest.section;
extracted.schoolYear = latest.schoolYear;
extracted.adviserName = latest.adviserName || '';
extracted.semester = latest.semester || '';
}
return extracted;
};
// ==========================================
// EXTRACT ALL SCHOLASTIC RECORDS (PDF, Image, Excel)
// ==========================================
exports.extractAllScholasticRecords = (text) => {
const records = [];
if (!text || typeof text !== 'string') return records;
const gradeRegex = /(?:GRADE\s*LEVEL|Classified\s*as\s*Grade|GRADE\s*:)[\s,:]*([7-9]|1[0-2])\b/gi;
let match;
while ((match = gradeRegex.exec(text)) !== null) {
const gradeLevel = match[1];
const gNum = parseInt(gradeLevel, 10);
if (isNaN(gNum) || gNum < 7 || gNum > 12) continue;
const startIndex = match.index;
const block = text.slice(startIndex, startIndex + 600);
// 1. Section
let section = '';
const secMatch = block.match(/SECTION[\s,:]*([A-Za-z0-9\-\s]+?)(?=\s*(?:FIRST|SECOND|1ST|2ND|SEM|SCHOOL\s*YEAR|S\.?Y\.?|NAME\s*OF|ADVISER|TEACHER|,|\n|$))/i);
if (secMatch) {
section = secMatch[1].replace(/[,:]/g, '').trim();
}
// 2. School Year
let schoolYear = '';
const syMatch = block.match(/(?:SCHOOL\s*YEAR|S\.?Y\.?)[\s,:]*(\d{4}\s*-\s*\d{4})/i);
if (syMatch) {
schoolYear = syMatch[1].replace(/\s/g, '');
}
// 3. Adviser / Teacher Name
let adviserName = '';
const advMatch = block.match(/(?:NAME\s*OF\s*ADVISER\/?TEACHER|ADVISER\/?TEACHER|ADVISER|TEACHER)[\s,:]*([A-Za-z\s\-\.]+?)(?=\s*(?:SIGNATURE|DATE|LEARNING|QUARTERLY|,|\n|$))/i);
if (advMatch) {
adviserName = advMatch[1].replace(/[,:]/g, '').trim();
adviserName = adviserName.replace(/\s*(?:Signature|Date).*$/i, '').trim();
}
// 4. Semester
let semester = '';
const semMatch = block.match(/(?:SEM(?:ESTER)?|SEM)[\s,:]*(FIRST|SECOND|1ST|2ND|1|2)/i) ||
block.match(/\b(FIRST|SECOND|1ST|2ND)\s*SEMESTER\b/i);
if (semMatch) {
semester = semMatch[1].toUpperCase();
}
if (gradeLevel && section && schoolYear) {
records.push({
gradeLevel,
section,
schoolYear,
adviserName,
semester
});
}
}
// Deduplicate records: if two semesters or blocks have the same (gradeLevel, schoolYear, section), do not duplicate!
const seen = new Set();
const deduplicated = [];
for (const rec of records) {
const key = `${rec.gradeLevel}_${rec.schoolYear}_${rec.section}`.toLowerCase();
if (!seen.has(key)) {
seen.add(key);
deduplicated.push(rec);
} else {
console.log(`[ocrParser] Deduplicating same semester/grade record -> Grade: ${rec.gradeLevel}, SY: ${rec.schoolYear}, Sec: ${rec.section}`);
}
}
return deduplicated;
};
// ==========================================
// HELPER FUNCTION
// ==========================================
function extractExtension(extracted) {
const extRegex = /(?:,\s*|\b)(JR\.?|SR\.?|II|III|IV|V|VI)\b/i;
// ==========================================
// HELPER FUNCTIONS FOR NAME CLEANING
// ==========================================
function cleanNameField(str) {
if (!str || typeof str !== 'string') return '';
let extMatch = extracted.lastName.match(extRegex);
if (extMatch) {
extracted.extension = extMatch[1].replace(/[\.,]/g, '').trim().toUpperCase();
extracted.lastName = extracted.lastName.replace(extMatch[0], '').replace(/,$/, '').trim();
// 1. Remove unwanted field headers/labels if accidentally captured in name string
let cleaned = str.replace(/\b(?:LAST|FIRST|MIDDLE|SURNAME|FAMILY|GIVEN|NAME|EXTENSION|SUFFIX|EXT|LEARNER'?S?|STUDENT|LRN|SEX|GENDER|DOB|BIRTH|DATE|GRADE|SECTION|SCHOOL|S\.?Y\.?)\b/gi, ' ');
// 2. Keep only valid name characters: letters, hyphens, periods, apostrophes, and spaces
cleaned = cleaned.replace(/[^A-Za-z\-\.\'\s]/g, ' ');
// 3. Split into trimmed words and strip lone or boundary periods
let words = cleaned.trim().split(/\s+/).map(w => w.replace(/^\.+|\.+$/g, '')).filter(w => w.length > 0);
if (words.length === 0) return '';
// 4. Iteratively deduplicate any repeated adjacent sub-sequences of words (e.g. 1-word, 2-word, 3-word repeats)
let changed = true;
while (changed) {
changed = false;
const maxChunk = Math.floor(words.length / 2);
for (let k = maxChunk; k >= 1; k--) {
for (let i = 0; i <= words.length - 2 * k; i++) {
const chunk1 = words.slice(i, i + k).map(w => w.toLowerCase()).join(' ');
const chunk2 = words.slice(i + k, i + 2 * k).map(w => w.toLowerCase()).join(' ');
if (chunk1.length > 0 && chunk1 === chunk2) {
words.splice(i + k, k);
changed = true;
break;
}
}
if (changed) break;
}
}
return words.join(' ');
}
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function extractStudentName(text) {
let lastName = '';
let firstName = '';
let middleName = '';
let extension = '';
// 1. Try explicit separate headers: LAST NAME, FIRST NAME, MIDDLE NAME, EXTENSION
const lastNameMatch = text.match(/\b(?:LAST\s*NAME|SURNAME|FAMILY\s*NAME)[:\s,]*([A-Za-z\-\ \.\']+?)(?=\s*(?:FIRST|GIVEN|MIDDLE|\bM\.?I\.?\b|EXT|SUFFIX|NAME|LRN|SEX|DOB|DATE|GRADE|SECTION|S\.?Y\.?|,|\r|\n|$))/i);
const firstNameMatch = text.match(/\b(?:FIRST\s*NAME|GIVEN\s*NAME)[:\s,]*([A-Za-z\-\ \.\']+?)(?=\s*(?:LAST|SURNAME|MIDDLE|\bM\.?I\.?\b|EXT|SUFFIX|NAME|LRN|SEX|DOB|DATE|GRADE|SECTION|S\.?Y\.?|,|\r|\n|$))/i);
const middleNameMatch = text.match(/\b(?:MIDDLE\s*NAME|MIDDLE\s*INITIAL|MIDDLE|\bM\.?I\.?\b)[:\s,]*([A-Za-z\-\ \.\']+?)(?=\s*(?:LAST|SURNAME|FIRST|GIVEN|EXT|SUFFIX|NAME|LRN|SEX|DOB|DATE|GRADE|SECTION|S\.?Y\.?|,|\r|\n|$))/i);
const extensionMatch = text.match(/\b(?:EXTENSION\s*NAME|EXT\.?\s*NAME|NAME\s*EXT\.?|EXTENSION|SUFFIX|EXT)[:\s,]*([A-Za-z0-9\.\-]+?)(?=\s*(?:LAST|FIRST|MIDDLE|\bM\.?I\.?\b|NAME|LRN|SEX|DOB|DATE|GRADE|SECTION|S\.?Y\.?|,|\r|\n|$))/i);
if (lastNameMatch) lastName = lastNameMatch[1].trim();
if (firstNameMatch) firstName = firstNameMatch[1].trim();
if (middleNameMatch) middleName = middleNameMatch[1].trim();
if (extensionMatch) extension = extensionMatch[1].trim();
// 2. Try combined format if separate headers didn't yield both lastName and firstName
if (!lastName || !firstName) {
const combinedMatch = text.match(/(?:Name|Learner'?s?\s*Name|Name\s*of\s*Learner|Name\s*of\s*Student)[:\s,]+([A-Za-z\-\ \.\']+?),\s*([A-Za-z\-\ \.\']+?)(?=\s*(?:LRN|SEX|GENDER|DOB|BIRTH|DATE|GRADE|SECTION|SCHOOL|S\.?Y\.?|TRACK|STRAND|\r|\n|$))/i);
if (combinedMatch) {
const candLast = combinedMatch[1].trim();
let candRest = combinedMatch[2].trim().split(/\s+/);
if (!lastName) lastName = candLast;
if (candRest.length > 0) {
const extCheck = candRest[candRest.length - 1].toUpperCase().replace(/\./g, '');
if (['JR', 'SR', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X'].includes(extCheck)) {
if (!extension) extension = candRest.pop();
}
if (candRest.length >= 2 && !middleName) {
middleName = candRest.pop();
}
if (!firstName) firstName = candRest.join(' ');
}
}
}
// 3. Initial cleaning of fields
lastName = cleanNameField(lastName);
firstName = cleanNameField(firstName);
middleName = cleanNameField(middleName);
extension = extension.replace(/[\.,]/g, '').trim().toUpperCase();
// 4. Remove cross-field duplicate values ONLY when target field has extra remaining words
const cleanLowerLast = lastName.toLowerCase();
const cleanLowerFirst = firstName.toLowerCase();
if (lastName && firstName && cleanLowerFirst !== cleanLowerLast) {
const lastPattern = new RegExp(`\\b${escapeRegExp(lastName)}\\b`, 'gi');
const candidateFirst = firstName.replace(lastPattern, '').trim();
if (candidateFirst.length > 0) {
firstName = candidateFirst;
}
}
if (lastName && middleName && middleName.toLowerCase() !== cleanLowerLast) {
const lastPattern = new RegExp(`\\b${escapeRegExp(lastName)}\\b`, 'gi');
const candidateMiddle = middleName.replace(lastPattern, '').trim();
if (candidateMiddle.length > 0) {
middleName = candidateMiddle;
}
}
if (firstName && middleName && middleName.toLowerCase() !== cleanLowerFirst) {
const firstPattern = new RegExp(`\\b${escapeRegExp(firstName)}\\b`, 'gi');
const candidateMiddle = middleName.replace(firstPattern, '').trim();
if (candidateMiddle.length > 0) {
middleName = candidateMiddle;
}
}
// Clean fields again after cross-field deduplication
lastName = cleanNameField(lastName);
firstName = cleanNameField(firstName);
middleName = cleanNameField(middleName);
// 5. Extract extension name from lastName, firstName, or middleName if not already found
let res = extractExtension({ lastName, firstName, middleName, extension });
res.lastName = cleanNameField(res.lastName);
res.firstName = cleanNameField(res.firstName);
res.middleName = cleanNameField(res.middleName);
return res;
}
function extractExtension(extracted) {
const extRegex = /(?:,\s*|\b)(JR\.?|SR\.?|I{1,3}|IV|V|VI{0,3}|IX|X)\b/i;
if (!extracted.extension) {
let extMatch = extracted.lastName.match(extRegex);
if (extMatch) {
extracted.extension = extMatch[1].replace(/[\.,]/g, '').trim().toUpperCase();
extracted.lastName = extracted.lastName.replace(extMatch[0], '').replace(/,$/, '').trim();
}
}
if (!extracted.extension) {
extMatch = extracted.firstName.match(extRegex);
let extMatch = extracted.firstName.match(extRegex);
if (extMatch) {
extracted.extension = extMatch[1].replace(/[\.,]/g, '').trim().toUpperCase();
extracted.firstName = extracted.firstName.replace(extMatch[0], '').replace(/,$/, '').trim();
}
}
if (!extracted.extension) {
let extMatch = extracted.middleName.match(extRegex);
if (extMatch) {
extracted.extension = extMatch[1].replace(/[\.,]/g, '').trim().toUpperCase();
extracted.middleName = extracted.middleName.replace(extMatch[0], '').replace(/,$/, '').trim();
}
}
return extracted;
}
function extractDob(text) {
const months = {
jan: '01', january: '01',
feb: '02', february: '02',
mar: '03', march: '03',
apr: '04', april: '04',
may: '05',
jun: '06', june: '06',
jul: '07', july: '07',
aug: '08', august: '08',
sep: '09', september: '09',
oct: '10', october: '10',
nov: '11', november: '11',
dec: '12', december: '12'
};
// Try finding after explicit DOB keyword first
const dobKeywordMatch = text.match(/(?:Date\s*of\s*Birth|Birth\s*Date|Birthdate(?:\s*\([^\)]*\))?|DOB|Birth|Born)[:\s,]*([^\n,]{4,35})/i);
const searchArea = dobKeywordMatch ? dobKeywordMatch[1] : text;
// 1. Check for Month Name Day, Year (e.g., "May 14, 2007" or "14 May 2007")
const monthNameMatch = searchArea.match(/\b([A-Za-z]{3,9})\s+(\d{1,2}),?\s+(\d{4})\b/) ||
searchArea.match(/\b(\d{1,2})\s+([A-Za-z]{3,9}),?\s+(\d{4})\b/);
if (monthNameMatch) {
let mStr = monthNameMatch[1].toLowerCase();
let dStr = monthNameMatch[2];
let yStr = monthNameMatch[3];
if (!months[mStr] && months[dStr.toLowerCase()]) {
mStr = dStr.toLowerCase();
dStr = monthNameMatch[1];
}
if (months[mStr]) {
const m = months[mStr];
const d = dStr.padStart(2, '0');
return `${yStr}-${m}-${d}`;
}
}
// 2. Check for YYYY-MM-DD or YYYY/MM/DD
const isoMatch = searchArea.match(/\b(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})\b/);
if (isoMatch) {
const y = isoMatch[1];
const m = isoMatch[2].padStart(2, '0');
const d = isoMatch[3].padStart(2, '0');
return `${y}-${m}-${d}`;
}
// 3. Check for MM/DD/YYYY, DD/MM/YYYY, MM/DD/YY, or DD/MM/YY (supports 2-digit and 4-digit years)
const slashMatch = searchArea.match(/\b(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{2,4})\b/);
if (slashMatch) {
let p1 = parseInt(slashMatch[1], 10);
let p2 = parseInt(slashMatch[2], 10);
let y = parseInt(slashMatch[3], 10);
if (y < 100) {
y += (y <= 30 ? 2000 : 1900);
}
let m = p1;
let d = p2;
if (p1 > 12 && p2 <= 12) {
// Must be DD/MM/YYYY
d = p1;
m = p2;
}
return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
}
// 4. Check for Excel serial date numbers (e.g., 38445 -> 2005-04-03)
const serialMatch = searchArea.match(/\b(1\d{4}|2\d{4}|3\d{4}|4\d{4}|5\d{4}|6\d{4}|7\d{4})\b/);
if (serialMatch) {
const serial = parseInt(serialMatch[1], 10);
if (serial > 10000 && serial < 80000) {
const date = new Date(Math.round((serial - 25569) * 86400 * 1000));
return date.toISOString().split('T')[0];
}
}
return null;
}
// ==========================================
// EXTRACT TEXT FROM FILE (Excel, PDF, Image)
// ==========================================
exports.extractTextFromFile = async (filePath, originalName = '', mimeType = '') => {
let text = '';
const isExcel = /\.(xlsx|xls|csv)$/i.test(originalName || filePath) ||
(mimeType && (mimeType.includes('spreadsheet') || mimeType.includes('excel') || mimeType.includes('csv')));
const isPdf = (mimeType === 'application/pdf') || /\.(pdf)$/i.test(originalName || filePath);
if (isExcel) {
console.log('[ocrParser] Excel/Spreadsheet file detected. Reading tabs via exceljs...');
const workbook = new ExcelJS.Workbook();
const ext = path.extname(originalName || filePath).toLowerCase();
if (ext === '.csv' || (mimeType && mimeType.includes('csv'))) {
await workbook.csv.readFile(filePath);
} else {
await workbook.xlsx.readFile(filePath);
}
const tabTexts = [];
for (const worksheet of workbook.worksheets) {
const tabName = worksheet.name;
const rowsText = [];
const maxRows = Math.max(worksheet.rowCount || 0, 300);
for (let r = 1; r <= maxRows; r++) {
const row = worksheet.getRow(r);
const rowValues = [];
let lastValStr = '';
for (let c = 1; c <= 150; c++) {
const cell = row.getCell(c);
let val = cell.value;
if (val !== null && val !== undefined) {
if (typeof val === 'object') {
if (val instanceof Date) {
val = val.toISOString().split('T')[0];
} else if (val.result !== undefined && val.result !== null) {
val = val.result;
} else if (val.text !== undefined && val.text !== null) {
val = val.text;
} else if (Array.isArray(val.richText)) {
val = val.richText.map(r => r.text).join('');
} else {
val = '';
}
}
const str = String(val ?? '').trim();
if (str.length > 0 && str !== lastValStr) {
rowValues.push(str);
lastValStr = str;
}
}
}
if (rowValues.length > 0) {
rowsText.push(rowValues.join(' '));
}
}
if (rowsText.length > 0) {
tabTexts.push(`--- TAB: ${tabName} ---\n` + rowsText.join('\n'));
}
}
text = tabTexts.join('\n\n');
return text;
}
let imagePathToScan = filePath;
let tempPngPath = null;
if (isPdf) {
console.log('[ocrParser] PDF detected. Spawning Ghostscript to convert page 1 to PNG...');
const saveDirectory = path.resolve('./uploads/temp_ocr/');
if (!fs.existsSync(saveDirectory)) {
fs.mkdirSync(saveDirectory, { recursive: true });
}
tempPngPath = path.join(saveDirectory, `temp_ocr_${Date.now()}_${Math.floor(Math.random() * 1000)}.png`);
const gsArgs = [
'-dQUIET', '-dPARANOIDSAFER', '-dBATCH', '-dNOPAUSE', '-dNOPROMPT',
'-sDEVICE=png16m',
'-dTextAlphaBits=4', '-dGraphicsAlphaBits=4',
'-r300',
'-dFirstPage=1', '-dLastPage=1',
`-sOutputFile=${tempPngPath}`,
filePath
];
const isWindows = process.platform === 'win32';
try {
if (isWindows) {
try {
await execFileAsync('gswin64c', gsArgs);
} catch (err) {
if (err.code === 'ENOENT') {
try {
await execFileAsync('gs', gsArgs);
} catch (fallbackErr) {
await execFileAsync('gswin32c', gsArgs);
}
} else throw err;
}
} else {
await execFileAsync('gs', gsArgs);
}
imagePathToScan = tempPngPath;
} catch (err) {
console.error('[ocrParser] Ghostscript conversion failed:', err.message);
throw new Error('Failed to convert PDF to image for OCR: ' + err.message);
}
}
try {
console.log('[ocrParser] Running Native Tesseract on image:', imagePathToScan);
const defaultTessData = path.join(__dirname, '..', '..', 'tesseract', 'tessdata');
const tessEnv = {
...process.env,
TESSDATA_PREFIX: process.env.TESSDATA_PREFIX || defaultTessData
};
const result = await execFileAsync('tesseract', [
imagePathToScan,
'stdout',
'-l', 'eng'
], { env: tessEnv, maxBuffer: 1024 * 1024 * 10 });
text = result.stdout || '';
// Auto-rotation fallback: check if initial text parsed key fields
let bestText = text;
let parsedSF9 = exports.parseSF9(bestText);
let parsedSF10 = exports.parseSF10(bestText);
let bestScore = Math.max(evalParseScore(parsedSF9), evalParseScore(parsedSF10));
if (bestScore < 3) {
console.log(`[ocrParser] Initial OCR score (${bestScore}) is low. Trying image rotations (90, 180, 270)...`);
const saveDirectory = path.resolve('./uploads/temp_ocr/');
if (!fs.existsSync(saveDirectory)) {
fs.mkdirSync(saveDirectory, { recursive: true });
}
for (const angle of [90, 180, 270]) {
const rotPath = path.join(saveDirectory, `rot_${angle}_${Date.now()}_${Math.floor(Math.random() * 1000)}.jpg`);
try {
await rotateImage(imagePathToScan, angle, rotPath);
const rotResult = await execFileAsync('tesseract', [
rotPath,
'stdout',
'-l', 'eng'
], { env: tessEnv, maxBuffer: 1024 * 1024 * 10 });
const rotText = rotResult.stdout || '';
const rSF9 = exports.parseSF9(rotText);
const rSF10 = exports.parseSF10(rotText);
const rotScore = Math.max(evalParseScore(rSF9), evalParseScore(rSF10));
if (rotScore > bestScore) {
bestScore = rotScore;
bestText = rotText;
console.log(`[ocrParser] Found better orientation at ${angle} degrees (score ${rotScore}).`);
}
if (bestScore >= 4) break;
} catch (rotErr) {
console.error(`[ocrParser] Rotation ${angle} failed:`, rotErr.message);
} finally {
if (fs.existsSync(rotPath)) {
try { fs.unlinkSync(rotPath); } catch (_) {}
}
}
}
}
text = bestText;
} catch (err) {
console.error('[ocrParser] Tesseract execution failed:', err.message);
throw new Error('Tesseract OCR execution failed: ' + err.message);
} finally {
if (tempPngPath && fs.existsSync(tempPngPath)) {
try { fs.unlinkSync(tempPngPath); } catch (_) {}
}
}
return text;
};
function evalParseScore(parsed) {
if (!parsed) return 0;
let score = 0;
if (parsed.lrn && parsed.lrn.length === 12) score += 3;
if (parsed.lastName && parsed.firstName) score += 3;
if (parsed.gradeLevel) score += 1;
if (parsed.schoolYear) score += 1;
if (parsed.section) score += 1;
if (parsed.scholasticRecords && parsed.scholasticRecords.length > 0) score += 2;
return score;
}
async function rotateImage(srcPath, angle, dstPath) {
if (process.platform === 'win32') {
const s = '$';
const script = 'param([string]' + s + 'src, [int]' + s + 'angle, [string]' + s + 'dst)\n' +
'Add-Type -AssemblyName System.Drawing\n' +
s + 'img = [System.Drawing.Image]::FromFile(' + s + 'src)\n' +
s + 'bmp = New-Object System.Drawing.Bitmap(' + s + 'img)\n' +
s + 'img.Dispose()\n' +
'if (' + s + 'angle -eq 90) { ' + s + 'bmp.RotateFlip([System.Drawing.RotateFlipType]::Rotate90FlipNone) }\n' +
'elseif (' + s + 'angle -eq 180) { ' + s + 'bmp.RotateFlip([System.Drawing.RotateFlipType]::Rotate180FlipNone) }\n' +
'elseif (' + s + 'angle -eq 270) { ' + s + 'bmp.RotateFlip([System.Drawing.RotateFlipType]::Rotate270FlipNone) }\n' +
s + 'bmp.Save(' + s + 'dst, [System.Drawing.Imaging.ImageFormat]::Jpeg)\n' +
s + 'bmp.Dispose()\n';
const dir = path.dirname(dstPath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const tempScript = path.join(dir, `rot_${Date.now()}_${Math.floor(Math.random() * 1000)}.ps1`);
fs.writeFileSync(tempScript, script, 'utf8');
try {
await execFileAsync('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', tempScript, '-src', path.resolve(srcPath), '-angle', String(angle), '-dst', path.resolve(dstPath)]);
} finally {
if (fs.existsSync(tempScript)) {
try { fs.unlinkSync(tempScript); } catch (_) {}
}
}
} else {
try {
await execFileAsync('convert', [srcPath, '-rotate', String(angle), dstPath]);
} catch (_) {}
}
}
@@ -0,0 +1,66 @@
'use strict';
const db = require('../config/db');
const notificationController = require('../controllers/notificationController');
let monitorInterval = null;
/**
* Checks all pending password reset links that have expired,
* marks them as expired, and dispatches in-app + FCM push notification to the initiating admin.
*/
const checkExpiredResetLinks = () => {
try {
const expiredLinks = db.prepare(`
SELECT prl.id, prl.user_id, prl.admin_id, prl.email, prl.expires_at,
u.username, u.first_name, u.last_name
FROM password_reset_links prl
JOIN users u ON prl.user_id = u.id
WHERE prl.status = 'pending'
AND prl.expires_at < (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
AND prl.expired_notified = 0
`).all();
if (expiredLinks && expiredLinks.length > 0) {
const markNotifiedStmt = db.prepare(`
UPDATE password_reset_links
SET status = 'expired', expired_notified = 1
WHERE id = ?
`);
for (const link of expiredLinks) {
markNotifiedStmt.run(link.id);
const fullName = [link.first_name, link.last_name].filter(Boolean).join(' ');
const title = 'Password Reset Link Expired';
const message = `The password reset link for @${link.username}${fullName ? ` (${fullName})` : ''} has expired without being used.`;
// Notify initiating admin or all admins if admin_id is null
notificationController.createNotification(
link.admin_id || null,
title,
message,
'user',
'user',
link.user_id
);
console.log(`[ResetMonitor] Notified admin for expired reset link (User: @${link.username})`);
}
}
} catch (err) {
console.error('[ResetMonitor] Error checking expired reset links:', err.message);
}
};
/**
* Starts the periodic background monitor (runs every 30 seconds).
*/
const startResetLinkMonitor = () => {
if (monitorInterval) clearInterval(monitorInterval);
checkExpiredResetLinks();
monitorInterval = setInterval(checkExpiredResetLinks, 30 * 1000);
console.log('[ResetMonitor] Password reset link expiration monitor started (30s interval)');
};
module.exports = {
checkExpiredResetLinks,
startResetLinkMonitor,
};
File diff suppressed because it is too large. Load diff
+291
View File
@@ -0,0 +1,291 @@
const fs = require('fs');
const path = require('path');
const db = require('../config/db');
const ocrParser = require('./ocrParser');
const logActivity = (userId, action, entityType, entityId, description) => {
try {
db.prepare(`
INSERT INTO activity_log (user_id, action, entity_type, entity_id, description)
VALUES (?, ?, ?, ?, ?)
`).run(userId, action, entityType, entityId, description);
} catch (err) {
console.error('logActivity error:', err.message);
}
};
function maskLrn(lrn) {
if (!lrn) return '';
const str = String(lrn).trim();
if (str.length <= 4) return str;
const bullets = '•'.repeat(str.length - 4);
return `${bullets}${str.slice(-4)}`;
}
function getEnrollmentLogDesc(year, gradeLevel, secName, lrn, studentName = '') {
const gradeStr = String(gradeLevel).toLowerCase().startsWith('grade') ? gradeLevel : `Grade ${gradeLevel}`;
const masked = maskLrn(lrn);
const details = [year, gradeStr, secName].filter(Boolean).join(' - ');
if (details) {
return `Enrolled student ${masked} in ${details}`;
}
return `Enrolled student ${masked}`;
}
const findTeacherUser = (db, adviserName) => {
if (!adviserName || typeof adviserName !== 'string') return null;
const clean = adviserName.trim().toLowerCase();
if (!clean) return null;
const allUsers = db.prepare("SELECT id, first_name, last_name, username FROM users WHERE role IN ('teacher', 'admin')").all();
for (const u of allUsers) {
const full1 = `${(u.first_name || '').trim()} ${(u.last_name || '').trim()}`.toLowerCase().replace(/\s+/g, ' ');
const full2 = `${(u.last_name || '').trim()}, ${(u.first_name || '').trim()}`.toLowerCase().replace(/\s+/g, ' ');
const userClean = (u.username || '').trim().toLowerCase();
if (clean === full1 || clean === full2 || clean === userClean) {
return u;
}
}
return null;
};
// Extract and parse enrollment data from an SF9/SF10 document without writing to the database.
// Records are gated to Grade 7-12.
exports.extractEnrollmentData = async ({ file, documentType = '', manual = false }) => {
// 1. Determine if SF10 or SF9
const docTypeStr = String(documentType || '');
const fileNameStr = String(file?.originalname || file?.name || '');
const isSF10 = /sf10|sf-10|sf 10|permanent record|form 10|school form 10|form 137|form-137|form 137-a|form 137a|student permanent record/i.test(docTypeStr) ||
/sf10|sf-10|sf 10|permanent record|form 10|school form 10|form 137|form-137|form 137-a|form 137a|student permanent record/i.test(fileNameStr);
const isSF9 = /sf9|sf-9|sf 9|report card|form 9|school form 9|form 138|form-138|form 138|student report card|sf1 for jhs\/sf9 for shs|sf1\b|sf-1\b|sf 1\b/i.test(docTypeStr) ||
/sf9|sf-9|sf 9|report card|form 9|school form 9|form 138|form-138|form 138|student report card|sf1 for jhs\/sf9 for shs|sf1\b|sf-1\b|sf 1\b/i.test(fileNameStr);
if (!isSF10 && !isSF9 && !manual) {
return {
ok: false,
skipped: true,
message: 'Auto Enrollment supports only SF9 and SF10 documents.'
};
}
if (!file || !file.path || !fs.existsSync(file.path)) {
return {
ok: false,
message: 'File path not found for OCR scanning.'
};
}
// 2. Extract text from PDF, Image, or Excel
let text = '';
try {
text = await ocrParser.extractTextFromFile(file.path, file.originalname || '', file.mimetype || '');
} catch (err) {
console.error('[sfEnrollmentService] OCR/Excel text extraction error:', err.message);
return {
ok: false,
message: 'Failed to extract text from document: ' + err.message
};
}
if (!text || text.trim().length === 0) {
return {
ok: false,
message: 'No readable text extracted from document.'
};
}
// 3. Parse text using SF10 or SF9 parser
const docLabel = isSF10 ? 'SF10' : (isSF9 ? 'SF9' : 'SF Document');
let extracted = isSF10 ? ocrParser.parseSF10(text) : ocrParser.parseSF9(text);
if (!extracted.schoolYear && !extracted.gradeLevel && !extracted.section && !isSF10) {
// Fallback check: if we tried SF9 and got nothing, try SF10 just in case
extracted = ocrParser.parseSF10(text);
} else if (!extracted.schoolYear && !extracted.gradeLevel && !extracted.section && isSF10) {
extracted = ocrParser.parseSF9(text);
}
const { schoolYear, gradeLevel, section, trackStrand } = extracted;
// 4. Gather all records to enroll (use deduplicated scholasticRecords or fallback to single extracted)
let recordsToEnroll = Array.isArray(extracted.scholasticRecords) && extracted.scholasticRecords.length > 0
? extracted.scholasticRecords
: [{
gradeLevel: extracted.gradeLevel,
section: extracted.section,
schoolYear: extracted.schoolYear,
adviserName: extracted.adviserName || '',
semester: extracted.semester || '',
trackStrand: extracted.trackStrand || null
}];
// Deduplicate: if 2 sem is the same, do not duplicate. Only Grade 7-12 records are accepted.
const uniqueRecords = [];
const seenSemKey = new Set();
for (const rec of recordsToEnroll) {
const gradeNum = parseInt(String(rec.gradeLevel).replace(/\D/g, ''), 10);
if (isNaN(gradeNum) || gradeNum < 7 || gradeNum > 12 || !rec.section) continue;
const syStr = (rec.schoolYear || '').trim();
const secName = (rec.section || '').trim();
const dedupeKey = `${gradeNum}_${syStr}_${secName}`.toLowerCase();
if (!seenSemKey.has(dedupeKey)) {
seenSemKey.add(dedupeKey);
uniqueRecords.push(rec);
}
}
if (uniqueRecords.length === 0) {
return {
ok: false,
message: `Could not extract a Grade 7-12 enrollment from ${docLabel}. Found -> Grade: "${gradeLevel || ''}", Section: "${section || ''}"`
};
}
return { ok: true, isSF10, isSF9, docLabel, extracted, uniqueRecords };
};
exports.autoEnrollFromSF = async ({ studentId, file, documentType = '', userId = null, manual = false }) => {
// 1. If not manual, check toggle setting in system_settings
if (!manual) {
const settingRow = db.prepare("SELECT value FROM system_settings WHERE key = 'auto_update_enrollment_from_sf'").get();
if (settingRow && (settingRow.value === 'false' || settingRow.value === '0')) {
return {
success: false,
skipped: true,
message: 'Auto-update enrollment from SF10/SF9 is disabled in system settings.'
};
}
}
// 2. Extract + parse records (Grade 7-12 only, no writes here)
const data = await exports.extractEnrollmentData({ file, documentType, manual });
if (!data.ok) {
return {
success: false,
skipped: data.skipped || false,
message: data.message
};
}
const { docLabel, extracted, uniqueRecords } = data;
const { schoolYear, gradeLevel, section, trackStrand } = extracted;
let lastAction = '';
let processedRecords = [];
for (const rec of uniqueRecords) {
const gradeNum = parseInt(String(rec.gradeLevel).replace(/\D/g, ''), 10);
const sectionName = rec.section.trim();
let syStr = (rec.schoolYear || '').trim();
// 5a. Resolve Academic Year
let ayRow = null;
if (syStr) {
ayRow = db.prepare('SELECT id, year_range FROM academic_years WHERE year_range = ?').get(syStr);
if (!ayRow && /^\d{4}-\d{4}$/.test(syStr)) {
const res = db.prepare("INSERT INTO academic_years (year_range, status) VALUES (?, 'inactive')").run(syStr);
ayRow = { id: res.lastInsertRowid, year_range: syStr };
}
}
if (!ayRow) {
ayRow = db.prepare("SELECT id, year_range FROM academic_years WHERE status = 'active' LIMIT 1").get();
}
if (!ayRow) continue;
// 6. Resolve or Create Section
let secRow = db.prepare(`
SELECT id FROM sections
WHERE LOWER(name) = LOWER(?) AND grade_level = ? AND (academic_year_id = ? OR academic_year_id IS NULL)
`).get(sectionName, gradeNum, ayRow.id);
if (!secRow) {
const res = db.prepare(`
INSERT INTO sections (name, grade_level, academic_year_id)
VALUES (?, ?, ?)
`).run(sectionName, gradeNum, ayRow.id);
secRow = { id: res.lastInsertRowid };
}
// 6b. Check Teacher Name -> do not fill out if the name of teacher is not on the database users
if (rec.adviserName) {
const teacherUser = findTeacherUser(db, rec.adviserName);
if (teacherUser) {
const existingTeacherSec = db.prepare(`
SELECT id FROM teacher_sections WHERE teacher_id = ? AND section_id = ?
`).get(teacherUser.id, secRow.id);
if (!existingTeacherSec) {
db.prepare(`
INSERT INTO teacher_sections (teacher_id, section_id) VALUES (?, ?)
`).run(teacherUser.id, secRow.id);
}
} else {
console.log(`[sfEnrollmentService] Teacher "${rec.adviserName}" not found in database users. Skipping teacher assignment.`);
}
}
// 7. Check Existing Enrollment
const existingEnrollment = db.prepare(`
SELECT * FROM enrollments
WHERE student_id = ? AND academic_year_id = ? AND grade_level = ?
LIMIT 1
`).get(studentId, ayRow.id, gradeNum);
const cleanTrackStrand = (rec.trackStrand || trackStrand) ? (rec.trackStrand || trackStrand).trim() : null;
const studentRow = db.prepare('SELECT lrn FROM students WHERE id = ?').get(studentId);
const lrn = studentRow?.lrn || studentId;
if (existingEnrollment) {
if (existingEnrollment.section_id !== secRow.id ||
(cleanTrackStrand && existingEnrollment.track_strand !== cleanTrackStrand)) {
db.prepare(`
UPDATE enrollments
SET section_id = ?, track_strand = ?
WHERE id = ?
`).run(secRow.id, cleanTrackStrand || existingEnrollment.track_strand || null, existingEnrollment.id);
lastAction = 'updated';
const studentRow = db.prepare('SELECT first_name, last_name FROM students WHERE id = ?').get(studentId);
const sName = studentRow ? `${studentRow.first_name} ${studentRow.last_name}`.trim() : '';
logActivity(userId, 'UPDATE', 'enrollment', existingEnrollment.id, getEnrollmentLogDesc(ayRow.year_range, gradeNum, sectionName, lrn, sName));
} else {
lastAction = 'already_up_to_date';
}
} else {
const resEnr = db.prepare(`
INSERT INTO enrollments (student_id, academic_year_id, section_id, grade_level, track_strand)
VALUES (?, ?, ?, ?, ?)
`).run(studentId, ayRow.id, secRow.id, gradeNum, cleanTrackStrand);
lastAction = 'added';
const studentRow = db.prepare('SELECT first_name, last_name FROM students WHERE id = ?').get(studentId);
const sName = studentRow ? `${studentRow.first_name} ${studentRow.last_name}`.trim() : '';
logActivity(userId, 'CREATE', 'enrollment', resEnr.lastInsertRowid, getEnrollmentLogDesc(ayRow.year_range, gradeNum, sectionName, lrn, sName));
}
processedRecords.push({
gradeLevel: String(gradeNum),
section: sectionName,
schoolYear: ayRow.year_range,
adviserName: rec.adviserName || '',
semester: rec.semester || '',
action: lastAction
});
}
// 8. Log Activity
const student = db.prepare('SELECT first_name, last_name, lrn FROM students WHERE id = ?').get(studentId);
const studentName = student ? `${student.first_name} ${student.last_name}`.trim() : `Student #${studentId}`;
const latestRec = processedRecords[processedRecords.length - 1] || {};
const logMsg = `Successful OCR scan of ${docLabel} for ${studentName} - Processed ${processedRecords.length} enrollment record(s): latest SY ${latestRec.schoolYear || 'N/A'}, Grade ${latestRec.gradeLevel || 'N/A'}, Section ${latestRec.section || 'N/A'}`;
logActivity(userId || null, 'CREATE', 'enrollment', studentId, logMsg);
return {
success: true,
action: lastAction || 'processed',
schoolYear: latestRec.schoolYear || schoolYear,
gradeLevel: latestRec.gradeLevel || gradeLevel,
section: latestRec.section || section,
trackStrand: trackStrand || null,
message: logMsg,
allRecords: processedRecords
};
};
+96
View File
@@ -0,0 +1,96 @@
const { Server } = require('socket.io');
const jwt = require('jsonwebtoken');
let io = null;
/**
* Initializes Socket.IO on the given HTTP server.
* Supports both WebSocket and polling transports with open CORS for LAN and Cloudflare tunnel clients.
*/
function initSocket(httpServer) {
io = new Server(httpServer, {
cors: {
origin: '*',
methods: ['GET', 'POST'],
credentials: true,
},
transports: ['websocket', 'polling'],
allowEIO3: true,
});
// Optional auth middleware: verifies JWT if supplied, but does not block connection
io.use((socket, next) => {
const token =
socket.handshake.auth?.token ||
socket.handshake.headers?.authorization?.replace(/^Bearer\s+/i, '');
if (token) {
try {
const secret = process.env.JWT_SECRET || 'tis-rms-jwt-secret-key-development';
const decoded = jwt.verify(token, secret);
socket.user = decoded;
} catch (err) {
console.warn(`[Socket.IO] Token verification notice for socket ${socket.id}: ${err.message}`);
}
}
next();
});
io.on('connection', (socket) => {
const userDesc = socket.user ? `@${socket.user.username} (${socket.user.role})` : 'client';
console.log(`[Socket.IO] Client connected: ${socket.id} [${userDesc}]`);
socket.on('disconnect', (reason) => {
console.log(`[Socket.IO] Client disconnected: ${socket.id} [${reason}]`);
});
});
console.log('[Socket.IO] Real-time WebSocket server initialized successfully.');
return io;
}
function getIO() {
return io;
}
function broadcastEvent(eventName, payload) {
if (!io) return;
try {
io.emit(eventName, payload);
} catch (err) {
console.error(`[Socket.IO] Failed to emit "${eventName}":`, err.message);
}
}
// Real-Time Student Event Emitters
function emitStudentAdded(data) {
broadcastEvent('student_added', data);
}
function emitStudentUpdated(data) {
broadcastEvent('student_updated', data);
}
function emitStudentDeleted(data) {
broadcastEvent('student_deleted', data);
}
// Real-Time Notification & Document Emitters
function emitNotificationCreated(data) {
broadcastEvent('notification_created', data);
}
function emitDocumentChanged(data) {
broadcastEvent('document_changed', data);
}
module.exports = {
initSocket,
getIO,
broadcastEvent,
emitStudentAdded,
emitStudentUpdated,
emitStudentDeleted,
emitNotificationCreated,
emitDocumentChanged,
};
@@ -0,0 +1,246 @@
const db = require('../config/db');
const { sendTeacherAttentionReminder } = require('./emailService');
const logActivity = (userId, action, entityType, entityId, description) => {
try {
let uid = userId;
const userCheck = uid ? db.prepare("SELECT id FROM users WHERE id = ?").get(uid) : null;
if (!userCheck) {
const anyUser = db.prepare("SELECT id FROM users WHERE role = 'admin' LIMIT 1").get();
uid = anyUser ? anyUser.id : null;
}
db.prepare(`
INSERT INTO activity_log (user_id, action, entity_type, entity_id, description)
VALUES (?, ?, ?, ?, ?)
`).run(uid, action, entityType, entityId, description);
} catch (err) {
console.error('[teacherReminderService] logActivity error:', err.message);
}
};
/**
* Checks document requirements with due dates and sends automated reminder emails
* to advisory teachers whose enrolled students are missing those requirements.
*/
const checkAndRunTeacherDueDateReminders = async (forced = false) => {
try {
const enabledRow = db.prepare("SELECT value FROM system_settings WHERE key = 'remind_teachers_due_date_enabled'").get();
const isEnabled = enabledRow?.value === 'true';
if (!isEnabled && !forced) {
return { executed: false, reason: 'Teacher due date reminders are disabled in system settings.' };
}
const daysRow = db.prepare("SELECT value FROM system_settings WHERE key = 'remind_teachers_due_date_days'").get();
const reminderDays = parseInt(daysRow?.value || '3', 10) || 3;
// Today's date string YYYY-MM-DD
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const todayStr = `${year}-${month}-${day}`;
// Check if already executed today
if (!forced) {
const lastRunRow = db.prepare("SELECT value FROM system_settings WHERE key = 'last_teacher_reminder_date'").get();
if (lastRunRow?.value === todayStr) {
return { executed: false, reason: 'Teacher due date reminders already ran today.' };
}
}
// Get enabled document requirements that have a due date set
const requirements = db.prepare(`
SELECT id, name, category, due_date, is_mandatory
FROM document_requirements
WHERE is_enabled = 1 AND due_date IS NOT NULL AND TRIM(due_date) != ''
`).all();
if (requirements.length === 0) {
if (!forced) {
db.prepare(`
INSERT INTO system_settings (key, value, updated_at)
VALUES ('last_teacher_reminder_date', ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP
`).run(todayStr);
}
return { executed: false, reason: 'No document requirements with due dates found.' };
}
// Filter requirements that are due within reminderDays (0 <= diffDays <= reminderDays)
const todayDateOnly = new Date(`${todayStr}T00:00:00`);
const dueSoonRequirements = requirements.filter(req => {
const reqDateStr = req.due_date.split('T')[0].split(' ')[0];
const reqDate = new Date(`${reqDateStr}T00:00:00`);
const diffTime = reqDate.getTime() - todayDateOnly.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays >= 0 && diffDays <= reminderDays;
});
if (dueSoonRequirements.length === 0) {
if (!forced) {
db.prepare(`
INSERT INTO system_settings (key, value, updated_at)
VALUES ('last_teacher_reminder_date', ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP
`).run(todayStr);
}
return { executed: false, reason: `No document requirements due within the next ${reminderDays} day(s).` };
}
// Get active teachers with emails
const teachers = db.prepare(`
SELECT id, username, first_name, middle_name, last_name, email
FROM users
WHERE role = 'teacher' AND is_active = 1 AND email IS NOT NULL AND TRIM(email) != ''
`).all();
if (teachers.length === 0) {
return { executed: false, reason: 'No active teachers with valid emails found.' };
}
let sentCount = 0;
let skippedCount = 0;
for (const teacher of teachers) {
const teacherName = `${teacher.first_name} ${teacher.last_name}`.trim();
// Get sections assigned to this teacher
const sections = db.prepare(`
SELECT s.id, s.name, s.grade_level
FROM teacher_sections ts
JOIN sections s ON ts.section_id = s.id
WHERE ts.teacher_id = ?
ORDER BY s.grade_level ASC, s.name ASC
`).all(teacher.id);
if (sections.length === 0) {
skippedCount++;
continue;
}
const sectionsWithAttentionStudents = [];
for (const section of sections) {
// Find enrolled students in this section
const students = db.prepare(`
SELECT s.id, s.lrn, s.first_name, s.middle_name, s.last_name, s.extension
FROM enrollments e
JOIN students s ON e.student_id = s.id
WHERE e.section_id = ? AND s.status = 'Enrolled'
ORDER BY s.last_name ASC, s.first_name ASC
`).all(section.id);
const sectionAttentionStudents = [];
for (const student of students) {
const studentFullName = [student.last_name, student.first_name, student.middle_name, student.extension]
.filter(Boolean)
.join(' ');
// Check missing requirements among the due soon requirements
const studentLevelCategory = section.grade_level <= 10 ? 'JHS' : 'SHS';
const matchingReqs = dueSoonRequirements.filter(r => r.category === studentLevelCategory);
if (matchingReqs.length === 0) continue;
const missingForStudent = [];
for (const req of matchingReqs) {
const hasCompleted = db.prepare(`
SELECT id FROM documents
WHERE student_id = ? AND requirement_id = ? AND status = 'Completed' AND deleted_at IS NULL
LIMIT 1
`).get(student.id, req.id);
if (!hasCompleted) {
const reqDateStr = req.due_date.split('T')[0].split(' ')[0];
missingForStudent.push(`${req.name} (Due: ${reqDateStr})`);
}
}
if (missingForStudent.length > 0) {
sectionAttentionStudents.push({
id: student.id,
lrn: student.lrn,
name: studentFullName,
missingDocs: missingForStudent
});
}
}
if (sectionAttentionStudents.length > 0) {
sectionsWithAttentionStudents.push({
sectionId: section.id,
sectionName: section.name,
gradeLevel: section.grade_level,
students: sectionAttentionStudents
});
}
}
if (sectionsWithAttentionStudents.length > 0) {
try {
// Validate teacher email before sending reminder (MEV / QEV)
let validEmail = true;
try {
const { validateEmailMEV } = require('../controllers/authController');
const mevResult = await validateEmailMEV(teacher.email);
if (!mevResult.valid) {
validEmail = false;
console.warn(`[TeacherReminderService] Skipping ${teacher.email}: ${mevResult.reason}`);
}
} catch (_) {}
if (!validEmail) {
skippedCount++;
continue;
}
await sendTeacherAttentionReminder({
to: teacher.email,
teacherName,
sectionsWithStudents: sectionsWithAttentionStudents
});
sentCount++;
} catch (err) {
console.error(`[TeacherReminderService] Failed to send reminder email to ${teacher.email}:`, err.message);
}
} else {
skippedCount++;
}
}
// Mark today as executed
db.prepare(`
INSERT INTO system_settings (key, value, updated_at)
VALUES ('last_teacher_reminder_date', ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP
`).run(todayStr);
if (sentCount > 0) {
logActivity(
null,
'NOTIFY',
'system_settings',
null,
`Automated due date reminder emails sent to ${sentCount} teacher(s)`
);
console.log(`[TeacherReminderService] Successfully sent due date reminder emails to ${sentCount} teacher(s).`);
}
return {
executed: true,
sentCount,
skippedCount,
dueRequirementsCount: dueSoonRequirements.length
};
} catch (error) {
console.error('[TeacherReminderService] checkAndRunTeacherDueDateReminders error:', error);
return { executed: false, error: error.message };
}
};
module.exports = {
checkAndRunTeacherDueDateReminders
};
+627
View File
@@ -0,0 +1,627 @@
'use strict';
const { spawn, spawnSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const dns = require('dns');
const http = require('http');
const https = require('https');
// ─── Constants ────────────────────────────────────────────────────────────────
const INTERNET_TIMEOUT_MS = 5_000;
const INTERNET_CHECK_INTERVAL_MS = 15_000;
const ORIGIN_CHECK_INTERVAL_MS = 20_000;
const ORIGIN_HOST = '127.0.0.1';
const ORIGIN_PORT = parseInt(process.env.PORT || '18484', 10);
const TUNNEL_CONNECT_TIMEOUT_MS = 90_000; // Warn if no connection registered after this
// ─── Service State ────────────────────────────────────────────────────────────
let tunnelProcess = null;
let isStarting = false;
/**
* Separate health dimensions — never collapse these into one flag.
*
* tunnelState: overall reported status (used by getTunnelStatus/API)
* 'stopped' | 'starting' | 'connected' | 'degraded' | 'error' | 'no_internet' | 'disabled'
*
* cloudflaredRunning: true iff the child process is alive right now.
* tunnelConnected: true iff cloudflared has logged an actual connection registration.
* originHealthy: true iff http://127.0.0.1:<port>/ returned HTTP 200.
* publicEndpointStatus: last HTTP status from the public Cloudflare URL (diagnostic only).
*/
let tunnelState = 'stopped';
let cloudflaredRunning = false;
let tunnelConnected = false;
let originHealthy = false;
let publicEndpointStatus = null;
let detectedTunnelHostname = null;
let lastInternetCheck = false;
let lastLogMessage = '';
let internetTimer = null;
let originHealthTimer = null;
let connectTimeoutTimer = null;
let signalHandlersRegistered = false;
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Extracts a hostname from a URL string if valid.
* @param {string} urlStr
* @returns {string|null}
*/
function extractHostname(urlStr) {
if (!urlStr || typeof urlStr !== 'string') return null;
try {
const parsed = new URL(urlStr.startsWith('http') ? urlStr : `https://${urlStr}`);
return parsed.hostname || null;
} catch (_) {
return null;
}
}
/**
* Returns the effective tunnel hostname (detected from logs, or configured via env).
* @returns {string|null}
*/
function getEffectiveHostname() {
if (detectedTunnelHostname) return detectedTunnelHostname;
if (process.env.CLOUDFLARE_TUNNEL_HOSTNAME) {
return process.env.CLOUDFLARE_TUNNEL_HOSTNAME.trim();
}
const envPublicUrl = process.env.PUBLIC_URL || process.env.APP_URL || process.env.CLOUDFLARE_TUNNEL_PUBLIC_URL;
if (envPublicUrl) {
return extractHostname(envPublicUrl);
}
return null;
}
/**
* Finds the cloudflared executable across common workspace and system locations.
* Respects CLOUDFLARED_PATH env var for custom installations.
* @returns {string|null}
*/
function getCloudflaredPath() {
const isWindows = process.platform === 'win32';
const binaryName = isWindows ? 'cloudflared.exe' : 'cloudflared';
const setExecutablePermission = (filePath) => {
if (!isWindows) {
try { fs.chmodSync(filePath, 0o755); } catch (_) {}
}
};
if (process.env.CLOUDFLARED_PATH && fs.existsSync(process.env.CLOUDFLARED_PATH)) {
setExecutablePermission(process.env.CLOUDFLARED_PATH);
return process.env.CLOUDFLARED_PATH;
}
const candidatePaths = [
path.join(__dirname, '..', '..', 'bin', binaryName),
path.join(__dirname, '..', 'bin', binaryName),
path.join(process.cwd(), 'bin', binaryName),
path.join(process.cwd(), '..', 'bin', binaryName),
path.join(__dirname, '..', '..', '..', 'bin', binaryName),
];
for (const cand of candidatePaths) {
if (fs.existsSync(cand)) {
setExecutablePermission(cand);
return path.resolve(cand);
}
}
return null;
}
// ─── Internet Check ───────────────────────────────────────────────────────────
/**
* Checks whether an internet connection is available via DNS + HTTPS probe.
* @param {number} timeoutMs
* @returns {Promise<boolean>}
*/
function checkInternet(timeoutMs = INTERNET_TIMEOUT_MS) {
return new Promise((resolve) => {
let isDone = false;
let activeHttpsReq = null;
const finish = (result) => {
if (!isDone) {
isDone = true;
clearTimeout(timeoutId);
if (activeHttpsReq && !activeHttpsReq.destroyed) {
try { activeHttpsReq.destroy(); } catch (_) {}
}
resolve(result);
}
};
const timeoutId = setTimeout(() => finish(false), timeoutMs);
// Parallel DNS hostname checks
const domains = ['cloudflare.com', 'one.one.one.one', 'google.com', 'dns.google'];
domains.forEach((domain) => {
dns.lookup(domain, (err) => {
if (!err) finish(true);
});
});
// Fast HTTPS fallback
try {
activeHttpsReq = https.get('https://1.1.1.1', { timeout: 3000 }, (res) => {
res.destroy();
finish(true);
});
activeHttpsReq.on('error', () => {});
activeHttpsReq.on('timeout', () => {
try { activeHttpsReq.destroy(); } catch (_) {}
});
} catch (_) {}
});
}
// ─── Origin Health Check ──────────────────────────────────────────────────────
/**
* Checks whether the local Node.js origin is reachable on 127.0.0.1.
* Uses IPv4 loopback explicitly to avoid Windows localhost/IPv6 ambiguity.
* This is NOT the same as the public Cloudflare endpoint check.
*
* @returns {Promise<boolean>}
*/
function checkOriginHealth() {
return new Promise((resolve) => {
let done = false;
const finish = (ok) => {
if (!done) {
done = true;
resolve(ok);
}
};
const req = http.request(
{
hostname: ORIGIN_HOST, // always 127.0.0.1, never 'localhost'
port: ORIGIN_PORT,
path: '/',
method: 'GET',
timeout: 3000,
family: 4, // force IPv4
},
(res) => {
res.resume(); // drain body
finish(res.statusCode >= 200 && res.statusCode < 500);
}
);
req.on('timeout', () => {
req.destroy();
finish(false);
});
req.on('error', () => finish(false));
req.end();
});
}
/**
* Runs a periodic origin health check and updates state/logs accordingly.
*/
async function runOriginHealthCheck() {
const wasHealthy = originHealthy;
originHealthy = await checkOriginHealth();
if (originHealthy && !wasHealthy) {
console.log(`[Tunnel] Origin healthy — http://${ORIGIN_HOST}:${ORIGIN_PORT}/ is reachable.`);
} else if (!originHealthy && wasHealthy) {
console.warn(`[Tunnel] Origin unreachable — http://${ORIGIN_HOST}:${ORIGIN_PORT}/ is NOT responding.`);
console.warn('[Tunnel] Diagnostic: if cloudflared is returning 502, this is likely the root cause.');
}
}
// ─── Cloudflared Output Parser ────────────────────────────────────────────────
/**
* Parses cloudflared log lines to update connection status and extract hostnames.
* The connection state is determined by ACTUAL cloudflared output, not by inference.
*
* @param {string} rawChunk
*/
function handleProcessOutput(rawChunk) {
const lines = rawChunk.toString().split(/\r?\n/);
for (const line of lines) {
const text = line.trim();
if (!text) continue;
lastLogMessage = text;
// 1. Hostname detection from JSON logs or plaintext output
const jsonHostMatch = text.match(/"hostname":\s*"([^"]+)"/);
if (jsonHostMatch?.[1]) {
detectedTunnelHostname = jsonHostMatch[1];
} else {
const urlMatch = text.match(/https:\/\/([a-zA-Z0-9-]+\.(?:trycloudflare\.com|cfargotunnel\.com))/);
if (urlMatch?.[1]) {
detectedTunnelHostname = urlMatch[1];
}
}
// 2. Actual connection registration detection.
// These are cloudflared's real "we have an established tunnel" log lines.
// We do NOT infer connection from process uptime.
const isRegisteredConnection =
text.includes('Registered tunnel connection') ||
(text.includes('Connection') && text.includes('registered')) ||
text.includes('Registered new connection');
// These indicate cloudflared is trying to connect but hasn't confirmed yet
const isConnectingIndicator =
text.includes('Tunnel connection curve preferences') ||
text.includes('protocol=quic') ||
text.includes('protocol=http2') ||
text.includes('Connecting to Cloudflare') ||
text.includes('Trying to connect');
if (isRegisteredConnection) {
if (!tunnelConnected) {
tunnelConnected = true;
tunnelState = 'connected';
// Cancel the connection timeout warning timer since we connected
if (connectTimeoutTimer) {
clearTimeout(connectTimeoutTimer);
connectTimeoutTimer = null;
}
console.log('[Tunnel] Tunnel connection registered — cloudflared is connected to Cloudflare edge.');
}
} else if (isConnectingIndicator && tunnelState === 'starting') {
console.log('[Tunnel] Tunnel connection in progress (cloudflared is negotiating)...');
}
// 3. Disconnection / reconnection events
if (text.includes('Unregistered tunnel connection') ||
text.includes('Lost connection to the Cloudflare edge') ||
text.includes('Retrying connection')) {
if (tunnelConnected) {
tunnelConnected = false;
if (tunnelState === 'connected') tunnelState = 'starting';
console.warn('[Tunnel] Tunnel connection lost — cloudflared is reconnecting...');
}
}
// 4. Auth / fatal error detection
if (text.includes('Invalid token') || text.includes('failed to authenticate') ||
text.includes('Authentication error') || text.includes('Unauthorized')) {
console.error('[Tunnel] Cloudflare authentication failed. Verify CLOUDFLARE_TUNNEL_TOKEN in .env.');
tunnelState = 'error';
}
// 5. Log 502-related messages from cloudflared itself as diagnostic info
// but DO NOT trigger a restart from these
if (text.includes('502') || text.includes('Bad Gateway') ||
text.includes('unable to reach the origin') || text.includes('error="dial tcp')) {
console.warn('[Tunnel] cloudflared reported an origin-reach error (possible 502 source).');
console.warn(`[Tunnel] cloudflared message: ${text.substring(0, 200)}`);
console.warn(`[Tunnel] Origin health: ${originHealthy ? 'healthy' : 'UNREACHABLE'}`);
console.warn(`[Tunnel] cloudflared process: ${cloudflaredRunning ? 'running' : 'not running'}`);
}
}
}
// ─── Tunnel Lifecycle ─────────────────────────────────────────────────────────
/**
* Launches cloudflared using only the configured tunnel token.
* Guards against duplicate processes — will not spawn if one is already running.
*/
async function startTunnel() {
// Strict duplicate guard: never start a second cloudflared for the same tunnel
if (tunnelProcess !== null || isStarting) {
console.log('[Tunnel] Skipping start — cloudflared is already running or starting.');
return;
}
isStarting = true;
const binPath = getCloudflaredPath();
if (!binPath) {
tunnelState = 'error';
lastLogMessage = 'cloudflared executable not found in bin/ or PATH';
console.warn(`[Tunnel] ${lastLogMessage}`);
isStarting = false;
return;
}
const rawToken = process.env.CLOUDFLARE_TUNNEL_TOKEN || '';
const token = rawToken.replace(/[\r\n\s'"=]/g, '').trim();
if (!token) {
tunnelState = 'error';
lastLogMessage = 'CLOUDFLARE_TUNNEL_TOKEN is required but not set. Add it to backend/.env.';
console.error(`[Tunnel] ${lastLogMessage}`);
isStarting = false;
return;
}
// Log a masked token reference (first 6 chars only) so the log proves a token was found
// without exposing the full value.
const maskedToken = token.substring(0, 6) + '...';
console.log(`[Tunnel] Starting cloudflared... (token prefix: ${maskedToken})`);
console.log(`[Tunnel] Binary: ${binPath}`);
tunnelState = 'starting';
tunnelConnected = false;
const args = ['tunnel', '--no-autoupdate', 'run', '--token', token];
try {
const proc = spawn(binPath, args, {
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, NO_AUTOUPDATE: 'true' },
// detached: false (default) — child dies with parent on Windows
});
tunnelProcess = proc;
cloudflaredRunning = true;
isStarting = false;
console.log(`[Tunnel] cloudflared process started (PID: ${proc.pid})`);
proc.stdout.on('data', handleProcessOutput);
proc.stderr.on('data', handleProcessOutput);
proc.on('error', (err) => {
console.error('[Tunnel] cloudflared process error:', err.message);
tunnelState = 'error';
lastLogMessage = err.message;
if (tunnelProcess === proc) {
tunnelProcess = null;
cloudflaredRunning = false;
tunnelConnected = false;
}
isStarting = false;
clearConnectTimeoutTimer();
});
proc.on('exit', (code, signal) => {
const reason = signal ? `signal=${signal}` : `code=${code}`;
console.log(`[Tunnel] cloudflared process exited (${reason})`);
console.log('[Tunnel] Restarting cloudflared because the process exited (next internet check cycle).');
if (tunnelProcess === proc) {
tunnelProcess = null;
cloudflaredRunning = false;
tunnelConnected = false;
}
isStarting = false;
if (tunnelState !== 'disabled') {
tunnelState = 'stopped';
}
clearConnectTimeoutTimer();
});
// ── Connection timeout warning ──────────────────────────────────────
// If cloudflared has been running for TUNNEL_CONNECT_TIMEOUT_MS without
// registering a tunnel connection, report 'degraded'. We do NOT kill or
// restart the process — cloudflared may still be retrying legitimately.
clearConnectTimeoutTimer();
connectTimeoutTimer = setTimeout(() => {
connectTimeoutTimer = null;
if (tunnelProcess === proc && !tunnelConnected) {
tunnelState = 'degraded';
console.warn(`[Tunnel] No tunnel connection registered after ${TUNNEL_CONNECT_TIMEOUT_MS / 1000}s.`);
console.warn('[Tunnel] Tunnel state: degraded. cloudflared is still running — it may still connect.');
console.warn(`[Tunnel] Diagnostics:`);
console.warn(`[Tunnel] Origin health: ${originHealthy ? 'healthy' : 'UNREACHABLE'}`);
console.warn(`[Tunnel] cloudflared PID: ${proc.pid}`);
console.warn(`[Tunnel] Tunnel connected: false`);
console.warn('[Tunnel] Check that CLOUDFLARE_TUNNEL_TOKEN is valid and not revoked.');
}
}, TUNNEL_CONNECT_TIMEOUT_MS);
} catch (err) {
console.error('[Tunnel] Failed to spawn cloudflared:', err.message);
tunnelState = 'error';
lastLogMessage = err.message;
tunnelProcess = null;
cloudflaredRunning = false;
isStarting = false;
clearConnectTimeoutTimer();
}
}
function clearConnectTimeoutTimer() {
if (connectTimeoutTimer) {
clearTimeout(connectTimeoutTimer);
connectTimeoutTimer = null;
}
}
/**
* Stops the cloudflared process cleanly.
* On Windows, uses taskkill /T /F to also terminate any child processes.
*/
function stopTunnel() {
clearConnectTimeoutTimer();
if (!tunnelProcess) {
cloudflaredRunning = false;
tunnelConnected = false;
return;
}
const proc = tunnelProcess;
tunnelProcess = null;
cloudflaredRunning = false;
tunnelConnected = false;
tunnelState = 'stopped';
try {
console.log(`[Tunnel] Stopping cloudflared process (PID: ${proc.pid})...`);
if (process.platform === 'win32') {
spawnSync('taskkill', ['/pid', String(proc.pid), '/T', '/F'], {
windowsHide: true,
stdio: 'ignore'
});
} else {
proc.kill('SIGTERM');
}
} catch (err) {
console.warn('[Tunnel] Error while stopping cloudflared process:', err.message);
}
}
// ─── Auto-Start / Internet Watch ─────────────────────────────────────────────
/**
* Checks internet and auto-starts the tunnel if needed.
* This is called on startup and periodically.
*
* Key invariant: restart cloudflared ONLY when the process is not running.
* A public HTTP 502 alone is never a reason to call this.
*/
async function checkAndAutoStart() {
const isEnabled = process.env.CLOUDFLARE_TUNNEL_ENABLED !== 'false';
if (!isEnabled) {
if (tunnelProcess) stopTunnel();
tunnelState = 'disabled';
return;
}
// If tunnel is already running or launching, do nothing.
// A 502 from the public endpoint is NOT a trigger to restart.
if (tunnelProcess !== null || isStarting) return;
const hasInternet = await checkInternet();
lastInternetCheck = hasInternet;
if (hasInternet) {
console.log('[Tunnel] Internet available — starting Cloudflare Tunnel...');
await startTunnel();
} else {
if (tunnelState !== 'no_internet') {
console.log('[Tunnel] No internet connection — waiting for internet to start tunnel...');
tunnelState = 'no_internet';
}
}
}
// ─── Public API ───────────────────────────────────────────────────────────────
/**
* Initializes the tunnel service on server startup.
* - Immediately attempts to start if internet is available.
* - Polls every INTERNET_CHECK_INTERVAL_MS to auto-recover if internet was down.
* - Polls every ORIGIN_CHECK_INTERVAL_MS to check origin health (diagnostic).
* - Registers process-exit signal handlers for clean shutdown.
*/
function startAutoTunnel() {
const isEnabled = process.env.CLOUDFLARE_TUNNEL_ENABLED !== 'false';
if (!isEnabled) {
console.log('[Tunnel] Cloudflare Tunnel is disabled (CLOUDFLARE_TUNNEL_ENABLED=false)');
tunnelState = 'disabled';
return;
}
console.log('[Tunnel] Initializing Cloudflare Tunnel service...');
// Immediate attempt
checkAndAutoStart();
// Run an initial origin check shortly after startup
setTimeout(runOriginHealthCheck, 3_000);
// Periodic internet/restart check (only restarts if process is dead)
if (internetTimer) clearInterval(internetTimer);
internetTimer = setInterval(checkAndAutoStart, INTERNET_CHECK_INTERVAL_MS);
// Periodic origin health check (diagnostic, never triggers restart)
if (originHealthTimer) clearInterval(originHealthTimer);
originHealthTimer = setInterval(runOriginHealthCheck, ORIGIN_CHECK_INTERVAL_MS);
if (!signalHandlersRegistered) {
signalHandlersRegistered = true;
const cleanExit = () => {
if (internetTimer) { clearInterval(internetTimer); internetTimer = null; }
if (originHealthTimer) { clearInterval(originHealthTimer); originHealthTimer = null; }
stopTunnel();
};
process.on('exit', cleanExit);
process.on('SIGINT', () => { cleanExit(); process.exit(0); });
process.on('SIGTERM', () => { cleanExit(); process.exit(0); });
process.on('SIGUSR2', () => { cleanExit(); process.kill(process.pid, 'SIGUSR2'); });
process.on('beforeExit', cleanExit);
}
}
/**
* Returns current tunnel status and diagnostics.
* All four health dimensions are included for observability.
*
* NOTE: publicEndpointStatus is not polled automatically to avoid hammering the
* Cloudflare edge. It is updated only if an external caller checks the public URL.
*
* @returns {object}
*/
function getTunnelStatus() {
const binPath = getCloudflaredPath();
const hostname = getEffectiveHostname();
const tokenConfigured = !!(process.env.CLOUDFLARE_TUNNEL_TOKEN || '').trim();
return {
// Configuration
enabled: process.env.CLOUDFLARE_TUNNEL_ENABLED !== 'false',
hasExecutable: !!binPath,
executablePath: binPath,
hasToken: tokenConfigured,
// Connectivity
hasInternet: lastInternetCheck,
// Health dimensions (separate, not collapsed)
cloudflaredRunning: cloudflaredRunning,
tunnelConnected: tunnelConnected,
originHealthy: originHealthy,
publicEndpointStatus: publicEndpointStatus,
// Process
pid: tunnelProcess ? tunnelProcess.pid : null,
// Overall state
status: tunnelState,
// Hostname / URL
hostname: hostname,
publicUrl: hostname ? `https://${hostname}` : null,
// Last log line (for diagnostics)
lastLog: lastLogMessage,
};
}
/**
* Returns detected public tunnel URL only when the tunnel is genuinely connected.
* Returns null if cloudflared has not registered a connection.
*
* @returns {string|null}
*/
function getDetectedTunnelUrl() {
if (tunnelConnected && tunnelState === 'connected') {
const hostname = getEffectiveHostname();
if (hostname) return `https://${hostname}`;
}
return null;
}
module.exports = {
startAutoTunnel,
startTunnel,
stopTunnel,
getTunnelStatus,
getDetectedTunnelUrl,
checkInternet,
checkOriginHealth,
getCloudflaredPath,
};
+6
View File
@@ -0,0 +1,6 @@
@echo off
echo Starting TIS RMS Server...
echo Make sure you have Node.js installed on your system.
cd /d "%~dp0"
node index.js
pause
+74
View File
@@ -0,0 +1,74 @@
@echo off
setlocal enabledelayedexpansion
echo =========================================================
echo TIS RMS Cloudflare Tunnel — EMERGENCY FALLBACK
echo =========================================================
echo.
echo *** WARNING ***
echo This script is an EMERGENCY / MANUAL FALLBACK ONLY.
echo Cloudflare Tunnel is managed automatically by the
echo Node.js backend (tunnelService.js).
echo.
echo DO NOT run this script while the TIS RMS backend service
echo or "node index.js" is running — doing so will create two
echo cloudflared processes for the same tunnel, which can
echo cause unpredictable behaviour.
echo.
echo Only run this script if:
echo - The backend is fully stopped, AND
echo - You need to manually verify the tunnel works, OR
echo - You are debugging cloudflared in isolation.
echo.
echo The token must be set in backend\.env as:
echo CLOUDFLARE_TUNNEL_TOKEN=your_token_here
echo.
echo The token must NOT be hard-coded in this file.
echo =========================================================
echo.
set BIN_DIR=%~dp0bin
set CLOUDFLARED_EXE=%BIN_DIR%\cloudflared.exe
set ENV_FILE=%~dp0.env
:: 1. Download cloudflared if missing
if not exist "%CLOUDFLARED_EXE%" (
if not exist "%BIN_DIR%" mkdir "%BIN_DIR%"
echo [INFO] Downloading cloudflared executable...
curl.exe -L -o "%CLOUDFLARED_EXE%" "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe"
)
if not exist "%CLOUDFLARED_EXE%" (
echo [ERROR] cloudflared executable not found at "%CLOUDFLARED_EXE%".
pause
exit /b 1
)
:: 2. Load token from .env — NEVER hard-code the token in this file
if not defined CLOUDFLARE_TUNNEL_TOKEN (
if exist "%ENV_FILE%" (
for /f "usebackq tokens=1,* delims==" %%A in ("%ENV_FILE%") do (
if /i "%%A"=="CLOUDFLARE_TUNNEL_TOKEN" set CLOUDFLARE_TUNNEL_TOKEN=%%B
)
)
)
:: Clean token (strip quotes and whitespace)
if defined CLOUDFLARE_TUNNEL_TOKEN (
set CLOUDFLARE_TUNNEL_TOKEN=%CLOUDFLARE_TUNNEL_TOKEN:"=%
set CLOUDFLARE_TUNNEL_TOKEN=%CLOUDFLARE_TUNNEL_TOKEN:'=%
)
:: 3. Run tunnel using token from .env (never from this file)
if defined CLOUDFLARE_TUNNEL_TOKEN (
echo [INFO] Connecting via token loaded from .env...
echo [INFO] (Token is not displayed for security)
echo.
"%CLOUDFLARED_EXE%" tunnel --no-autoupdate run --token !CLOUDFLARE_TUNNEL_TOKEN!
) else (
echo [ERROR] CLOUDFLARE_TUNNEL_TOKEN was not found in environment or .env file.
echo Please set CLOUDFLARE_TUNNEL_TOKEN in backend\.env to connect the tunnel.
echo Never put the real token directly in this .bat file.
)
pause
+28
View File
@@ -0,0 +1,28 @@
@echo off
:: ============================================================
:: TIS RMS - NSSM Service Uninstaller
:: Run as Administrator
:: ============================================================
NET SESSION >nul 2>&1
IF %ERRORLEVEL% NEQ 0 (
echo [ERROR] Run as Administrator.
pause
exit /b 1
)
set BACKEND_DIR=F:\SumbrerongBato\tis_rms_server\backend
set NSSM=%BACKEND_DIR%\bin\nssm.exe
set SERVICE_NAME=TIS.RMS.ServerService
echo.
echo Stopping and removing "%SERVICE_NAME%"...
"%NSSM%" stop "%SERVICE_NAME%" >nul 2>&1
timeout /t 3 /nobreak >nul
"%NSSM%" remove "%SERVICE_NAME%" confirm
echo.
echo [OK] Service removed. Node.js will no longer start on boot.
echo You can re-install anytime by running install_service.bat
echo.
pause
Loaded 100 of 390 files, more files were not shown because too many files have changed in this diff. Show more