Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5402f34b5 | ||
|
|
d126ecaef1 | ||
|
|
a8473c23b7 | ||
|
|
0058decaef | ||
|
|
3e71448add | ||
|
|
d4d80bf9e6 | ||
|
|
35f56f925a | ||
|
|
e6e8188312 | ||
|
|
3a3e0dc7b8 | ||
|
|
a74e0dc7fe | ||
|
|
79f4504694 | ||
|
|
b04981d7fb | ||
|
|
f30faeecff | ||
|
|
5952de21b2 | ||
|
|
bcfa1a0919 | ||
|
|
3e2a52770c | ||
|
|
4f20a67fda | ||
|
|
c085154b8a | ||
|
|
e533b60769 | ||
|
|
fca3a1f9d7 | ||
|
|
18abb9a6d8 | ||
|
|
d75d441441 | ||
|
|
6f95fd265b | ||
|
|
09c0e600e9 | ||
|
|
c6d5607811 | ||
|
|
8fcd875f38 | ||
|
|
3a1f872f54 | ||
|
|
30e260a357 | ||
|
|
58bf10b4ec | ||
|
|
bd2a8acbfe | ||
|
|
a9c2ab0da5 | ||
|
|
13bbdf9e26 | ||
|
|
32cff2431a | ||
|
|
ae5246f271 | ||
|
|
42d9a60805 | ||
|
|
ecfb98ac86 | ||
|
|
7faade70a9 | ||
|
|
2376e99217 | ||
|
|
99be1e8d79 | ||
|
|
71526fe61e | ||
|
|
13abad6563 | ||
|
|
689dbc48e7 | ||
|
|
1a20994749 | ||
|
|
e2f10f6260 | ||
|
|
69b4879ac2 | ||
|
|
2dc8ee4242 | ||
|
|
59e94192aa | ||
|
|
48fcf181af | ||
|
|
793bd340f1 | ||
|
|
029629c349 | ||
|
|
9a47a61c55 | ||
|
|
78035685a3 | ||
|
|
7db2ad88b3 | ||
|
|
f6765887f1 | ||
|
|
4d0ea25506 | ||
|
|
42cec52e21 | ||
|
|
64b58537bd | ||
|
|
0af3c726eb | ||
|
|
6fa533d530 | ||
|
|
b737aede64 | ||
|
|
df60ee9c39 | ||
|
|
632c0a4a87 | ||
|
|
4bb18d128f | ||
|
|
9cd8ac1783 | ||
|
|
05e11068f4 | ||
|
|
78717e76fe | ||
|
|
e11a6375a2 | ||
|
|
d662c72d14 | ||
|
|
49af400c89 | ||
|
|
14c6193367 | ||
|
|
1144b43d49 | ||
|
|
28f1dfb1b0 | ||
|
|
20d5d4d694 | ||
|
|
ba045acb74 | ||
|
|
0e26fcea35 | ||
|
|
8e1cf43b14 | ||
|
|
1051967eb0 | ||
|
|
9d30d86386 | ||
|
|
d2b3a2b5d6 | ||
|
|
7907c244b1 | ||
|
|
470a8999a6 | ||
|
|
bf33ac6544 | ||
|
|
8d04d71925 | ||
|
|
b56f0b2f11 | ||
|
|
5d78742fef | ||
|
|
d0164c0db3 | ||
|
|
0f8cf0a5f9 | ||
|
|
d8dd86ba04 | ||
|
|
01d636ea19 | ||
|
|
1af5ada292 | ||
|
|
fb5ba24571 | ||
|
|
287fe831e6 | ||
|
|
440dbae906 | ||
|
|
7141f51e08 | ||
|
|
fa0df9ed40 | ||
|
|
e3452099cd | ||
|
|
fb99ed0fb8 | ||
|
|
7ffd5e2ae0 | ||
|
|
8fa366a61d | ||
|
|
91a6383da7 | ||
|
|
e6a0a69271 | ||
|
|
3dcad8878b | ||
|
|
906df823b5 | ||
|
|
c4bd6b0f4e | ||
|
|
02645de474 | ||
|
|
0f0c73f8f4 | ||
|
|
7c4bff2191 | ||
|
|
8c82db71e7 | ||
|
|
76a3bc6916 | ||
|
|
4f28947ca2 | ||
|
|
351b50f3cf | ||
|
|
661ba275c2 | ||
|
|
21af7198ae | ||
|
|
12209a79a6 | ||
|
|
d779fc02db | ||
|
|
69933f60bc | ||
|
|
8ae1ab6f0b | ||
|
|
3073790f56 | ||
|
|
8b677a9ec0 | ||
|
|
611c78c3a7 | ||
|
|
2a54abc349 | ||
|
|
ce2a1c4464 | ||
|
|
8b487b6248 | ||
|
|
b8294fe576 | ||
|
|
934bdae8a1 | ||
|
|
c41ecb0a40 | ||
|
|
7eb02e3044 | ||
|
|
2360fcddc4 | ||
|
|
4236986d4e | ||
|
|
36bd41dea2 | ||
|
|
da5f5a7b54 | ||
|
|
c58e64550a | ||
|
|
5430dbcb63 | ||
|
|
707082c6dd | ||
|
|
74919a1ab1 | ||
|
|
4405a45fe9 | ||
|
|
3dc10845e0 | ||
|
|
a0ae156733 | ||
|
|
175b959eac | ||
|
|
f2c259891d | ||
|
|
42d97dd32a | ||
|
|
a012bda370 | ||
|
|
3d0708dc7a | ||
|
|
b04433019c | ||
|
|
472714aa2b | ||
|
|
cedf7f3d00 | ||
|
|
3a91bfa750 | ||
|
|
95671e39b7 | ||
|
|
ee47158e74 | ||
|
|
4b552dbdbd | ||
|
|
1ac86de43d | ||
|
|
3da79ec92f | ||
|
|
ae2cfe1495 | ||
|
|
2edca09efb | ||
|
|
4d585f3a6d | ||
|
|
3e0af305e2 | ||
|
|
b3290b43a0 | ||
|
|
8483e9c31d | ||
|
|
559e5f440a | ||
|
|
2465e04f28 | ||
|
|
243a431966 | ||
|
|
ff03b30c02 | ||
|
|
79cc152f33 | ||
|
|
bbfd02216e | ||
|
|
1133c8fdf3 | ||
|
|
e933e9a8f4 | ||
|
|
c81e664cbf | ||
|
|
60f92b1c7a | ||
|
|
0806a78b3b | ||
|
|
89d55da737 | ||
|
|
c6384642c2 | ||
|
|
1361fe753f | ||
|
|
61a1135750 | ||
|
|
7f069e0663 | ||
|
|
9129a3c0ff | ||
|
|
631134f822 | ||
|
|
6e1f001fd3 | ||
|
|
09fcd479f4 | ||
|
|
c6bd932c43 | ||
|
|
5c9f959a2f | ||
|
|
a66a5bd210 | ||
|
|
2e25b78c52 | ||
|
|
6b35dae427 | ||
|
|
8ec1b68789 | ||
|
|
a141b67612 | ||
|
|
c97eb0e2c1 | ||
|
|
27ec086389 | ||
|
|
77d3af8aee | ||
|
|
e100d6bdf4 | ||
|
|
7517d81489 | ||
|
|
b6f202e72f | ||
|
|
dff12b31b9 | ||
|
|
5740ca170e | ||
|
|
eddb5da323 | ||
|
|
d9ce190ead | ||
|
|
4d969ba0a5 | ||
|
|
e60e671171 | ||
|
|
8eadef3f92 | ||
|
|
d863b18f3f | ||
|
|
6b3152a50c | ||
|
|
acdc9c04a2 | ||
|
|
67ea480a14 | ||
|
|
9d7609cf36 | ||
|
|
03bcde4805 | ||
|
|
bec1e4fba7 | ||
|
|
41d5fb3459 | ||
|
|
5b0b2db0cb | ||
|
|
e87a771a02 | ||
|
|
c8f215eaaf | ||
|
|
8aaefb2aa4 | ||
|
|
2e370a5faf | ||
|
|
305ce1a64f | ||
|
|
27a92e1614 | ||
|
|
8eda711bce | ||
|
|
967d37161d | ||
|
|
6070ffce0d | ||
|
|
e538710a34 | ||
|
|
171afbd72b | ||
|
|
255fe61811 | ||
|
|
7084d5ab65 | ||
|
|
e707842a5f | ||
|
|
bc02d86a17 | ||
|
|
4df929e1df | ||
|
|
497a38d405 | ||
|
|
1220b79222 | ||
|
|
a0ea82d7db | ||
|
|
7ff1d2aaa4 | ||
|
|
b4b4df9d1f | ||
|
|
14951a31a7 | ||
|
|
5f1a2789a4 | ||
|
|
e8af7c6a2a | ||
|
|
dd2d51e1ce | ||
|
|
33fd2d32a9 | ||
|
|
94ef3ddb9f | ||
|
|
f5f29c95d8 | ||
|
|
93c737e526 | ||
|
|
26a5d4be60 | ||
|
|
1a063ab5c2 | ||
|
|
be8a58b178 | ||
|
|
030322d496 | ||
|
|
5ba89a5fc1 | ||
|
|
746f65338b | ||
|
|
0b121dd984 | ||
|
|
0c859d7142 | ||
|
|
0725f4ecf9 | ||
|
|
cb484051e3 | ||
|
|
e94eaa63bf | ||
|
|
fa8b101b34 | ||
|
|
281a782b93 | ||
|
|
648280ab45 | ||
|
|
4306a19d7b | ||
|
|
1f02eb1f26 | ||
|
|
9aa6e6df2b | ||
|
|
4487f2872f | ||
|
|
f4cb43b29f | ||
|
|
078c395126 | ||
|
|
db42cf3050 | ||
|
|
93f419f6d1 | ||
|
|
9e4ed42806 | ||
|
|
006587cb23 | ||
|
|
74f4dfff33 | ||
|
|
14607c15c4 | ||
|
|
a7f0b6a66a | ||
|
|
57e0c201f8 | ||
|
|
b57bcffa41 | ||
|
|
65c989d747 | ||
|
|
314337c443 | ||
|
|
77ee87a6f8 | ||
|
|
98495e91eb | ||
|
|
e5b02614cc | ||
|
|
acba1b4eb0 | ||
|
|
01605bc8fd | ||
|
|
1169347893 | ||
|
|
38a0920c05 | ||
|
|
214b25092c | ||
|
|
e268569eac | ||
|
|
b5e651dfb2 | ||
|
|
ff00bc4e1b | ||
|
|
5a0eb25ad2 | ||
|
|
c98fbb6a6e | ||
|
|
6f2d8865c9 | ||
|
|
2eeb1a153f | ||
|
|
911c03d61e | ||
|
|
88c470d924 | ||
|
|
a723eed805 | ||
|
|
c5998468b7 | ||
|
|
2ac3a791ce | ||
|
|
2e182f8301 | ||
|
|
ec50f31b21 | ||
|
|
e68ae49f28 | ||
|
|
8e32bbe5e1 | ||
|
|
a03f85604e | ||
|
|
4c69b0f063 | ||
|
|
db463ddb6d | ||
|
|
82114783f5 | ||
|
|
5578572709 | ||
|
|
9b8da635f8 | ||
|
|
2930f49a34 | ||
|
|
28a19f13fa | ||
|
|
e7fa72eaec | ||
|
|
919c99f9a9 | ||
|
|
98e25c42ce | ||
|
|
4a228c2cef | ||
|
|
87a711be95 | ||
|
|
e96174fd3f | ||
|
|
f52485043b | ||
|
|
0ea61177aa | ||
|
|
246194ef86 | ||
|
|
8597f8e1ec | ||
|
|
7e4641182a | ||
|
|
6af6c58405 | ||
|
|
c59cc5d1f7 | ||
|
|
be2f223439 | ||
|
|
e443f8d338 | ||
|
|
9637100811 | ||
|
|
5391488d1d | ||
|
|
78ef355ebc | ||
|
|
c458d097f5 | ||
|
|
f97100b96c | ||
|
|
5f60defa21 | ||
|
|
833bfd98f0 | ||
|
|
5dd32d9177 | ||
|
|
d2e9a3a0ed | ||
|
|
59e1c32b99 | ||
|
|
fba82da792 | ||
|
|
017675d633 | ||
|
|
6ca1552075 | ||
|
|
549f584ce2 | ||
|
|
91882683f6 | ||
|
|
ad95aece48 | ||
|
|
ab4b020fef | ||
|
|
36383f3455 | ||
|
|
7e667324b1 | ||
|
|
416a141497 | ||
|
|
3a9be0537b | ||
|
|
458b37a8d3 | ||
|
|
03dc81259d | ||
|
|
eb6382f345 | ||
|
|
dd86db0c30 | ||
|
|
c4b1f479e0 | ||
|
|
5579352c38 | ||
|
|
b4b101a866 | ||
|
|
d69763c47e | ||
|
|
2958d872bb | ||
|
|
4ec0fecb4e | ||
|
|
3a04e3b6b0 | ||
|
|
99168cab9a | ||
|
|
86fe0c45fc | ||
|
|
0fb4dfd1d3 | ||
|
|
0e6437d9be | ||
|
|
6693837ab3 | ||
|
|
58373c3a68 | ||
|
|
345de6ca4d | ||
|
|
ef40cde503 | ||
|
|
8cb593cf1f | ||
|
|
f4d5c68481 | ||
|
|
3f1bf688c5 | ||
|
|
a9cbd5f23e | ||
|
|
206f6edb79 | ||
|
|
dea1f53a7b | ||
|
|
7a2cacee15 | ||
|
|
c10b87584b | ||
|
|
bf1e27426a | ||
|
|
f8a4500029 | ||
|
|
03eda282bf | ||
|
|
f7b3b072a1 | ||
|
|
9834ffbec1 | ||
|
|
bc2c0a79f4 | ||
|
|
cc315f59e1 | ||
|
|
c56ecce50f | ||
|
|
fa887df47a | ||
|
|
5c9f81f0c9 | ||
|
|
750cfba699 | ||
|
|
34189702d1 | ||
|
|
63a41b5360 | ||
|
|
5d70496138 | ||
|
|
714c315501 | ||
|
|
0c7cf93f03 | ||
|
|
cae56e9b16 | ||
|
|
89827c5558 | ||
|
|
6e81eb148b | ||
|
|
baec36e84c | ||
|
|
b5a02c6bf0 | ||
|
|
7681aa4c1f | ||
|
|
9c97d2db8a | ||
|
|
5ed9d4c213 | ||
|
|
aebfeca342 | ||
|
|
443a930b57 | ||
|
|
4c09591dcb | ||
|
|
516c47fe7a | ||
|
|
610947d3cd | ||
|
|
793b0b371a | ||
|
|
5bf1254148 | ||
|
|
e4843606c1 | ||
|
|
652716de43 | ||
|
|
3e85e5738b | ||
|
|
3ed477327c | ||
|
|
4a27cf4853 | ||
|
|
2653f892f4 | ||
|
|
7256b5e757 | ||
|
|
691d1a61ff | ||
|
|
d71ea2e0bf | ||
|
|
7545664a97 | ||
|
|
cedd181730 | ||
|
|
1c105fd2ed | ||
|
|
9988eb8337 | ||
|
|
1675fe6e2a | ||
|
|
8da6c52ad1 | ||
|
|
f67423ac98 | ||
|
|
06ff0de6a9 | ||
|
|
01125760c9 | ||
|
|
b4f8d1bc17 | ||
|
|
9da5699f15 | ||
|
|
ebf58f421e | ||
|
|
69b83992df | ||
|
|
382d8129c7 | ||
|
|
9f3220ccd3 | ||
|
|
ddc63ab71e | ||
|
|
f1a66ff20b | ||
|
|
14eefa1062 | ||
|
|
8c80bc0033 | ||
|
|
0ec45caf87 | ||
|
|
fcb6c964a5 | ||
|
|
e14be0098d | ||
|
|
a8133e2013 | ||
|
|
72fea0d5aa | ||
|
|
b69908fb67 | ||
|
|
6b8e1da0fc | ||
|
|
1fc2242672 | ||
|
|
37d2d5b9a7 | ||
|
|
81336b3f92 | ||
|
|
f9706eb740 | ||
|
|
9baf3d5b15 | ||
|
|
b47c7f7b04 | ||
|
|
afb4946fa9 | ||
|
|
9a2386142f | ||
|
|
209fc7c40f | ||
|
|
e25cf990c8 | ||
|
|
31d613fc09 | ||
|
|
e80d9b7c53 | ||
|
|
4aea12840e | ||
|
|
db6a5d5dd4 | ||
|
|
7d001b6911 | ||
|
|
441e06502c | ||
|
|
e7651bf153 | ||
|
|
9f72e31f20 | ||
|
|
a57c408f2f | ||
|
|
65272df088 | ||
|
|
d6c38c0951 | ||
|
|
f5d0efa544 | ||
|
|
c8220e28bf | ||
|
|
a7b05d81e9 | ||
|
|
b34bbfe96f | ||
|
|
4b4ed5e742 | ||
|
|
6df0a62b32 | ||
|
|
c7406b7fb1 | ||
|
|
3f6f1f34a0 | ||
|
|
ee69ca48c2 | ||
|
|
7db281547e | ||
|
|
888803e9c0 | ||
|
|
22726e82ba | ||
|
|
13a5c0baef | ||
|
|
852fafe5e8 | ||
|
|
6ea0978f53 | ||
|
|
2a2b1ed0a8 | ||
|
|
b6bbb757f2 | ||
|
|
3633ad7c81 | ||
|
|
320fd87b08 | ||
|
|
47b1821b78 | ||
|
|
965a3eadb5 | ||
|
|
c8780c53db | ||
|
|
fe8f9be585 | ||
|
|
5fef5363fe | ||
|
|
ace58784a8 | ||
|
|
16c46d9d8c | ||
|
|
9a37b8f22e | ||
|
|
14809ad09e | ||
|
|
e8e20f8b6b | ||
|
|
b15e52d39c | ||
|
|
7e528441ef | ||
|
|
79bb032658 | ||
|
|
df5426a40a | ||
|
|
1da39c94e3 | ||
|
|
ddb3e106b2 | ||
|
|
f383c3b8cc | ||
|
|
c21c4b6ccc | ||
|
|
ba1609a00c | ||
|
|
db5e27eb96 | ||
|
|
99e1471889 | ||
|
|
0883fc0d8d | ||
|
|
9f946a88d7 | ||
|
|
9de353dea4 | ||
|
|
d35659bb08 | ||
|
|
2593961935 | ||
|
|
37e755ed13 | ||
|
|
ab5b236ff9 | ||
|
|
d7ac0c1ec8 | ||
|
|
f20a7078e6 | ||
|
|
f2e52a22e5 | ||
|
|
e885ebc538 | ||
|
|
855761be6b | ||
|
|
a74c7ca294 | ||
|
|
ced6ca57c4 | ||
|
|
3b7561daaf | ||
|
|
a204275305 | ||
|
|
5cc99fd810 | ||
|
|
7bc5de6c96 | ||
|
|
5ef0843e81 | ||
|
|
7a6aa5108d | ||
|
|
91d0ff01b4 | ||
|
|
82349d7d2e | ||
|
|
eac009794a | ||
|
|
3e1ba4dbb0 | ||
|
|
6bd806b8c4 | ||
|
|
86c9e9b8eb | ||
|
|
09bffccb36 | ||
|
|
b427cd7296 | ||
|
|
2bbed4fd87 | ||
|
|
3c18f6be17 | ||
|
|
8e97021216 | ||
|
|
772e120cf6 | ||
|
|
e1173e513a | ||
|
|
74a0bb9211 | ||
|
|
afe53c95cd | ||
|
|
1d7e350dd1 | ||
|
|
36b66dca43 | ||
|
|
f0eb4d22bf | ||
|
|
66638a2b21 | ||
|
|
7fa6c1140e | ||
|
|
dae724a41d | ||
|
|
871541885c | ||
|
|
fb4f62b528 | ||
|
|
86870de4d5 | ||
|
|
7068c90656 | ||
|
|
1ba92683fc | ||
|
|
352b34e2d5 | ||
|
|
6ded3a45e4 | ||
|
|
d983c0d2d8 | ||
|
|
a7460fa14b | ||
|
|
ad166c860f | ||
|
|
1fc58339b1 | ||
|
|
4e5a9c7c74 | ||
|
|
dad079db78 | ||
|
|
e39fc5593d | ||
|
|
f8b5a54e3b | ||
|
|
2b02fbd1d1 | ||
|
|
df06411ba0 | ||
|
|
b8c18539de | ||
|
|
639a32aed4 | ||
|
|
56257d0d7c |
No files matched your search
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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.
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
</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 & Node.js · TIS Records Management System</sub>
|
||||
</div>
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 296 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 36 KiB |
@@ -0,0 +1 @@
|
||||
{}
|
||||
|
Before Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 2.1 MiB |
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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'
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
// ==========================================
|
||||
|
||||
@@ -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;
|
||||
@@ -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,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;
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||