Compare commits

..

47 Commits

Author SHA1 Message Date
Carlos Mesquita
02564c8426 Had the type hardcoded 2024-09-08 22:47:43 +01:00
Carlos Mesquita
6f534662e1 Shuffles bugfix 2024-09-08 22:45:24 +01:00
Tiago Ribeiro
620e4dd787 Solved some problems, bypassed some stuff 2024-09-08 11:35:09 +01:00
João Ramos
e3847baadb Merged in bug-fixing-8-sep-24 (pull request #99)
Bug fixing 8 sep 24

Approved-by: Tiago Ribeiro
2024-09-08 09:21:53 +00:00
Joao Ramos
5b8631ab6a Readded master statistical to corporate 2024-09-08 01:30:12 +01:00
Joao Ramos
f9f29eabb3 Fixed an issue after merging 2024-09-08 01:23:56 +01:00
Tiago Ribeiro
898edb152f Moved the Dockerfile env 2024-09-07 23:59:58 +01:00
Tiago Ribeiro
bf0d696b2f Some hard coding at least for now 2024-09-07 23:51:04 +01:00
Tiago Ribeiro
d91b1c14e7 Removed an error 2024-09-07 23:41:01 +01:00
Tiago Ribeiro
cdd42b2f07 Merge branch 'main' into migration-mongodb 2024-09-07 23:09:37 +01:00
João Ramos
34bc9df9ea Merged in ENCOA-200_StatisticalCorporate (pull request #97)
ENCOA-200: Added Master Statistical to Corporate

Approved-by: Tiago Ribeiro
2024-09-07 21:57:22 +00:00
Tiago Ribeiro
15cc7c8cc9 Improved a bit of the pagination 2024-09-07 22:48:52 +01:00
Carlos Mesquita
b4ab620c78 lint warnings 2024-09-07 22:42:22 +01:00
Carlos Mesquita
6e4ef249b8 Finished refactoring 2024-09-07 22:39:14 +01:00
Carlos Mesquita
c2b4bb29d6 Refactored /api/users 2024-09-07 21:50:29 +01:00
Carlos Mesquita
cab469007b Refactored /api/tickets /api/training 2024-09-07 21:25:51 +01:00
Carlos Mesquita
d6782bd86e Refactored /api/paypal, /api/permissions, /api/reset /api/sessions, /api/stats 2024-09-07 20:43:55 +01:00
Tiago Ribeiro
6251f8f4db Continued migrating more and more files 2024-09-07 18:29:20 +01:00
Joao Ramos
fb9d11f38d ENCOA-200: Added Master Statistical to Corporate 2024-09-07 18:19:25 +01:00
Carlos Mesquita
bb8dca69cf Merge conflict 2024-09-07 18:09:14 +01:00
Carlos Mesquita
53b31b306d /api/packages refactored, forgot to commit some changes 2024-09-07 18:08:03 +01:00
Tiago Ribeiro
d173cdb02a Updated the Grading System 2024-09-07 18:00:05 +01:00
Tiago Ribeiro
07f0ea25bb Merge branch 'migration-mongodb' of bitbucket.org:ecropdev/ielts-ui into migration-mongodb 2024-09-07 17:55:33 +01:00
Carlos Mesquita
e7ee55d608 Merge branch 'migration-mongodb' of https://bitbucket.org/ecropdev/ielts-ui into migration-mongodb 2024-09-07 17:54:19 +01:00
Carlos Mesquita
7fa4edf37d /api/groups and /api/invites refactored, fixed some inserts/updates in which I didn't include the id 2024-09-07 17:54:10 +01:00
Tiago Ribeiro
49022394b0 Did the same treatment to Corporate and Teacher dashboards 2024-09-07 17:53:16 +01:00
Tiago Ribeiro
3be0d158e3 Improved the performance of the MasterCorporate 2024-09-07 17:34:41 +01:00
Tiago Ribeiro
56f374bbfe Updated the pagination on the useUsers and migrading the grading 2024-09-07 16:53:58 +01:00
Carlos Mesquita
417c9176fe /api/exam refactor 2024-09-07 16:39:07 +01:00
Carlos Mesquita
e3400e8564 /api/evaluate refactor 2024-09-07 16:17:24 +01:00
Carlos Mesquita
d680905a87 Merge branch 'migration-mongodb' of https://bitbucket.org/ecropdev/ielts-ui into migration-mongodb 2024-09-07 16:03:32 +01:00
Carlos Mesquita
c07e3f86fb Refactored discounts and replaced my previous commit id queries to use id not _id 2024-09-07 16:03:26 +01:00
Tiago Ribeiro
238a25aaeb Updated the make_user to use MongoDB 2024-09-07 16:01:03 +01:00
Carlos Mesquita
171231cd21 Refactored codes 2024-09-07 15:41:09 +01:00
Carlos Mesquita
6ed342bb6f Merge branch 'migration-mongodb' of https://bitbucket.org/ecropdev/ielts-ui into migration-mongodb 2024-09-07 15:15:17 +01:00
Carlos Mesquita
6f7ef1abef Refactored pages/api/assignments to mongodb 2024-09-07 15:13:41 +01:00
Tiago Ribeiro
e33fa00fa3 Updated the groups and users 2024-09-07 15:13:13 +01:00
Tiago Ribeiro
c0b814081e Updated the register endpoint to use MongoDB 2024-09-07 13:58:52 +01:00
Tiago Ribeiro
e8b7c5ff80 Started migrating the DB to MongoDB 2024-09-07 13:37:08 +01:00
Tiago Ribeiro
8c94bcac52 Corrected a problem with too many participants 2024-09-07 13:02:47 +01:00
carlos.mesquita
8803a8c166 Merged in feature/training-content (pull request #95)
ENCOA-69: Training update, most of the styles in the old tips were standardized, before all the styles were hardcoded into the tip
2024-09-07 11:42:06 +00:00
João Ramos
2f63fd196b Merged in ENCOA-183_MasterCorporateGrouping (pull request #96)
ENCOA-183 MasterCorporateGrouping

Approved-by: Tiago Ribeiro
2024-09-07 11:41:29 +00:00
Joao Ramos
42471170ce Improved grouping for master statistical 2024-09-07 12:38:28 +01:00
Carlos Mesquita
2bf9afca9c Merge remote-tracking branch 'origin/develop' into feature/training-content 2024-09-07 11:38:18 +01:00
Carlos Mesquita
9c41ddee60 Training update, most of the styles in the old tips were standardized, before all the styles were hardcoded into the tip, the new tips may still have some hardcoded styles but the vast majority only uses standard html or custom ones that are picked up in FormatTip to attribute styles 2024-09-07 11:37:04 +01:00
Joao Ramos
9993c7a8a7 ENCOA-183: Initial test changes for corporates grouped by name 2024-09-07 11:04:00 +01:00
João Ramos
a22c9d102f Merged in ENCOA-131_MasterStatistical (pull request #94)
Added level part display on excel and a pseudo sorting

Approved-by: Tiago Ribeiro
2024-09-07 08:13:29 +00:00
134 changed files with 6040 additions and 5581 deletions

View File

@@ -23,6 +23,8 @@ COPY . .
# Uncomment the following line in case you want to disable telemetry during the build. # Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1 # ENV NEXT_TELEMETRY_DISABLED 1
ENV MONGODB_URI "mongodb+srv://user:JKpFBymv0WLv3STj@encoach.lz18a.mongodb.net/?retryWrites=true&w=majority&appName=EnCoach"
RUN yarn build RUN yarn build
# If using npm comment out above and use below instead # If using npm comment out above and use below instead

304
package-lock.json generated
View File

@@ -26,7 +26,8 @@
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@use-gesture/react": "^10.3.1", "@use-gesture/react": "^10.3.1",
"axios": "^1.3.5", "axios": "^1",
"axios-cache-interceptor": "^1",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"chart.js": "^4.2.1", "chart.js": "^4.2.1",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
@@ -50,6 +51,7 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"moment": "^2.29.4", "moment": "^2.29.4",
"moment-timezone": "^0.5.44", "moment-timezone": "^0.5.44",
"mongodb": "^6.8.1",
"next": "^14.2.5", "next": "^14.2.5",
"nodemailer": "^6.9.5", "nodemailer": "^6.9.5",
"nodemailer-express-handlebars": "^6.1.0", "nodemailer-express-handlebars": "^6.1.0",
@@ -78,7 +80,7 @@
"read-excel-file": "^5.7.1", "read-excel-file": "^5.7.1",
"short-unique-id": "5.0.2", "short-unique-id": "5.0.2",
"stripe": "^13.10.0", "stripe": "^13.10.0",
"swr": "^2.1.3", "swr": "^2.2.5",
"tailwind-merge": "^2.5.2", "tailwind-merge": "^2.5.2",
"tailwind-scrollbar-hide": "^1.1.7", "tailwind-scrollbar-hide": "^1.1.7",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@@ -89,6 +91,7 @@
"zustand": "^4.3.6" "zustand": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@simbathesailor/use-what-changed": "^2.0.0",
"@types/blob-stream": "^0.1.33", "@types/blob-stream": "^0.1.33",
"@types/formidable": "^3.4.0", "@types/formidable": "^3.4.0",
"@types/howler": "^2.2.11", "@types/howler": "^2.2.11",
@@ -1946,6 +1949,14 @@
"prop-types": "^15.7.2" "prop-types": "^15.7.2"
} }
}, },
"node_modules/@mongodb-js/saslprep": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.9.tgz",
"integrity": "sha512-tVkljjeEaAhCqTzajSdgbQ6gE6f3oneVwa3iXR6csiEwXXOFsiC6Uh9iAjAhXPtqa/XMDHWjjeNH/77m/Yq2dw==",
"dependencies": {
"sparse-bitfield": "^3.0.3"
}
},
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "14.2.5", "version": "14.2.5",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.5.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.5.tgz",
@@ -3036,6 +3047,15 @@
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz",
"integrity": "sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg==" "integrity": "sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg=="
}, },
"node_modules/@simbathesailor/use-what-changed": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@simbathesailor/use-what-changed/-/use-what-changed-2.0.0.tgz",
"integrity": "sha512-ulBNrPSvfho9UN6zS2fii3AsdEcp2fMaKeqUZZeCNPaZbB6aXyTUhpEN9atjMAbu/eyK3AY8L4SYJUG62Ekocw==",
"dev": true,
"peerDependencies": {
"react": ">=16"
}
},
"node_modules/@swc/counter": { "node_modules/@swc/counter": {
"version": "0.1.3", "version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
@@ -3455,6 +3475,19 @@
"@types/debounce": "*" "@types/debounce": "*"
} }
}, },
"node_modules/@types/webidl-conversions": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
"integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="
},
"node_modules/@types/whatwg-url": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz",
"integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==",
"dependencies": {
"@types/webidl-conversions": "*"
}
},
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "5.51.0", "version": "5.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.51.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.51.0.tgz",
@@ -4001,6 +4034,25 @@
"proxy-from-env": "^1.1.0" "proxy-from-env": "^1.1.0"
} }
}, },
"node_modules/axios-cache-interceptor": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/axios-cache-interceptor/-/axios-cache-interceptor-1.5.3.tgz",
"integrity": "sha512-kPgGId9XW7tR+VF7hgSkqF4f6FrV4ecCyKxjkD9v1hNJ4sXSAskocr7SMKaVHVvrbzVeruwB6yL6Y9/lY1ApKg==",
"dependencies": {
"cache-parser": "1.2.5",
"fast-defer": "1.1.8",
"object-code": "1.3.3"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/arthurfiorette/axios-cache-interceptor?sponsor=1"
},
"peerDependencies": {
"axios": "^1"
}
},
"node_modules/axobject-query": { "node_modules/axobject-query": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz",
@@ -4255,6 +4307,14 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/bson": {
"version": "6.8.0",
"resolved": "https://registry.npmjs.org/bson/-/bson-6.8.0.tgz",
"integrity": "sha512-iOJg8pr7wq2tg/zSlCCHMi3hMm5JTOxLTagf3zxhcenHsFp+c6uOs6K7W5UE7A4QIJGtqh/ZovFNMP4mOPJynQ==",
"engines": {
"node": ">=16.20.1"
}
},
"node_modules/buffer": { "node_modules/buffer": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
@@ -4318,6 +4378,11 @@
"node": ">=10.16.0" "node": ">=10.16.0"
} }
}, },
"node_modules/cache-parser": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/cache-parser/-/cache-parser-1.2.5.tgz",
"integrity": "sha512-Md/4VhAHByQ9frQ15WD6LrMNiVw9AEl/J7vWIXw+sxT6fSOpbtt6LHTp76vy8+bOESPBO94117Hm2bIjlI7XjA=="
},
"node_modules/call-bind": { "node_modules/call-bind": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
@@ -6045,6 +6110,11 @@
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
}, },
"node_modules/fast-defer": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/fast-defer/-/fast-defer-1.1.8.tgz",
"integrity": "sha512-lEJeOH5VL5R09j6AA0D4Uvq7AgsHw0dAImQQ+F3iSyHZuAxyQfWobsagGpTcOPvJr3urmKRHrs+Gs9hV+/Qm/Q=="
},
"node_modules/fast-glob": { "node_modules/fast-glob": {
"version": "3.2.12", "version": "3.2.12",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
@@ -8372,6 +8442,11 @@
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
}, },
"node_modules/memory-pager": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="
},
"node_modules/merge2": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -8503,6 +8578,91 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/mongodb": {
"version": "6.8.1",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.8.1.tgz",
"integrity": "sha512-qsS+gl5EJb+VzJqUjXSZ5Y5rbuM/GZlZUEJ2OIVYP10L9rO9DQ0DGp+ceTzsmoADh6QYMWd9MSdG9IxRyYUkEA==",
"dependencies": {
"@mongodb-js/saslprep": "^1.1.5",
"bson": "^6.7.0",
"mongodb-connection-string-url": "^3.0.0"
},
"engines": {
"node": ">=16.20.1"
},
"peerDependencies": {
"@aws-sdk/credential-providers": "^3.188.0",
"@mongodb-js/zstd": "^1.1.0",
"gcp-metadata": "^5.2.0",
"kerberos": "^2.0.1",
"mongodb-client-encryption": ">=6.0.0 <7",
"snappy": "^7.2.2",
"socks": "^2.7.1"
},
"peerDependenciesMeta": {
"@aws-sdk/credential-providers": {
"optional": true
},
"@mongodb-js/zstd": {
"optional": true
},
"gcp-metadata": {
"optional": true
},
"kerberos": {
"optional": true
},
"mongodb-client-encryption": {
"optional": true
},
"snappy": {
"optional": true
},
"socks": {
"optional": true
}
}
},
"node_modules/mongodb-connection-string-url": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.1.tgz",
"integrity": "sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg==",
"dependencies": {
"@types/whatwg-url": "^11.0.2",
"whatwg-url": "^13.0.0"
}
},
"node_modules/mongodb-connection-string-url/node_modules/tr46": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz",
"integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==",
"dependencies": {
"punycode": "^2.3.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/mongodb-connection-string-url/node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"engines": {
"node": ">=12"
}
},
"node_modules/mongodb-connection-string-url/node_modules/whatwg-url": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-13.0.0.tgz",
"integrity": "sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig==",
"dependencies": {
"tr46": "^4.1.1",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@@ -8730,6 +8890,11 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/object-code": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/object-code/-/object-code-1.3.3.tgz",
"integrity": "sha512-/Ds4Xd5xzrtUOJ+xJQ57iAy0BZsZltOHssnDgcZ8DOhgh41q1YJCnTPnWdWSLkNGNnxYzhYChjc5dgC9mEERCA=="
},
"node_modules/object-hash": { "node_modules/object-hash": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
@@ -10381,6 +10546,14 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/sparse-bitfield": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
"dependencies": {
"memory-pager": "^1.0.2"
}
},
"node_modules/stop-iteration-iterator": { "node_modules/stop-iteration-iterator": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz",
@@ -10665,10 +10838,11 @@
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==" "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g=="
}, },
"node_modules/swr": { "node_modules/swr": {
"version": "2.1.3", "version": "2.2.5",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.1.3.tgz", "resolved": "https://registry.npmjs.org/swr/-/swr-2.2.5.tgz",
"integrity": "sha512-g3ApxIM4Fjbd6vvEAlW60hJlKcYxHb+wtehogTygrh6Jsw7wNagv9m4Oj5Gq6zvvZw0tcyhVGL9L0oISvl3sUw==", "integrity": "sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==",
"dependencies": { "dependencies": {
"client-only": "^0.0.1",
"use-sync-external-store": "^1.2.0" "use-sync-external-store": "^1.2.0"
}, },
"peerDependencies": { "peerDependencies": {
@@ -13163,6 +13337,14 @@
"prop-types": "^15.7.2" "prop-types": "^15.7.2"
} }
}, },
"@mongodb-js/saslprep": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.9.tgz",
"integrity": "sha512-tVkljjeEaAhCqTzajSdgbQ6gE6f3oneVwa3iXR6csiEwXXOFsiC6Uh9iAjAhXPtqa/XMDHWjjeNH/77m/Yq2dw==",
"requires": {
"sparse-bitfield": "^3.0.3"
}
},
"@next/env": { "@next/env": {
"version": "14.2.5", "version": "14.2.5",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.5.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.5.tgz",
@@ -13901,6 +14083,12 @@
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz",
"integrity": "sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg==" "integrity": "sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg=="
}, },
"@simbathesailor/use-what-changed": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@simbathesailor/use-what-changed/-/use-what-changed-2.0.0.tgz",
"integrity": "sha512-ulBNrPSvfho9UN6zS2fii3AsdEcp2fMaKeqUZZeCNPaZbB6aXyTUhpEN9atjMAbu/eyK3AY8L4SYJUG62Ekocw==",
"dev": true
},
"@swc/counter": { "@swc/counter": {
"version": "0.1.3", "version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
@@ -14287,6 +14475,19 @@
"@types/debounce": "*" "@types/debounce": "*"
} }
}, },
"@types/webidl-conversions": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
"integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="
},
"@types/whatwg-url": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz",
"integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==",
"requires": {
"@types/webidl-conversions": "*"
}
},
"@typescript-eslint/parser": { "@typescript-eslint/parser": {
"version": "5.51.0", "version": "5.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.51.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.51.0.tgz",
@@ -14678,6 +14879,16 @@
"proxy-from-env": "^1.1.0" "proxy-from-env": "^1.1.0"
} }
}, },
"axios-cache-interceptor": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/axios-cache-interceptor/-/axios-cache-interceptor-1.5.3.tgz",
"integrity": "sha512-kPgGId9XW7tR+VF7hgSkqF4f6FrV4ecCyKxjkD9v1hNJ4sXSAskocr7SMKaVHVvrbzVeruwB6yL6Y9/lY1ApKg==",
"requires": {
"cache-parser": "1.2.5",
"fast-defer": "1.1.8",
"object-code": "1.3.3"
}
},
"axobject-query": { "axobject-query": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz",
@@ -14869,6 +15080,11 @@
"update-browserslist-db": "^1.0.10" "update-browserslist-db": "^1.0.10"
} }
}, },
"bson": {
"version": "6.8.0",
"resolved": "https://registry.npmjs.org/bson/-/bson-6.8.0.tgz",
"integrity": "sha512-iOJg8pr7wq2tg/zSlCCHMi3hMm5JTOxLTagf3zxhcenHsFp+c6uOs6K7W5UE7A4QIJGtqh/ZovFNMP4mOPJynQ=="
},
"buffer": { "buffer": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
@@ -14906,6 +15122,11 @@
"streamsearch": "^1.1.0" "streamsearch": "^1.1.0"
} }
}, },
"cache-parser": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/cache-parser/-/cache-parser-1.2.5.tgz",
"integrity": "sha512-Md/4VhAHByQ9frQ15WD6LrMNiVw9AEl/J7vWIXw+sxT6fSOpbtt6LHTp76vy8+bOESPBO94117Hm2bIjlI7XjA=="
},
"call-bind": { "call-bind": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
@@ -16235,6 +16456,11 @@
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
}, },
"fast-defer": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/fast-defer/-/fast-defer-1.1.8.tgz",
"integrity": "sha512-lEJeOH5VL5R09j6AA0D4Uvq7AgsHw0dAImQQ+F3iSyHZuAxyQfWobsagGpTcOPvJr3urmKRHrs+Gs9hV+/Qm/Q=="
},
"fast-glob": { "fast-glob": {
"version": "3.2.12", "version": "3.2.12",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
@@ -18009,6 +18235,11 @@
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
}, },
"memory-pager": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="
},
"merge2": { "merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -18097,6 +18328,49 @@
"moment": "^2.29.4" "moment": "^2.29.4"
} }
}, },
"mongodb": {
"version": "6.8.1",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.8.1.tgz",
"integrity": "sha512-qsS+gl5EJb+VzJqUjXSZ5Y5rbuM/GZlZUEJ2OIVYP10L9rO9DQ0DGp+ceTzsmoADh6QYMWd9MSdG9IxRyYUkEA==",
"requires": {
"@mongodb-js/saslprep": "^1.1.5",
"bson": "^6.7.0",
"mongodb-connection-string-url": "^3.0.0"
}
},
"mongodb-connection-string-url": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.1.tgz",
"integrity": "sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg==",
"requires": {
"@types/whatwg-url": "^11.0.2",
"whatwg-url": "^13.0.0"
},
"dependencies": {
"tr46": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz",
"integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==",
"requires": {
"punycode": "^2.3.0"
}
},
"webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="
},
"whatwg-url": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-13.0.0.tgz",
"integrity": "sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig==",
"requires": {
"tr46": "^4.1.1",
"webidl-conversions": "^7.0.0"
}
}
}
},
"ms": { "ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@@ -18248,6 +18522,11 @@
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="
}, },
"object-code": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/object-code/-/object-code-1.3.3.tgz",
"integrity": "sha512-/Ds4Xd5xzrtUOJ+xJQ57iAy0BZsZltOHssnDgcZ8DOhgh41q1YJCnTPnWdWSLkNGNnxYzhYChjc5dgC9mEERCA=="
},
"object-hash": { "object-hash": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
@@ -19413,6 +19692,14 @@
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==" "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw=="
}, },
"sparse-bitfield": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
"requires": {
"memory-pager": "^1.0.2"
}
},
"stop-iteration-iterator": { "stop-iteration-iterator": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz",
@@ -19626,10 +19913,11 @@
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==" "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g=="
}, },
"swr": { "swr": {
"version": "2.1.3", "version": "2.2.5",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.1.3.tgz", "resolved": "https://registry.npmjs.org/swr/-/swr-2.2.5.tgz",
"integrity": "sha512-g3ApxIM4Fjbd6vvEAlW60hJlKcYxHb+wtehogTygrh6Jsw7wNagv9m4Oj5Gq6zvvZw0tcyhVGL9L0oISvl3sUw==", "integrity": "sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==",
"requires": { "requires": {
"client-only": "^0.0.1",
"use-sync-external-store": "^1.2.0" "use-sync-external-store": "^1.2.0"
} }
}, },

View File

@@ -53,6 +53,7 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"moment": "^2.29.4", "moment": "^2.29.4",
"moment-timezone": "^0.5.44", "moment-timezone": "^0.5.44",
"mongodb": "^6.8.1",
"next": "^14.2.5", "next": "^14.2.5",
"nodemailer": "^6.9.5", "nodemailer": "^6.9.5",
"nodemailer-express-handlebars": "^6.1.0", "nodemailer-express-handlebars": "^6.1.0",

View File

@@ -42,7 +42,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
setIsLoading(true); setIsLoading(true);
axios axios
.patch("/api/users/update", { .patch<{user: User}>("/api/users/update", {
demographicInformation: { demographicInformation: {
country, country,
phone: `+${countryCodes.findOne("countryCode" as any, country!).countryCallingCode}${phone}`, phone: `+${countryCodes.findOne("countryCode" as any, country!).countryCallingCode}${phone}`,
@@ -54,7 +54,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
}, },
agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined, agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined,
}) })
.then((response) => mutateUser((response.data as {user: User}).user)) .then((response) => mutateUser(response.data.user))
.catch(() => { .catch(() => {
toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"}); toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"});
}) })
@@ -89,7 +89,15 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
<label className="font-normal text-base text-mti-gray-dim">Country *</label> <label className="font-normal text-base text-mti-gray-dim">Country *</label>
<CountrySelect value={country} onChange={setCountry} /> <CountrySelect value={country} onChange={setCountry} />
</div> </div>
<Input type="tel" name="phone" label="Phone number" onChange={(e) => setPhone(e)} value={phone} placeholder="Enter phone number" required /> <Input
type="tel"
name="phone"
label="Phone number"
onChange={(e) => setPhone(e)}
value={phone}
placeholder="Enter phone number"
required
/>
</div> </div>
{user.type === "student" && ( {user.type === "student" && (
<Input <Input

View File

@@ -1,39 +1,47 @@
import { useCallback } from "react"; import React, { useCallback } from "react";
import { HighlightConfig, HighlightTarget } from "@/training/TrainingInterfaces";
const HighlightContent: React.FC<{ interface HighlightedContentProps {
html: string; html: string;
highlightPhrases: string[], highlightConfigs: HighlightConfig[];
firstOccurence?: boolean contentType: HighlightTarget;
}> = ({ currentSegmentIndex?: number;
html,
highlightPhrases,
firstOccurence = false
}) => {
const createHighlightedContent = useCallback(() => {
if (highlightPhrases.length === 0) {
return { __html: html };
} }
const HighlightedContent: React.FC<HighlightedContentProps> = ({
html,
highlightConfigs,
contentType,
currentSegmentIndex
}) => {
const createHighlightedContent = useCallback(() => {
let highlightedHtml = html;
highlightConfigs.forEach(config => {
if (config.targets.includes(contentType) || config.targets.includes('all')) {
const escapeRegExp = (string: string) => { const escapeRegExp = (string: string) => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}; };
const regex = new RegExp(`(${highlightPhrases.map(escapeRegExp).join('|')})`, 'i'); const regex = new RegExp(config.phrases.map(escapeRegExp).join('|'), 'g');
const globalRegex = new RegExp(`(${highlightPhrases.map(escapeRegExp).join('|')})`, 'gi');
let highlightedHtml = html; if (contentType === 'segment' && currentSegmentIndex !== undefined) {
const segments = highlightedHtml.split('</div>');
if (firstOccurence) { segments[currentSegmentIndex] = segments[currentSegmentIndex].replace(regex, (match) => {
highlightedHtml = html.replace(regex, (match) => `<span style="background-color: yellow;">${match}</span>`); return `<span style="background-color: #FFFACD;">${match}</span>`;
});
highlightedHtml = segments.join('</div>');
} else { } else {
highlightedHtml = html.replace(globalRegex, (match) => `<span style="background-color: yellow;">${match}</span>`); highlightedHtml = highlightedHtml.replace(regex, (match) => {
return `<span style="background-color: #FFFACD;">${match}</span>`;
});
} }
}
});
return { __html: highlightedHtml }; return { __html: highlightedHtml };
}, [html, highlightPhrases, firstOccurence]); }, [html, highlightConfigs, contentType, currentSegmentIndex]);
return <div dangerouslySetInnerHTML={createHighlightedContent()} />; return <div dangerouslySetInnerHTML={createHighlightedContent()} />;
}; };
export default HighlightContent; export default HighlightedContent;

View File

@@ -65,28 +65,28 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
{ {
module: "reading", module: "reading",
icon: () => <BsBook className="h-4 w-4 text-white" />, icon: () => <BsBook className="h-4 w-4 text-white" />,
achieved: user.levels.reading >= user.desiredLevels.reading, achieved: user.levels?.reading || 0 >= user.desiredLevels?.reading || 9,
}, },
{ {
module: "listening", module: "listening",
icon: () => <BsHeadphones className="h-4 w-4 text-white" />, icon: () => <BsHeadphones className="h-4 w-4 text-white" />,
achieved: user.levels.listening >= user.desiredLevels.listening, achieved: user.levels?.listening || 0 >= user.desiredLevels?.listening || 9,
}, },
{ {
module: "writing", module: "writing",
icon: () => <BsPen className="h-4 w-4 text-white" />, icon: () => <BsPen className="h-4 w-4 text-white" />,
achieved: user.levels.writing >= user.desiredLevels.writing, achieved: user.levels?.writing || 0 >= user.desiredLevels?.writing || 9,
}, },
{ {
module: "speaking", module: "speaking",
icon: () => <BsMegaphone className="h-4 w-4 text-white" />, icon: () => <BsMegaphone className="h-4 w-4 text-white" />,
achieved: user.levels.speaking >= user.desiredLevels.speaking, achieved: user.levels?.speaking || 0 >= user.desiredLevels?.speaking || 9,
}, },
{ {
module: "level", module: "level",
icon: () => <BsClipboard className="h-4 w-4 text-white" />, icon: () => <BsClipboard className="h-4 w-4 text-white" />,
achieved: user.levels.level >= user.desiredLevels.level, achieved: user.levels?.level || 0 >= user.desiredLevels?.level || 9,
}, },
]; ];

View File

@@ -1,90 +1,42 @@
import React, { useState, useCallback } from "react"; import React from "react";
import ExerciseWalkthrough from "@/training/ExerciseWalkthrough"; import ExerciseWalkthrough from "@/training/ExerciseWalkthrough";
import { ITrainingTip, WalkthroughConfigs } from "./TrainingInterfaces"; import { ITrainingTip, WalkthroughConfigs } from "./TrainingInterfaces";
import formatTip from "./FormatTip";
// This wrapper is just to test new exercises from the handbook, will be removed when all the tips and exercises are in firestore // This wrapper is just to test new exercises from the handbook, will be removed when all the tips and exercises are in firestore
const TrainingExercise: React.FC<ITrainingTip> = (trainingTip: ITrainingTip) => { const TrainingExercise: React.FC<ITrainingTip> = (trainingTip: ITrainingTip) => {
const leftText = "<div class=\"container mx-auto px-4 overflow-x-auto\"><table class=\"min-w-full bg-white border border-gray-300\"><thead><tr class=\"bg-gray-100\"><th class=\"py-2 px-4 border-b font-semibold text-left\">Category</th><th class=\"py-2 px-4 border-b font-semibold text-left\">Option A</th><th class=\"py-2 px-4 border-b font-semibold text-left\">Option B</th></tr></thead><tbody><tr><td class=\"py-2 px-4 border-b font-medium\">Self</td><td class=\"py-2 px-4 border-b\">You need to take care of yourself and connect with the people around you.</td><td class=\"py-2 px-4 border-b\">Focus on your interests and talents and meet people who are like you.</td></tr><tr class=\"bg-gray-50\"><td class=\"py-2 px-4 border-b font-medium\">Home</td><td class=\"py-2 px-4 border-b\">It's a good idea to paint your living room yellow.</td><td class=\"py-2 px-4 border-b\">You should arrange your home so that it makes you feel happy.</td></tr><tr><td class=\"py-2 px-4 border-b font-medium\">Financial Life</td><td class=\"py-2 px-4 border-b\">You can be happy if you have enough money, but don't want money too much.</td><td class=\"py-2 px-4 border-b\">If you waste money on things you don't need, you won't have enough money for things that you do need.</td></tr><tr class=\"bg-gray-50\"><td class=\"py-2 px-4 border-b font-medium\">Social Life</td><td class=\"py-2 px-4 border-b\">A good group of friends can increase your happiness.</td><td class=\"py-2 px-4 border-b\">Researchers say that a happy friend can increase our mood by nine percent.</td></tr><tr><td class=\"py-2 px-4 border-b font-medium\">Workplace</td><td class=\"py-2 px-4 border-b\">You spend a lot of time at work, so you should like your workplace.</td><td class=\"py-2 px-4 border-b\">Your boss needs to be someone you enjoy working for.</td></tr><tr class=\"bg-gray-50\"><td class=\"py-2 px-4 border-b font-medium\">Community</td><td class=\"py-2 px-4 border-b\">The place where you live is more important for happiness than anything else.</td><td class=\"py-2 px-4 border-b\">Live around people who have the same amount of money as you do.</td></tr></tbody></table></div>";
const tip = { const tip = {
category: "Strategy", "category": "",
body: "<p>Look for <b>clues to the main idea</b> in the first (and sometimes second) sentence of a paragraph.</p>" "embedding": "",
"text": "",
"html": "",
"id": "",
"verified": true,
"standalone": false,
"exercise": {
"question": "",
"additional": "",
"segments": []
} }
const question = "<div class=\"container mx-auto px-4 py-8\"><h2 class=\"text-2xl font-bold mb-4\">Identifying Main Ideas</h2><p class=\"text-lg leading-relaxed mb-6\">Read the statements below. Circle the main idea in each pair of statements (a or b).</p></div>";
const rightTextData: WalkthroughConfigs[] = [
{
"html": "<div class='bg-blue-100 p-4 rounded-lg mb-4'><h2 class='text-xl font-bold mb-2'>Identifying Main Ideas</h2><p class='text-gray-700 leading-relaxed'>Let's analyze each pair of statements to determine which one represents the main idea. We'll focus on which statement is more general and encompasses the overall concept.</p></div>",
"wordDelay": 200,
"holdDelay": 5000,
"highlight": []
},
{
"html": "<div class='bg-green-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>1. Self</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You need to take care of yourself and connect with the people around you.</b></p><p class='mt-2'>This statement is more comprehensive, covering both self-care and social connections. Option B is more specific and could be considered a subset of A.</p></div>",
"wordDelay": 200,
"holdDelay": 8000,
"highlight": ["You need to take care of yourself and connect with the people around you."]
},
{
"html": "<div class='bg-yellow-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>2. Home</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>B. You should arrange your home so that it makes you feel happy.</b></p><p class='mt-2'>This statement is more general and applies to the entire home. Option A is a specific example that could fall under this broader concept.</p></div>",
"wordDelay": 200,
"holdDelay": 8000,
"highlight": ["You should arrange your home so that it makes you feel happy."]
},
{
"html": "<div class='bg-pink-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>3. Financial Life</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You can be happy if you have enough money, but don't want money too much.</b></p><p class='mt-2'>This statement provides a balanced view of money's role in happiness. Option B is more specific and could be seen as a consequence of wanting money too much.</p></div>",
"wordDelay": 200,
"holdDelay": 8000,
"highlight": ["You can be happy if you have enough money, but don't want money too much."]
},
{
"html": "<div class='bg-purple-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>4. Social Life</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. A good group of friends can increase your happiness.</b></p><p class='mt-2'>This statement is more general about the impact of friendships. Option B provides a specific statistic that supports this main idea.</p></div>",
"wordDelay": 200,
"holdDelay": 8000,
"highlight": ["A good group of friends can increase your happiness."]
},
{
"html": "<div class='bg-indigo-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>5. Workplace</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You spend a lot of time at work, so you should like your workplace.</b></p><p class='mt-2'>This statement covers the overall importance of workplace satisfaction. Option B focuses on one specific aspect (the boss) and is less comprehensive.</p></div>",
"wordDelay": 200,
"holdDelay": 8000,
"highlight": ["You spend a lot of time at work, so you should like your workplace."]
},
{
"html": "<div class='bg-red-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>6. Community</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. The place where you live is more important for happiness than anything else.</b></p><p class='mt-2'>While this statement might be debatable, it's more general and encompasses the overall importance of community. Option B is a specific suggestion about community demographics.</p></div>",
"wordDelay": 200,
"holdDelay": 8000,
"highlight": ["The place where you live is more important for happiness than anything else."]
},
{
"html": "<div class='bg-orange-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>Key Strategy</h3><p class='text-gray-700 leading-relaxed'>When identifying main ideas:</p><ul class='list-disc pl-5 space-y-2'><li>Look for broader, more encompassing statements</li><li>Consider which statement other ideas could fall under</li><li>Identify which statement provides a general principle rather than a specific example</li></ul></div>",
"wordDelay": 200,
"holdDelay": 8000,
"highlight": []
},
{
"html": "<div class='bg-teal-50 p-4 rounded-lg'><h3 class='text-lg font-semibold mb-2'>Helpful Tip</h3><p class='text-gray-700 leading-relaxed'>Remember to look for clues to the main idea in the first (and sometimes second) sentence of a paragraph. In this exercise, we applied this concept to pairs of statements. This approach can help you quickly identify the central theme or main point in various types of text.</p></div>",
"wordDelay": 200,
"holdDelay": 5000,
"highlight": []
} }
]
const mockTip: ITrainingTip = { const mockTip: ITrainingTip = {
id: "some random id", id: "some random id",
tipCategory: tip.category, tipCategory: tip.category,
tipHtml: tip.body, tipHtml: tip.html,
standalone: false, standalone: tip.standalone,
exercise: { exercise: {
question: question, question: tip.exercise.question,
highlightable: leftText, additional: tip.exercise.additional,
segments: rightTextData segments: tip.exercise.segments as WalkthroughConfigs[]
} }
} }
const formattedTip = formatTip(mockTip);
return ( return (
<div className="flex flex-col p-10"> <ExerciseWalkthrough {...formatTip(trainingTip)}
<ExerciseWalkthrough {...trainingTip}
/> />
</div>
); );
} }

View File

@@ -1,19 +1,32 @@
import React, {useState, useEffect, useRef, useCallback} from "react"; import React, { useState, useEffect, useRef, useCallback } from 'react';
import {animated} from "@react-spring/web"; import { animated } from '@react-spring/web';
import { FaRegCirclePlay, FaRegCircleStop } from "react-icons/fa6"; import { FaRegCirclePlay, FaRegCircleStop } from "react-icons/fa6";
import HighlightContent from "../HighlightContent"; import HighlightContent from '../HighlightContent';
import {ITrainingTip, SegmentRef, TimelineEvent} from "./TrainingInterfaces"; import { ITrainingTip, SegmentRef, TimelineEvent, HighlightConfig, InsertHtmlConfig } from './TrainingInterfaces';
import Tip from './Tip';
interface HtmlState {
question: string;
additional: string;
walkthrough: string;
}
const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => { const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
const [isAutoPlaying, setIsAutoPlaying] = useState<boolean>(false); const [isAutoPlaying, setIsAutoPlaying] = useState<boolean>(false);
const [currentTime, setCurrentTime] = useState<number>(0); const [currentTime, setCurrentTime] = useState<number>(0);
const [walkthroughHtml, setWalkthroughHtml] = useState<string>(""); const [currentHighlightConfigs, setCurrentHighlightConfigs] = useState<HighlightConfig[]>([]);
const [highlightedPhrases, setHighlightedPhrases] = useState<string[]>([]);
const [isPlaying, setIsPlaying] = useState<boolean>(false); const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [currentSegmentIndex, setCurrentSegmentIndex] = useState<number>(0);
const timelineRef = useRef<TimelineEvent[]>([]); const timelineRef = useRef<TimelineEvent[]>([]);
const animationRef = useRef<number | null>(null); const animationRef = useRef<number | null>(null);
const segmentsRef = useRef<SegmentRef[]>([]); const segmentsRef = useRef<SegmentRef[]>([]);
const [questionHtml, setQuestionHtml] = useState(tip.exercise?.question || '');
const [additionalHtml, setAdditionalHtml] = useState(tip.exercise?.additional || '');
const [walkthroughHtml, setWalkthroughHtml] = useState<string>('');
const [htmlStates, setHtmlStates] = useState<HtmlState[]>([]);
const lastProcessedInsertTime = useRef<number>(-1);
const toggleAutoPlay = useCallback(() => { const toggleAutoPlay = useCallback(() => {
setIsAutoPlaying((prev) => { setIsAutoPlaying((prev) => {
if (!prev && currentTime === getMaxTime()) { if (!prev && currentTime === getMaxTime()) {
@@ -33,23 +46,24 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
}, []); }, []);
const getMaxTime = (): number => { const getMaxTime = (): number => {
return ( return tip.exercise?.segments.reduce((sum, segment) =>
tip.exercise?.segments.reduce((sum, segment) => sum + segment.wordDelay * segment.html.split(/\s+/).length + segment.holdDelay, 0) ?? 0 sum + segment.wordDelay * segment.html.split(/\s+/).length + segment.holdDelay, 0
); ) ?? 0;
}; };
useEffect(() => { useEffect(() => {
const timeline: TimelineEvent[] = []; const timeline: TimelineEvent[] = [];
let currentTimePosition = 0; let currentTimePosition = 0;
segmentsRef.current = []; segmentsRef.current = [];
const newHtmlStates: HtmlState[] = [];
tip.exercise?.segments.forEach((segment, index) => { tip.exercise?.segments.forEach((segment, index) => {
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(segment.html, "text/html"); const doc = parser.parseFromString(segment.html, 'text/html');
const words: string[] = []; const words: string[] = [];
const walkTree = (node: Node) => { const walkTree = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) { if (node.nodeType === Node.TEXT_NODE) {
words.push(...(node.textContent?.split(/\s+/).filter((word) => word.length > 0) || [])); words.push(...(node.textContent?.split(/\s+/).filter(word => word.length > 0) || []));
} else if (node.nodeType === Node.ELEMENT_NODE) { } else if (node.nodeType === Node.ELEMENT_NODE) {
Array.from(node.childNodes).forEach(walkTree); Array.from(node.childNodes).forEach(walkTree);
} }
@@ -62,59 +76,108 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
...segment, ...segment,
words: words, words: words,
startTime: currentTimePosition, startTime: currentTimePosition,
endTime: currentTimePosition + textDuration, endTime: currentTimePosition + textDuration
}); });
timeline.push({ timeline.push({
type: "text", type: 'text',
start: currentTimePosition, start: currentTimePosition,
end: currentTimePosition + textDuration, end: currentTimePosition + textDuration,
segmentIndex: index, segmentIndex: index
}); });
currentTimePosition += textDuration; currentTimePosition += textDuration;
timeline.push({ timeline.push({
type: "highlight", type: 'highlight',
start: currentTimePosition, start: currentTimePosition,
end: currentTimePosition + segment.holdDelay, end: currentTimePosition + segment.holdDelay,
content: segment.highlight, content: segment.highlight,
segmentIndex: index, segmentIndex: index
}); });
if (segment.insertHTML && segment.insertHTML.length > 0) {
newHtmlStates.push({
question: questionHtml,
additional: additionalHtml,
walkthrough: walkthroughHtml
});
timeline.push({
type: 'insert',
start: currentTimePosition,
end: currentTimePosition + segment.holdDelay,
segmentIndex: index,
content: segment.insertHTML
});
}
currentTimePosition += segment.holdDelay; currentTimePosition += segment.holdDelay;
}); });
for (let i = 0; i < timeline.length; i++) {
if (timeline[i].type === 'insert') {
const nextInsertIndex = timeline.findIndex((event, index) => index > i && event.type === 'insert');
if (nextInsertIndex !== -1) {
timeline[i].end = timeline[nextInsertIndex].start;
} else {
timeline[i].end = currentTimePosition;
}
}
}
timelineRef.current = timeline; timelineRef.current = timeline;
}, [tip.exercise?.segments]); setHtmlStates(newHtmlStates);
}, [tip.exercise?.segments, questionHtml, additionalHtml, walkthroughHtml]);
const updateText = useCallback(() => { const updateText = useCallback(() => {
const currentEvent = timelineRef.current.find((event) => currentTime >= event.start && currentTime < event.end); const currentEvents = timelineRef.current.filter(
event => currentTime >= event.start && currentTime <= event.end
);
if (currentEvent) { if (currentTime < lastProcessedInsertTime.current) {
if (currentEvent.type === "text") { const lastInsertEvent = timelineRef.current
.filter(event => event.type === 'insert' && event.start <= currentTime)
.pop();
if (lastInsertEvent) {
const stateIndex = timelineRef.current.indexOf(lastInsertEvent);
if (stateIndex >= 0 && stateIndex < htmlStates.length) {
const previousState = htmlStates[stateIndex];
setQuestionHtml(previousState.question);
setAdditionalHtml(previousState.additional);
setWalkthroughHtml(previousState.walkthrough);
}
} else {
// If no previous insert event, revert to initial state
setQuestionHtml(tip.exercise?.question || '');
setAdditionalHtml(tip.exercise?.additional || '');
setWalkthroughHtml('');
}
}
currentEvents.forEach(currentEvent => {
if (currentEvent.type === 'text') {
const segment = segmentsRef.current[currentEvent.segmentIndex]; const segment = segmentsRef.current[currentEvent.segmentIndex];
const elapsedTime = currentTime - currentEvent.start; const elapsedTime = currentTime - currentEvent.start;
const wordsToShow = Math.min(Math.floor(elapsedTime / segment.wordDelay), segment.words.length); const wordsToShow = Math.min(Math.floor(elapsedTime / segment.wordDelay), segment.words.length);
const previousSegmentsHtml = segmentsRef.current const previousSegmentsHtml = segmentsRef.current
.slice(0, currentEvent.segmentIndex) .slice(0, currentEvent.segmentIndex)
.map((seg) => seg.html) .map(seg => seg.html)
.join(""); .join('');
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(segment.html, "text/html"); const doc = parser.parseFromString(segment.html, 'text/html');
let wordCount = 0; let wordCount = 0;
const walkTree = (node: Node, action: (node: Node) => void): boolean => { const walkTree = (node: Node, action: (node: Node) => void): boolean => {
if (node.nodeType === Node.TEXT_NODE && node.textContent) { if (node.nodeType === Node.TEXT_NODE && node.textContent) {
const words = node.textContent.split(/(\s+)/).filter((word) => word.length > 0); const words = node.textContent.split(/(\s+)/).filter(word => word.length > 0);
if (wordCount + words.filter((w) => !/\s+/.test(w)).length <= wordsToShow) { if (wordCount + words.filter(w => !/\s+/.test(w)).length <= wordsToShow) {
action(node.cloneNode(true)); action(node.cloneNode(true));
wordCount += words.filter((w) => !/\s+/.test(w)).length; wordCount += words.filter(w => !/\s+/.test(w)).length;
} else { } else {
const remainingWords = wordsToShow - wordCount; const remainingWords = wordsToShow - wordCount;
const newTextContent = words.reduce( const newTextContent = words.reduce((acc, word) => {
(acc, word) => {
if (!/\s+/.test(word) && acc.nonSpaceWords < remainingWords) { if (!/\s+/.test(word) && acc.nonSpaceWords < remainingWords) {
acc.text += word; acc.text += word;
acc.nonSpaceWords++; acc.nonSpaceWords++;
@@ -122,9 +185,7 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
acc.text += word; acc.text += word;
} }
return acc; return acc;
}, }, { text: '', nonSpaceWords: 0 }).text;
{text: "", nonSpaceWords: 0},
).text;
const newNode = node.cloneNode(false); const newNode = node.cloneNode(false);
newNode.textContent = newTextContent; newNode.textContent = newTextContent;
action(newNode); action(newNode);
@@ -133,38 +194,79 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
} else if (node.nodeType === Node.ELEMENT_NODE) { } else if (node.nodeType === Node.ELEMENT_NODE) {
const clone = node.cloneNode(false); const clone = node.cloneNode(false);
action(clone); action(clone);
Array.from(node.childNodes).some((child) => { Array.from(node.childNodes).some(child => {
return walkTree(child, (childNode) => (clone as Node).appendChild(childNode)); return walkTree(child, childNode => (clone as Node).appendChild(childNode));
}); });
} }
return wordCount >= wordsToShow; return wordCount >= wordsToShow;
}; };
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
walkTree(doc.body, (node) => fragment.appendChild(node)); walkTree(doc.body, node => fragment.appendChild(node));
const serializer = new XMLSerializer(); const serializer = new XMLSerializer();
const currentSegmentHtml = Array.from(fragment.childNodes) const currentSegmentHtml = Array.from(fragment.childNodes)
.map((node) => serializer.serializeToString(node)) .map(node => serializer.serializeToString(node))
.join(""); .join('');
const newHtml = previousSegmentsHtml + currentSegmentHtml; const newHtml = previousSegmentsHtml + currentSegmentHtml;
setWalkthroughHtml(newHtml); setWalkthroughHtml(newHtml);
setHighlightedPhrases([]); setCurrentSegmentIndex(currentEvent.segmentIndex);
} else if (currentEvent.type === "highlight") { setCurrentHighlightConfigs([]);
} else if (currentEvent.type === 'highlight') {
const newHtml = segmentsRef.current const newHtml = segmentsRef.current
.slice(0, currentEvent.segmentIndex + 1) .slice(0, currentEvent.segmentIndex + 1)
.map((seg) => seg.html) .map(seg => seg.html)
.join(""); .join('');
setWalkthroughHtml(newHtml); setWalkthroughHtml(newHtml);
setHighlightedPhrases(currentEvent.content || []); setCurrentSegmentIndex(currentEvent.segmentIndex);
setCurrentHighlightConfigs(currentEvent.content as HighlightConfig[] || []);
} else if (currentEvent.type === 'insert') {
const insertConfigs = currentEvent.content as InsertHtmlConfig[];
insertConfigs.forEach(config => {
switch (config.target) {
case 'question':
setQuestionHtml(prevHtml => insertHtmlContent(prevHtml, config));
break;
case 'additional':
setAdditionalHtml(prevHtml => insertHtmlContent(prevHtml, config));
break;
case 'segment':
setWalkthroughHtml(prevHtml => insertHtmlContent(prevHtml, config));
break;
} }
});
lastProcessedInsertTime.current = currentTime;
} }
}, [currentTime]); });
}, [currentTime, htmlStates, tip.exercise?.question, tip.exercise?.additional]);
useEffect(() => { useEffect(() => {
updateText(); updateText();
}, [currentTime, updateText]); }, [currentTime, updateText]);
const insertHtmlContent = (prevHtml: string, config: InsertHtmlConfig): string => {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = prevHtml;
const targetElement = tempDiv.querySelector(`#${config.targetId}`);
if (targetElement) {
switch (config.position) {
case 'append':
targetElement.insertAdjacentHTML('beforeend', config.html);
break;
case 'prepend':
targetElement.insertAdjacentHTML('afterbegin', config.html);
break;
case 'replace':
targetElement.innerHTML = config.html;
break;
}
}
return tempDiv.innerHTML;
};
useEffect(() => { useEffect(() => {
if (isAutoPlaying) { if (isAutoPlaying) {
const lastEvent = timelineRef.current[timelineRef.current.length - 1]; const lastEvent = timelineRef.current[timelineRef.current.length - 1];
@@ -219,31 +321,22 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
} }
}; };
if (tip.standalone || !tip.exercise) {
return ( return (
<div className="container mx-auto"> <div className="container mx-auto py-6">
<h1 className="text-xl font-bold text-red-600">The exercise for this tip is not available yet!</h1> <Tip category={tip.tipCategory} html={tip.tipHtml} />
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4 mt-10"> {!tip.standalone && (
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3> <div className='flex flex-col space-y-4'>
<div className="text-gray-700" dangerouslySetInnerHTML={{__html: tip.tipHtml}} /> <div className='flex flex-row items-center space-x-4 py-4'>
</div>
</div>
);
}
return (
<div className="container mx-auto">
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4">
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
<div className="text-gray-700" dangerouslySetInnerHTML={{__html: tip.tipHtml}} />
</div>
<div className="flex flex-col space-y-4">
<div className="flex flex-row items-center space-x-4 py-4">
<button <button
onClick={toggleAutoPlay} onClick={toggleAutoPlay}
className="p-2 bg-blue-500 text-white rounded-full transition-colors duration-200 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50" className="p-2 bg-blue-500 text-white rounded-full transition-colors duration-200 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
aria-label={isAutoPlaying ? "Pause" : "Play"}> aria-label={isAutoPlaying ? 'Pause' : 'Play'}
{isAutoPlaying ? <FaRegCircleStop className="w-6 h-6" /> : <FaRegCirclePlay className="w-6 h-6" />} >
{isAutoPlaying ? (
<FaRegCircleStop className="w-6 h-6" />
) : (
<FaRegCirclePlay className="w-6 h-6" />
)}
</button> </button>
<input <input
type="range" type="range"
@@ -255,25 +348,53 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
onMouseUp={handleSliderMouseUp} onMouseUp={handleSliderMouseUp}
onTouchStart={handleSliderMouseDown} onTouchStart={handleSliderMouseDown}
onTouchEnd={handleSliderMouseUp} onTouchEnd={handleSliderMouseUp}
className="flex-grow" className='flex-grow'
/> />
</div> </div>
<div className="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4"> <div className='flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 w-full'>
<div className="flex-1 bg-white p-6 rounded-lg shadow"> <div className='flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 w-full'>
{/*<h2 className="text-xl font-bold mb-4">Question</h2>*/} <div className='flex-1 bg-white p-6 rounded-lg shadow space-y-6'>
<div className="mb-4" dangerouslySetInnerHTML={{__html: tip.exercise.question}} /> <div className="container mx-auto px-4">
<HighlightContent html={tip.exercise.highlightable} highlightPhrases={highlightedPhrases} /> <div id="question-container" className="border p-6 rounded-lg shadow-md">
<HighlightContent
html={questionHtml}
highlightConfigs={currentHighlightConfigs}
contentType="question"
/>
</div> </div>
<div className="flex-1"> </div>
<div className="bg-gray-50 rounded-lg shadow"> {tip.exercise?.additional && (<div className="container mx-auto px-4">
<div className="p-6 space-y-4"> <div id="additional-container" className="border p-6 rounded-lg shadow-md">
<animated.div dangerouslySetInnerHTML={{__html: walkthroughHtml}} /> <HighlightContent
html={additionalHtml}
highlightConfigs={currentHighlightConfigs}
contentType="additional"
/>
</div>
</div>
)}
</div>
<div className='flex-1'>
<div className='bg-gray-50 rounded-lg shadow'>
<div id="segment-container" className='p-6 space-y-4'>
<animated.div>
<HighlightContent
html={walkthroughHtml}
highlightConfigs={currentHighlightConfigs.filter(config =>
config.targets.includes('segment') || config.targets.includes('all')
)}
contentType="segment"
currentSegmentIndex={currentSegmentIndex}
/>
</animated.div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
)}
</div>
); );
}; };

View File

@@ -0,0 +1,201 @@
import { ITrainingTip, WalkthroughConfigs } from "./TrainingInterfaces";
const colorOptions = [
'red', 'blue', 'green', 'purple', 'pink', 'indigo', 'teal', 'orange', 'cyan', 'emerald', 'sky', 'violet', 'fuchsia', 'rose', 'lime', 'slate'
]
const getRandomColors = (count: number) => {
const shuffled = [...colorOptions].sort(() => 0.5 - Math.random());
return shuffled.slice(0, count);
};
const classMap = {
"mainDiv": {
"tip": "flex-col gap-2",
"question": "flex-col gap-2",
"additional": "flex-col gap-8",
"segment": "p-4 rounded-lg mb-4 flex flex-col gap-2"
},
"h2": {
"tip": "mb-4 font-semibold text-lg",
"question": "text-lg font-semibold mb-4",
"additional": "text-2xl font-semibold mb-4",
"segment": "text-xl font-semibold"
}
}
const setClass = (element: Element, style: string) => {
element.setAttribute('class', style)
}
// DON'T OVERRIDE DIV AND SPAN STYLES
const processHtml = (section: string, html: string, color: string) => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const mainDiv = doc.body.firstElementChild;
if (mainDiv && mainDiv.tagName === 'DIV') {
if (section === "segment") {
setClass(mainDiv, `bg-${color}-100 ${classMap["mainDiv"][section]}`);
} else {
setClass(mainDiv, classMap["mainDiv"][section as keyof typeof classMap["mainDiv"]]);
}
}
doc.querySelectorAll('h1').forEach(e => {
if (section === "additional") {
setClass(e, 'text-4xl font-bold mb-6')
} else {
setClass(e, 'text-xl font-semibold mb-4');
}
});
doc.querySelectorAll('h2').forEach(e => {
setClass(e, classMap["h2"][section as keyof typeof classMap["h2"]])
});
doc.querySelectorAll('h3').forEach(e => {
e.setAttribute('class', 'text-lg font-semibold mb-4')
})
doc.querySelectorAll('p').forEach(e => {
if (section === "segment") {
setClass(e, 'text-gray-700 leading-relaxed')
} else {
setClass(e, 'mb-4');
}
});
doc.querySelectorAll('label').forEach(e => {
if (section === "additional") {
setClass(e, 'font-semibold');
} else {
setClass(e, 'min-w-[16px] mr-1 font-semibold');
}
});
doc.querySelectorAll('ul').forEach(e => {
const hasLabel = Array.from(e.querySelectorAll('li')).some(li => li.querySelector('label'));
if (hasLabel) {
e.setAttribute('class', 'list-none space-y-2');
} else {
e.setAttribute('class', `list-disc pl-5 space-y-2`);
}
});
doc.querySelectorAll('ol').forEach(e => {
e.setAttribute('class', 'list-decimal pl-5 space-y-2');
})
doc.querySelectorAll('hz-row').forEach(e => {
e.setAttribute('class', `flex flex-row items-center mb-4 gap-2`);
})
if (section === "segment") {
doc.querySelectorAll('b').forEach(e => {
e.setAttribute('class', `text-${color}-700`);
});
}
doc.querySelectorAll('section').forEach(e => {
e.setAttribute('class', `mb-8`);
});
doc.querySelectorAll('option-box').forEach(e => {
e.setAttribute('class', `flex justify-center min-w-[32px] min-h-6 bg-gray-200 rounded`);
});
doc.querySelectorAll('option-box-grow').forEach(e => {
e.setAttribute('class', 'flex flex-grow ml-2 w-10 min-h-6 bg-gray-200 rounded px-4 py-2');
})
doc.querySelectorAll('option-box-blank').forEach(e => {
e.setAttribute('class', 'min-w-[32px] min-h-[32px] border border-gray-300 text-center mr-3 flex justify-center items-center');
})
doc.querySelectorAll('option-card').forEach(e => {
e.setAttribute('class', 'bg-gray-100 rounded-lg flex flex-col p-4')
})
doc.querySelectorAll('footer').forEach(e => {
e.setAttribute('class', `flex flex-col gap-2 text-sm`);
});
doc.querySelectorAll('single-line').forEach(e => {
e.setAttribute('class', `border-b border-black w-full h-4 inline-block`);
})
doc.querySelectorAll('padded-line').forEach(e => {
e.setAttribute('class', `my-2 inline-block w-full`);
})
doc.querySelectorAll('table').forEach(table => {
table.setAttribute('class', 'min-w-full bg-white border border-gray-300')
table.querySelectorAll('thead tr').forEach(tr => {
tr.setAttribute('class', 'bg-gray-100');
});
table.querySelectorAll('th').forEach(th => {
th.setAttribute('class', 'py-2 px-4 border-b font-semibold text-left');
});
table.querySelectorAll('tbody tr').forEach((tr, index) => {
if (index % 2 === 1) {
tr.setAttribute('class', 'bg-gray-50');
}
});
table.querySelectorAll('td').forEach(td => {
if (td === td.parentElement?.firstElementChild) {
td.setAttribute('class', 'py-2 px-4 border-b font-medium');
} else {
td.setAttribute('class', 'py-2 px-4 border-b');
}
});
});
doc.querySelectorAll('blockquote').forEach(e => {
setClass(e, `flex w-full justify-center ${section === "segment" ? "" : "mb-4"}`)
})
doc.querySelectorAll('items-between').forEach(e => {
setClass(e, 'flex flex-row justify-between mb-4')
})
return doc.body.innerHTML;
}
const formatTip = (tip: ITrainingTip): ITrainingTip => {
if (tip.exercise && tip.exercise.segments) {
const colors = getRandomColors(tip.exercise.segments.length);
const processedSegments: WalkthroughConfigs[] = tip.exercise.segments.map((segment, index) => ({
...segment,
html: processHtml("segment", segment.html, colors[index])
}));
return {
id: tip.id,
tipCategory: tip.tipCategory,
tipHtml: processHtml("tip", tip.tipHtml, ""),
standalone: tip.standalone,
exercise: {
question: processHtml("question", tip.exercise.question, ""),
additional: tip.exercise.additional ? processHtml("additional", tip.exercise.additional, "") : undefined,
segments: processedSegments
}
};
}
return {
id: tip.id,
tipCategory: tip.tipCategory,
tipHtml: processHtml("tip", tip.tipHtml, ""),
standalone: tip.standalone,
exercise: undefined
};
};
export default formatTip;

View File

@@ -0,0 +1,83 @@
import React, { useMemo } from 'react';
import { FaChessKnight, FaLink, FaPen } from 'react-icons/fa';
import { IoLanguage } from 'react-icons/io5';
import { MdOutlineCategory } from 'react-icons/md';
import { GiSkills } from 'react-icons/gi';
import { BiBookReader } from 'react-icons/bi';
type CategoryConfig = {
[key: string]: {
headerColor: string;
bodyColor: string;
textColor: string;
icon: any;
}
};
const categoryConfig : CategoryConfig = {
'Strategy': {
headerColor: 'bg-yellow-400',
bodyColor: 'bg-yellow-100',
textColor: 'text-yellow-900',
icon: FaChessKnight
},
'Word Partners': {
headerColor: 'bg-purple-700',
bodyColor: 'bg-purple-200',
textColor: 'text-purple-900',
icon: MdOutlineCategory
},
'Word Link': {
headerColor: 'bg-green-600',
bodyColor: 'bg-green-100',
textColor: 'text-green-900',
icon: FaLink
},
'CT Focus': {
headerColor: 'bg-purple-700',
bodyColor: 'bg-purple-200',
textColor: 'text-purple-900',
icon: GiSkills
},
'Reading Skill': {
headerColor: 'bg-orange-200',
bodyColor: 'bg-orange-100',
textColor: 'text-orange-900',
icon: BiBookReader
},
'Language for Writing': {
headerColor: 'bg-orange-200',
bodyColor: 'bg-orange-100',
textColor: 'text-orange-900',
icon: IoLanguage
},
'Writing Skill': {
headerColor: 'bg-orange-200',
bodyColor: 'bg-orange-100',
textColor: 'text-orange-900',
icon: FaPen
}
};
const Tip: React.FC<{ category: string; html: string }> = ({ category, html }) => {
const { headerColor, bodyColor, textColor, icon: Icon } = useMemo(() =>
categoryConfig[category] || categoryConfig['Strategy'], [category]
);
return (
<div className="rounded-lg overflow-hidden shadow-md mb-4">
<div className={`px-4 py-3 ${headerColor}`}>
<h2 className="font-bold text-white text-xl flex items-center">
<Icon className="ml-2 mr-2" size={24} />
{category === "CT Focus" ? "Critical Thinking" : category}
</h2>
</div>
<div className={`p-6 ${bodyColor}`}>
<p className={`text-lg ${textColor}`} dangerouslySetInnerHTML={{ __html: html }} />
</div>
</div>
);
};
export default Tip;

View File

@@ -29,7 +29,7 @@ export interface ITrainingTip {
standalone: boolean; standalone: boolean;
exercise?: { exercise?: {
question: string; question: string;
highlightable: string; additional?: string;
segments: WalkthroughConfigs[] segments: WalkthroughConfigs[]
} }
} }
@@ -38,16 +38,31 @@ export interface WalkthroughConfigs {
html: string; html: string;
wordDelay: number; wordDelay: number;
holdDelay: number; holdDelay: number;
highlight: string[]; highlight?: HighlightConfig[];
insertHTML?: InsertHtmlConfig[];
}
export type HighlightTarget = 'question' | 'additional' | 'segment' | 'all';
export interface HighlightConfig {
targets: HighlightTarget[];
phrases: string[];
}
export interface InsertHtmlConfig {
target: 'question' | 'additional' | 'segment';
targetId: string;
html: string;
position: 'append' | 'prepend' | 'replace';
} }
export interface TimelineEvent { export interface TimelineEvent {
type: 'text' | 'highlight'; type: 'text' | 'highlight' | 'insert';
start: number; start: number;
end: number; end: number;
segmentIndex: number; segmentIndex: number;
content?: string[]; content?: HighlightConfig[] | InsertHtmlConfig[];
} }
export interface SegmentRef extends WalkthroughConfigs { export interface SegmentRef extends WalkthroughConfigs {

View File

@@ -36,8 +36,7 @@ export default function AdminDashboard({user}: Props) {
const [selectedUser, setSelectedUser] = useState<User>(); const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const {data: stats} = useFilterRecordsByUser<Stat[]>(user.id); const {users, reload, isLoading} = useUsers();
const {users, reload} = useUsers();
const {groups} = useGroups({}); const {groups} = useGroups({});
const {pending, done} = usePaymentStatusUsers(); const {pending, done} = usePaymentStatusUsers();
@@ -280,6 +279,7 @@ export default function AdminDashboard({user}: Props) {
<section className="w-full grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 place-items-center items-center justify-between"> <section className="w-full grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 place-items-center items-center justify-between">
<IconCard <IconCard
Icon={BsPersonFill} Icon={BsPersonFill}
isLoading={isLoading}
label="Students" label="Students"
value={users.filter((x) => x.type === "student").length} value={users.filter((x) => x.type === "student").length}
onClick={() => router.push("/#students")} onClick={() => router.push("/#students")}
@@ -287,6 +287,7 @@ export default function AdminDashboard({user}: Props) {
/> />
<IconCard <IconCard
Icon={BsPencilSquare} Icon={BsPencilSquare}
isLoading={isLoading}
label="Teachers" label="Teachers"
value={users.filter((x) => x.type === "teacher").length} value={users.filter((x) => x.type === "teacher").length}
onClick={() => router.push("/#teachers")} onClick={() => router.push("/#teachers")}
@@ -294,6 +295,7 @@ export default function AdminDashboard({user}: Props) {
/> />
<IconCard <IconCard
Icon={BsBank} Icon={BsBank}
isLoading={isLoading}
label="Corporate" label="Corporate"
value={users.filter((x) => x.type === "corporate").length} value={users.filter((x) => x.type === "corporate").length}
onClick={() => router.push("/#corporate")} onClick={() => router.push("/#corporate")}
@@ -301,6 +303,7 @@ export default function AdminDashboard({user}: Props) {
/> />
<IconCard <IconCard
Icon={BsBriefcaseFill} Icon={BsBriefcaseFill}
isLoading={isLoading}
label="Country Managers" label="Country Managers"
value={users.filter((x) => x.type === "agent").length} value={users.filter((x) => x.type === "agent").length}
onClick={() => router.push("/#agents")} onClick={() => router.push("/#agents")}
@@ -308,6 +311,7 @@ export default function AdminDashboard({user}: Props) {
/> />
<IconCard <IconCard
Icon={BsGlobeCentralSouthAsia} Icon={BsGlobeCentralSouthAsia}
isLoading={isLoading}
label="Countries" label="Countries"
value={[...new Set(users.filter((x) => x.demographicInformation).map((x) => x.demographicInformation?.country))].length} value={[...new Set(users.filter((x) => x.demographicInformation).map((x) => x.demographicInformation?.country))].length}
color="purple" color="purple"
@@ -315,6 +319,7 @@ export default function AdminDashboard({user}: Props) {
<IconCard <IconCard
onClick={() => router.push("/#inactiveStudents")} onClick={() => router.push("/#inactiveStudents")}
Icon={BsPersonFill} Icon={BsPersonFill}
isLoading={isLoading}
label="Inactive Students" label="Inactive Students"
value={ value={
users.filter((x) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate))) users.filter((x) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
@@ -325,6 +330,7 @@ export default function AdminDashboard({user}: Props) {
<IconCard <IconCard
onClick={() => router.push("/#inactiveCountryManagers")} onClick={() => router.push("/#inactiveCountryManagers")}
Icon={BsBriefcaseFill} Icon={BsBriefcaseFill}
isLoading={isLoading}
label="Inactive Country Managers" label="Inactive Country Managers"
value={users.filter(inactiveCountryManagerFilter).length} value={users.filter(inactiveCountryManagerFilter).length}
color="rose" color="rose"
@@ -332,6 +338,7 @@ export default function AdminDashboard({user}: Props) {
<IconCard <IconCard
onClick={() => router.push("/#inactiveCorporate")} onClick={() => router.push("/#inactiveCorporate")}
Icon={BsBank} Icon={BsBank}
isLoading={isLoading}
label="Inactive Corporate" label="Inactive Corporate"
value={ value={
users.filter((x) => x.type === "corporate" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate))) users.filter((x) => x.type === "corporate" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
@@ -342,6 +349,7 @@ export default function AdminDashboard({user}: Props) {
<IconCard <IconCard
onClick={() => router.push("/#paymentdone")} onClick={() => router.push("/#paymentdone")}
Icon={BsCurrencyDollar} Icon={BsCurrencyDollar}
isLoading={isLoading}
label="Payment Done" label="Payment Done"
value={done.length} value={done.length}
color="purple" color="purple"
@@ -349,6 +357,7 @@ export default function AdminDashboard({user}: Props) {
<IconCard <IconCard
onClick={() => router.push("/#paymentpending")} onClick={() => router.push("/#paymentpending")}
Icon={BsCurrencyDollar} Icon={BsCurrencyDollar}
isLoading={isLoading}
label="Pending Payment" label="Pending Payment"
value={pending.length} value={pending.length}
color="rose" color="rose"
@@ -356,12 +365,14 @@ export default function AdminDashboard({user}: Props) {
<IconCard <IconCard
onClick={() => router.push("https://cms.encoach.com/admin")} onClick={() => router.push("https://cms.encoach.com/admin")}
Icon={BsLayoutSidebar} Icon={BsLayoutSidebar}
isLoading={isLoading}
label="Content Management System (CMS)" label="Content Management System (CMS)"
color="green" color="green"
/> />
<IconCard <IconCard
onClick={() => router.push("/#corporatestudentslevels")} onClick={() => router.push("/#corporatestudentslevels")}
Icon={BsPersonFill} Icon={BsPersonFill}
isLoading={isLoading}
label="Corporate Students Levels" label="Corporate Students Levels"
color="purple" color="purple"
/> />

View File

@@ -1,510 +0,0 @@
/* eslint-disable @next/next/no-img-element */
import Modal from "@/components/Modal";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import useUsers, { userHashStudent, userHashTeacher, userHashCorporate} from "@/hooks/useUsers";
import {CorporateUser, Group, MasterCorporateUser, Stat, User } from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils";
import moment from "moment";
import {useEffect, useMemo, useState} from "react";
import {
BsArrowLeft,
BsClipboard2Data,
BsClipboard2DataFill,
BsClock,
BsGlobeCentralSouthAsia,
BsPaperclip,
BsPerson,
BsPersonAdd,
BsPersonFill,
BsPersonFillGear,
BsPersonGear,
BsPencilSquare,
BsPersonBadge,
BsPersonCheck,
BsPeople,
BsArrowRepeat,
BsPlus,
BsEnvelopePaper,
} from "react-icons/bs";
import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups";
import {averageLevelCalculator, calculateAverageLevel, calculateBandScore} from "@/utils/score";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {Module} from "@/interfaces";
import {groupByExam} from "@/utils/stats";
import IconCard from "./IconCard";
import GroupList from "@/pages/(admin)/Lists/GroupList";
import useFilterStore from "@/stores/listFilterStore";
import {useRouter} from "next/router";
import useCodes from "@/hooks/useCodes";
import {getUserCorporate} from "@/utils/groups";
import useAssignments from "@/hooks/useAssignments";
import {Assignment} from "@/interfaces/results";
import AssignmentView from "./AssignmentView";
import AssignmentCreator from "./AssignmentCreator";
import clsx from "clsx";
import AssignmentCard from "./AssignmentCard";
import {createColumnHelper} from "@tanstack/react-table";
import Checkbox from "@/components/Low/Checkbox";
import List from "@/components/List";
import {getUserCompanyName} from "@/resources/user";
import {futureAssignmentFilter, pastAssignmentFilter, archivedAssignmentFilter, activeAssignmentFilter} from "@/utils/assignments";
import useUserBalance from "@/hooks/useUserBalance";
import AssignmentsPage from "./views/AssignmentsPage";
interface Props {
user: CorporateUser;
linkedCorporate?: CorporateUser | MasterCorporateUser;
}
type StudentPerformanceItem = User & {corporateName: string; group: string};
const StudentPerformanceList = ({items, stats, users}: {items: StudentPerformanceItem[]; stats: Stat[]; users: User[]}) => {
const [isShowingAmount, setIsShowingAmount] = useState(false);
const columnHelper = createColumnHelper<StudentPerformanceItem>();
const columns = [
columnHelper.accessor("name", {
header: "Student Name",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("email", {
header: "E-mail",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("demographicInformation.passport_id", {
header: "ID",
cell: (info) => info.getValue() || "N/A",
}),
columnHelper.accessor("group", {
header: "Group",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("corporateName", {
header: "Corporate",
cell: (info) => info.getValue() || "N/A",
}),
columnHelper.accessor("levels.reading", {
header: "Reading",
cell: (info) =>
!isShowingAmount
? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "reading" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.listening", {
header: "Listening",
cell: (info) =>
!isShowingAmount
? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "listening" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.writing", {
header: "Writing",
cell: (info) =>
!isShowingAmount
? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "writing" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.speaking", {
header: "Speaking",
cell: (info) =>
!isShowingAmount
? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "speaking" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.level", {
header: "Level",
cell: (info) =>
!isShowingAmount
? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "level" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels", {
id: "overall_level",
header: "Overall",
cell: (info) =>
!isShowingAmount
? averageLevelCalculator(
users,
stats.filter((x) => x.user === info.row.original.id),
).toFixed(1)
: `${Object.keys(groupByExam(stats.filter((x) => x.user === info.row.original.id))).length} exams`,
}),
];
return (
<div className="flex flex-col gap-4 w-full h-full">
<Checkbox isChecked={isShowingAmount} onChange={setIsShowingAmount}>
Show Utilization
</Checkbox>
<List<StudentPerformanceItem>
data={items.sort(
(a, b) =>
averageLevelCalculator(
users,
stats.filter((x) => x.user === b.id),
) -
averageLevelCalculator(
users,
stats.filter((x) => x.user === a.id),
),
)}
columns={columns}
/>
</div>
);
};
export default function CorporateDashboard({user, linkedCorporate}: Props) {
const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false);
const {data: stats} = useFilterRecordsByUser<Stat[]>();
const {groups} = useGroups({admin: user.id});
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id});
const {balance} = useUserBalance();
const {users: students, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(userHashStudent);
const {users: teachers, reload: reloadTeachers, isLoading: isTeachersLoading} = useUsers(userHashTeacher);
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter();
const assignmentsGroups = useMemo(() => groups.filter((x) => x.admin === user.id || x.participants.includes(user.id)), [groups, user.id]);
const assignmentsUsers = useMemo(
() =>
[...teachers, ...students].filter((x) =>
!!selectedUser
? groups
.filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants)
.includes(x.id) || false
: groups.flatMap((g) => g.participants).includes(x.id),
),
[groups, teachers, students, selectedUser],
);
useEffect(() => {
setShowModal(!!selectedUser && router.asPath === "/#");
}, [selectedUser, router.asPath]);
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
const UserDisplay = (displayUser: User) => (
<div
onClick={() => setSelectedUser(displayUser)}
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
<div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
const GroupsList = () => {
const filter = (x: Group) => x.admin === user.id || x.participants.includes(user.id);
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Groups ({groups.filter(filter).length})</h2>
</div>
<GroupList user={user} />
</>
);
};
const StudentPerformancePage = () => {
const performanceStudents = students.map((u) => ({
...u,
group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A",
corporateName: getUserCompanyName(user, [], groups),
}));
return (
<>
<div className="w-full flex justify-between items-center">
<div
onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<div
onClick={reloadStudents}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<span>Reload</span>
<BsArrowRepeat className={clsx("text-xl", isStudentsLoading && "animate-spin")} />
</div>
</div>
<StudentPerformanceList items={performanceStudents} stats={stats} users={students} />
</>
);
};
const averageLevelCalculator = (studentStats: Stat[]) => {
const formattedStats = studentStats
.map((s) => ({
focus: students.find((u) => u.id === s.user)?.focus,
score: s.score,
module: s.module,
}))
.filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({
module: s.module,
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
}));
const levels: {[key in Module]: number} = {
reading: 0,
listening: 0,
writing: 0,
speaking: 0,
level: 0,
};
bandScores.forEach((b) => (levels[b.module] += b.level));
return calculateAverageLevel(levels);
};
const DefaultDashboard = () => (
<>
{!!linkedCorporate && (
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
Linked to: <b>{linkedCorporate?.corporateInformation?.companyInformation.name || linkedCorporate.name}</b>
</div>
)}
<section className="grid grid-cols-5 -md:grid-cols-2 gap-4 text-center">
<IconCard
onClick={() => router.push("/#students")}
isLoading={isStudentsLoading}
Icon={BsPersonFill}
label="Students"
value={students.length}
color="purple"
/>
<IconCard
onClick={() => router.push("/#teachers")}
isLoading={isTeachersLoading}
Icon={BsPencilSquare}
label="Teachers"
value={teachers.length}
color="purple"
/>
<IconCard
Icon={BsClipboard2Data}
label="Exams Performed"
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
color="purple"
/>
<IconCard
Icon={BsPaperclip}
isLoading={isStudentsLoading}
label="Average Level"
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
color="purple"
/>
<IconCard onClick={() => router.push("/#groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
<IconCard
Icon={BsPersonCheck}
label="User Balance"
value={`${balance}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
color="purple"
/>
<IconCard
Icon={BsClock}
label="Expiration Date"
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
color="rose"
/>
<IconCard
Icon={BsPersonFillGear}
isLoading={isStudentsLoading}
label="Student Performance"
value={students.length}
color="purple"
onClick={() => router.push("/#studentsPerformance")}
/>
<button
disabled={isAssignmentsLoading}
onClick={() => router.push("/#assignments")}
className="bg-white col-span-2 rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
<span className="flex flex-col gap-1 items-center text-xl">
<span className="text-lg">Assignments</span>
<span className="font-semibold text-mti-purple-light">
{isAssignmentsLoading ? "Loading..." : assignments.filter((a) => !a.archived).length}
</span>
</span>
</button>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest teachers</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{teachers
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest level students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest exam count students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort(
(a, b) =>
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
</section>
</>
);
return (
<>
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
<>
{selectedUser && (
<div className="w-full flex flex-col gap-8">
<UserCard
loggedInUser={user}
onClose={(shouldReload) => {
setSelectedUser(undefined);
if (shouldReload && selectedUser!.type === "student") reloadStudents();
if (shouldReload && selectedUser!.type === "teacher") reloadTeachers();
}}
onViewStudents={
selectedUser.type === "corporate" || selectedUser.type === "teacher"
? () => {
appendUserFilters({
id: "view-students",
filter: (x: User) => x.type === "student",
});
appendUserFilters({
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.flatMap((g) => g.participants)
.includes(x.id),
});
router.push("/list/users");
}
: undefined
}
onViewTeachers={
selectedUser.type === "corporate" || selectedUser.type === "student"
? () => {
appendUserFilters({
id: "view-teachers",
filter: (x: User) => x.type === "teacher",
});
appendUserFilters({
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.flatMap((g) => g.participants)
.includes(x.id),
});
router.push("/list/users");
}
: undefined
}
user={selectedUser}
/>
</div>
)}
</>
</Modal>
{router.asPath === "/#students" && (
<UserList
user={user}
type="student"
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Students ({total})</h2>
</div>
)}
/>
)}
{router.asPath === "/#teachers" && (
<UserList
user={user}
type="teacher"
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Teachers ({total})</h2>
</div>
)}
/>
)}
{router.asPath === "/#groups" && <GroupsList />}
{router.asPath === "/#assignments" && (
<AssignmentsPage
assignments={assignments}
user={user}
groups={assignmentsGroups}
users={assignmentsUsers}
reloadAssignments={reloadAssignments}
isLoading={isAssignmentsLoading}
onBack={() => router.push("/")}
/>
)}
{router.asPath === "/#studentsPerformance" && <StudentPerformancePage />}
{router.asPath === "/" && <DefaultDashboard />}
</>
);
}

View File

@@ -0,0 +1,45 @@
import useUsers, {userHashStudent, userHashTeacher} from "@/hooks/useUsers";
import {CorporateUser, User} from "@/interfaces/user";
import {useRouter} from "next/router";
import {useMemo} from "react";
import {BsArrowLeft} from "react-icons/bs";
import MasterStatistical from "../MasterCorporate/MasterStatistical";
interface Props {
user: CorporateUser;
}
const MasterStatisticalPage = ({user}: Props) => {
const {users: students} = useUsers(userHashStudent);
const {users: teachers} = useUsers(userHashTeacher);
// this workaround will allow us toreuse the master statistical due to master corporate restraints
// while still being able to use the corporate user
const groupedByNameCorporateIds = useMemo(
() => ({
[user.corporateInformation?.companyInformation?.name || user.name]: [user.id],
}),
[user],
);
const teachersAndStudents = useMemo(() => [...students, ...teachers], [students, teachers]);
const router = useRouter();
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Master Statistical</h2>
</div>
<MasterStatistical users={teachersAndStudents} corporateUsers={groupedByNameCorporateIds} displaySelection={false} />
</>
);
};
export default MasterStatisticalPage;

View File

@@ -0,0 +1,154 @@
/* eslint-disable @next/next/no-img-element */
import Modal from "@/components/Modal";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import useUsers, {userHashStudent, userHashTeacher, userHashCorporate} from "@/hooks/useUsers";
import {CorporateUser, Group, MasterCorporateUser, Stat, User} from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils";
import moment from "moment";
import {useEffect, useMemo, useState} from "react";
import {
BsArrowLeft,
BsClipboard2Data,
BsClipboard2DataFill,
BsClock,
BsGlobeCentralSouthAsia,
BsPaperclip,
BsPerson,
BsPersonAdd,
BsPersonFill,
BsPersonFillGear,
BsPersonGear,
BsPencilSquare,
BsPersonBadge,
BsPersonCheck,
BsPeople,
BsArrowRepeat,
BsPlus,
BsEnvelopePaper,
} from "react-icons/bs";
import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups";
import {averageLevelCalculator, calculateAverageLevel, calculateBandScore} from "@/utils/score";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {Module} from "@/interfaces";
import {groupByExam} from "@/utils/stats";
import IconCard from "../IconCard";
import GroupList from "@/pages/(admin)/Lists/GroupList";
import useFilterStore from "@/stores/listFilterStore";
import {useRouter} from "next/router";
import useCodes from "@/hooks/useCodes";
import {getUserCorporate} from "@/utils/groups";
import useAssignments from "@/hooks/useAssignments";
import {Assignment} from "@/interfaces/results";
import AssignmentView from "../AssignmentView";
import AssignmentCreator from "../AssignmentCreator";
import clsx from "clsx";
import AssignmentCard from "../AssignmentCard";
import {createColumnHelper} from "@tanstack/react-table";
import Checkbox from "@/components/Low/Checkbox";
import List from "@/components/List";
import {getUserCompanyName} from "@/resources/user";
import {futureAssignmentFilter, pastAssignmentFilter, archivedAssignmentFilter, activeAssignmentFilter} from "@/utils/assignments";
import useUserBalance from "@/hooks/useUserBalance";
import AssignmentsPage from "../views/AssignmentsPage";
type StudentPerformanceItem = User & {corporateName: string; group: string};
const StudentPerformanceList = ({items, stats, users}: {items: StudentPerformanceItem[]; stats: Stat[]; users: User[]}) => {
const [isShowingAmount, setIsShowingAmount] = useState(false);
const columnHelper = createColumnHelper<StudentPerformanceItem>();
const columns = [
columnHelper.accessor("name", {
header: "Student Name",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("email", {
header: "E-mail",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("demographicInformation.passport_id", {
header: "ID",
cell: (info) => info.getValue() || "N/A",
}),
columnHelper.accessor("group", {
header: "Group",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("corporateName", {
header: "Corporate",
cell: (info) => info.getValue() || "N/A",
}),
columnHelper.accessor("levels.reading", {
header: "Reading",
cell: (info) =>
!isShowingAmount
? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "reading" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.listening", {
header: "Listening",
cell: (info) =>
!isShowingAmount
? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "listening" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.writing", {
header: "Writing",
cell: (info) =>
!isShowingAmount
? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "writing" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.speaking", {
header: "Speaking",
cell: (info) =>
!isShowingAmount
? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "speaking" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.level", {
header: "Level",
cell: (info) =>
!isShowingAmount
? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "level" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels", {
id: "overall_level",
header: "Overall",
cell: (info) =>
!isShowingAmount
? averageLevelCalculator(
users,
stats.filter((x) => x.user === info.row.original.id),
).toFixed(1)
: `${Object.keys(groupByExam(stats.filter((x) => x.user === info.row.original.id))).length} exams`,
}),
];
return (
<div className="flex flex-col gap-4 w-full h-full">
<Checkbox isChecked={isShowingAmount} onChange={setIsShowingAmount}>
Show Utilization
</Checkbox>
<List<StudentPerformanceItem>
data={items.sort(
(a, b) =>
averageLevelCalculator(
users,
stats.filter((x) => x.user === b.id),
) -
averageLevelCalculator(
users,
stats.filter((x) => x.user === a.id),
),
)}
columns={columns}
/>
</div>
);
};
export default StudentPerformanceList;

View File

@@ -0,0 +1,49 @@
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import useGroups from "@/hooks/useGroups";
import useUsers, {userHashStudent} from "@/hooks/useUsers";
import {Stat, User} from "@/interfaces/user";
import {getUserCompanyName} from "@/resources/user";
import clsx from "clsx";
import {useRouter} from "next/router";
import {BsArrowLeft, BsArrowRepeat} from "react-icons/bs";
import StudentPerformanceList from "./StudentPerformanceList";
interface Props {
user: User;
}
const StudentPerformancePage = ({user}: Props) => {
const {groups} = useGroups({admin: user.id});
const {users: students, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(userHashStudent);
const {data: stats} = useFilterRecordsByUser<Stat[]>();
const router = useRouter();
const performanceStudents = students.map((u) => ({
...u,
group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A",
corporateName: getUserCompanyName(user, [], groups),
}));
return (
<>
<div className="w-full flex justify-between items-center">
<div
onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<div
onClick={reloadStudents}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<span>Reload</span>
<BsArrowRepeat className={clsx("text-xl", isStudentsLoading && "animate-spin")} />
</div>
</div>
<StudentPerformanceList items={performanceStudents} stats={stats} users={students} />
</>
);
};
export default StudentPerformancePage;

View File

@@ -0,0 +1,546 @@
/* eslint-disable @next/next/no-img-element */
import Modal from "@/components/Modal";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import useUsers, {
userHashStudent,
userHashTeacher,
userHashCorporate,
} from "@/hooks/useUsers";
import {
CorporateUser,
Group,
MasterCorporateUser,
Stat,
User,
} from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList";
import { dateSorter } from "@/utils";
import moment from "moment";
import { useEffect, useMemo, useState } from "react";
import {
BsArrowLeft,
BsClipboard2Data,
BsClipboard2DataFill,
BsClock,
BsGlobeCentralSouthAsia,
BsPaperclip,
BsPerson,
BsPersonAdd,
BsPersonFill,
BsPersonFillGear,
BsPersonGear,
BsPencilSquare,
BsPersonBadge,
BsPersonCheck,
BsPeople,
BsArrowRepeat,
BsPlus,
BsEnvelopePaper,
BsDatabase,
} from "react-icons/bs";
import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups";
import {
averageLevelCalculator,
calculateAverageLevel,
calculateBandScore,
} from "@/utils/score";
import { MODULE_ARRAY } from "@/utils/moduleUtils";
import { Module } from "@/interfaces";
import { groupByExam } from "@/utils/stats";
import IconCard from "../IconCard";
import GroupList from "@/pages/(admin)/Lists/GroupList";
import useFilterStore from "@/stores/listFilterStore";
import { useRouter } from "next/router";
import useCodes from "@/hooks/useCodes";
import { getUserCorporate } from "@/utils/groups";
import useAssignments from "@/hooks/useAssignments";
import { Assignment } from "@/interfaces/results";
import AssignmentView from "../AssignmentView";
import AssignmentCreator from "../AssignmentCreator";
import clsx from "clsx";
import AssignmentCard from "../AssignmentCard";
import { createColumnHelper } from "@tanstack/react-table";
import Checkbox from "@/components/Low/Checkbox";
import List from "@/components/List";
import { getUserCompanyName } from "@/resources/user";
import {
futureAssignmentFilter,
pastAssignmentFilter,
archivedAssignmentFilter,
activeAssignmentFilter,
} from "@/utils/assignments";
import useUserBalance from "@/hooks/useUserBalance";
import AssignmentsPage from "../views/AssignmentsPage";
import StudentPerformancePage from "./StudentPerformancePage";
import MasterStatistical from "../MasterCorporate/MasterStatistical";
interface Props {
user: CorporateUser;
linkedCorporate?: CorporateUser | MasterCorporateUser;
}
const studentHash = {
type: "student",
orderBy: "registrationDate",
size: 25,
};
const teacherHash = {
type: "teacher",
orderBy: "registrationDate",
size: 25,
};
export default function CorporateDashboard({ user, linkedCorporate }: Props) {
const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false);
const { data: stats } = useFilterRecordsByUser<Stat[]>();
const { groups } = useGroups({ admin: user.id });
const {
assignments,
isLoading: isAssignmentsLoading,
reload: reloadAssignments,
} = useAssignments({ corporate: user.id });
const { balance } = useUserBalance();
const {
users: students,
total: totalStudents,
reload: reloadStudents,
isLoading: isStudentsLoading,
} = useUsers(studentHash);
const {
users: teachers,
total: totalTeachers,
reload: reloadTeachers,
isLoading: isTeachersLoading,
} = useUsers(teacherHash);
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter();
const assignmentsGroups = useMemo(
() =>
groups.filter(
(x) => x.admin === user.id || x.participants.includes(user.id)
),
[groups, user.id]
);
const assignmentsUsers = useMemo(
() =>
[...teachers, ...students].filter((x) =>
!!selectedUser
? groups
.filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants)
.includes(x.id) || false
: groups.flatMap((g) => g.participants).includes(x.id)
),
[groups, teachers, students, selectedUser]
);
useEffect(() => {
setShowModal(!!selectedUser && router.asPath === "/#");
}, [selectedUser, router.asPath]);
const getStatsByStudent = (user: User) =>
stats.filter((s) => s.user === user.id);
const UserDisplay = (displayUser: User) => (
<div
onClick={() => setSelectedUser(displayUser)}
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300"
>
<img
src={displayUser.profilePicture}
alt={displayUser.name}
className="rounded-full w-10 h-10"
/>
<div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
// this workaround will allow us toreuse the master statistical due to master corporate restraints
// while still being able to use the corporate user
const groupedByNameCorporateIds = useMemo(
() => ({
[user.corporateInformation?.companyInformation?.name || user.name]: [
user.id,
],
}),
[user]
);
const teachersAndStudents = useMemo(
() => [...students, ...teachers],
[students, teachers]
);
const MasterStatisticalPage = () => {
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Master Statistical</h2>
</div>
<MasterStatistical
users={teachersAndStudents}
corporateUsers={groupedByNameCorporateIds}
displaySelection={false}
/>
</>
);
};
const GroupsList = () => {
const filter = (x: Group) =>
x.admin === user.id || x.participants.includes(user.id);
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">
Groups ({groups.filter(filter).length})
</h2>
</div>
<GroupList user={user} />
</>
);
};
const averageLevelCalculator = (studentStats: Stat[]) => {
const formattedStats = studentStats
.map((s) => ({
focus: students.find((u) => u.id === s.user)?.focus,
score: s.score,
module: s.module,
}))
.filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({
module: s.module,
level: calculateBandScore(
s.score.correct,
s.score.total,
s.module,
s.focus!
),
}));
const levels: { [key in Module]: number } = {
reading: 0,
listening: 0,
writing: 0,
speaking: 0,
level: 0,
};
bandScores.forEach((b) => (levels[b.module] += b.level));
return calculateAverageLevel(levels);
};
if (router.asPath === "/#students")
return (
<UserList
user={user}
type="student"
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Students ({total})</h2>
</div>
)}
/>
);
if (router.asPath === "/#teachers")
return (
<UserList
user={user}
type="teacher"
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Teachers ({total})</h2>
</div>
)}
/>
);
if (router.asPath === "/#groups") return <GroupsList />;
if (router.asPath === "/#studentsPerformance")
return <StudentPerformancePage user={user} />;
if (router.asPath === "/#assignments")
return (
<AssignmentsPage
assignments={assignments}
user={user}
groups={assignmentsGroups}
reloadAssignments={reloadAssignments}
isLoading={isAssignmentsLoading}
onBack={() => router.push("/")}
/>
);
if (router.asPath === "/#statistical") return <MasterStatisticalPage />;
return (
<>
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
<>
{selectedUser && (
<div className="w-full flex flex-col gap-8">
<UserCard
loggedInUser={user}
onClose={(shouldReload) => {
setSelectedUser(undefined);
if (shouldReload && selectedUser!.type === "student")
reloadStudents();
if (shouldReload && selectedUser!.type === "teacher")
reloadTeachers();
}}
onViewStudents={
selectedUser.type === "corporate" ||
selectedUser.type === "teacher"
? () => {
appendUserFilters({
id: "view-students",
filter: (x: User) => x.type === "student",
});
appendUserFilters({
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter(
(g) =>
g.admin === selectedUser.id ||
g.participants.includes(selectedUser.id)
)
.flatMap((g) => g.participants)
.includes(x.id),
});
router.push("/list/users");
}
: undefined
}
onViewTeachers={
selectedUser.type === "corporate" ||
selectedUser.type === "student"
? () => {
appendUserFilters({
id: "view-teachers",
filter: (x: User) => x.type === "teacher",
});
appendUserFilters({
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter(
(g) =>
g.admin === selectedUser.id ||
g.participants.includes(selectedUser.id)
)
.flatMap((g) => g.participants)
.includes(x.id),
});
router.push("/list/users");
}
: undefined
}
user={selectedUser}
/>
</div>
)}
</>
</Modal>
<>
{!!linkedCorporate && (
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
Linked to:{" "}
<b>
{linkedCorporate?.corporateInformation?.companyInformation.name ||
linkedCorporate.name}
</b>
</div>
)}
<section className="grid grid-cols-5 -md:grid-cols-2 gap-4 text-center">
<IconCard
onClick={() => router.push("/#students")}
isLoading={isStudentsLoading}
Icon={BsPersonFill}
label="Students"
value={totalStudents}
color="purple"
/>
<IconCard
onClick={() => router.push("/#teachers")}
isLoading={isTeachersLoading}
Icon={BsPencilSquare}
label="Teachers"
value={totalTeachers}
color="purple"
/>
<IconCard
Icon={BsClipboard2Data}
label="Exams Performed"
value={
stats.filter((s) =>
groups.flatMap((g) => g.participants).includes(s.user)
).length
}
color="purple"
/>
<IconCard
Icon={BsPaperclip}
isLoading={isStudentsLoading}
label="Average Level"
value={averageLevelCalculator(
stats.filter((s) =>
groups.flatMap((g) => g.participants).includes(s.user)
)
).toFixed(1)}
color="purple"
/>
<IconCard
onClick={() => router.push("/#groups")}
Icon={BsPeople}
label="Groups"
value={groups.length}
color="purple"
/>
<IconCard
Icon={BsPersonCheck}
label="User Balance"
value={`${balance}/${
user.corporateInformation?.companyInformation?.userAmount || 0
}`}
color="purple"
/>
<IconCard
Icon={BsClock}
label="Expiration Date"
value={
user.subscriptionExpirationDate
? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy")
: "Unlimited"
}
color="rose"
/>
<IconCard
Icon={BsPersonFillGear}
isLoading={isStudentsLoading}
label="Student Performance"
value={totalStudents}
color="purple"
onClick={() => router.push("/#studentsPerformance")}
/>
<IconCard
Icon={BsDatabase}
label="Master Statistical"
color="purple"
onClick={() => router.push("/#statistical")}
/>
<button
disabled={isAssignmentsLoading}
onClick={() => router.push("/#assignments")}
className="bg-white col-span-2 rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300"
>
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
<span className="flex flex-col gap-1 items-center text-xl">
<span className="text-lg">Assignments</span>
<span className="font-semibold text-mti-purple-light">
{isAssignmentsLoading
? "Loading..."
: assignments.filter((a) => !a.archived).length}
</span>
</span>
</button>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest teachers</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{teachers
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest level students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort(
(a, b) =>
calculateAverageLevel(b.levels) -
calculateAverageLevel(a.levels)
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest exam count students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort(
(a, b) =>
Object.keys(groupByExam(getStatsByStudent(b))).length -
Object.keys(groupByExam(getStatsByStudent(a))).length
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
</section>
</>
</>
);
}

View File

@@ -1,688 +0,0 @@
/* eslint-disable @next/next/no-img-element */
import Modal from "@/components/Modal";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import useUsers, { userHashStudent, userHashTeacher, userHashCorporate } from "@/hooks/useUsers";
import {CorporateUser, Group, MasterCorporateUser, Stat, User} from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils";
import moment from "moment";
import {useEffect, useState, useMemo} from "react";
import {
BsArrowLeft,
BsClipboard2Data,
BsClock,
BsPaperclip,
BsPersonFill,
BsPencilSquare,
BsPersonCheck,
BsPeople,
BsBank,
BsEnvelopePaper,
BsArrowRepeat,
BsPlus,
BsPersonFillGear,
BsFilter,
BsDatabase,
} from "react-icons/bs";
import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups";
import {averageLevelCalculator, calculateAverageLevel, calculateBandScore} from "@/utils/score";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {Module} from "@/interfaces";
import {groupByExam} from "@/utils/stats";
import IconCard from "./IconCard";
import GroupList from "@/pages/(admin)/Lists/GroupList";
import useFilterStore from "@/stores/listFilterStore";
import {useRouter} from "next/router";
import useCodes from "@/hooks/useCodes";
import useAssignments from "@/hooks/useAssignments";
import {Assignment} from "@/interfaces/results";
import AssignmentView from "./AssignmentView";
import AssignmentCreator from "./AssignmentCreator";
import clsx from "clsx";
import AssignmentCard from "./AssignmentCard";
import {createColumn, createColumnHelper} from "@tanstack/react-table";
import List from "@/components/List";
import {getUserCorporate} from "@/utils/groups";
import {getCorporateUser, getUserCompanyName} from "@/resources/user";
import Checkbox from "@/components/Low/Checkbox";
import {groupBy, uniq, uniqBy} from "lodash";
import Select from "@/components/Low/Select";
import {Menu, MenuButton, MenuItem, MenuItems} from "@headlessui/react";
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
import MasterStatistical from "./MasterStatistical";
import {futureAssignmentFilter, pastAssignmentFilter, archivedAssignmentFilter, activeAssignmentFilter} from "@/utils/assignments";
import useUserBalance from "@/hooks/useUserBalance";
import AssignmentsPage from "./views/AssignmentsPage";
interface Props {
user: MasterCorporateUser;
}
type StudentPerformanceItem = User & {
corporate?: CorporateUser;
group?: Group;
};
const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPerformanceItem[]; stats: Stat[]; users: User[]; groups: Group[]}) => {
const [isShowingAmount, setIsShowingAmount] = useState(false);
const [availableCorporates] = useState(
uniqBy(
items.map((x) => x.corporate),
"id",
),
);
const [availableGroups] = useState(
uniqBy(
items.map((x) => x.group),
"id",
),
);
const [selectedCorporate, setSelectedCorporate] = useState<CorporateUser | null | undefined>(null);
const [selectedGroup, setSelectedGroup] = useState<Group | null | undefined>(null);
const columnHelper = createColumnHelper<StudentPerformanceItem>();
const columns = [
columnHelper.accessor("name", {
header: "Student Name",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("email", {
header: "E-mail",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("demographicInformation.passport_id", {
header: "ID",
cell: (info) => info.getValue() || "N/A",
}),
columnHelper.accessor("group", {
header: "Group",
cell: (info) => info.getValue()?.name || "N/A",
}),
columnHelper.accessor("corporate", {
header: "Corporate",
cell: (info) => (!!info.getValue() ? getUserCompanyName(info.getValue() as User, users, groups) : "N/A"),
}),
columnHelper.accessor("levels.reading", {
header: "Reading",
cell: (info) =>
!isShowingAmount
? calculateBandScore(
stats
.filter((x) => x.module === "reading" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.correct, 0),
stats
.filter((x) => x.module === "reading" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.total, 0),
"level",
info.row.original.focus || "academic",
) || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "reading" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.listening", {
header: "Listening",
cell: (info) =>
!isShowingAmount
? calculateBandScore(
stats
.filter((x) => x.module === "listening" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.correct, 0),
stats
.filter((x) => x.module === "listening" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.total, 0),
"level",
info.row.original.focus || "academic",
) || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "listening" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.writing", {
header: "Writing",
cell: (info) =>
!isShowingAmount
? calculateBandScore(
stats
.filter((x) => x.module === "writing" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.correct, 0),
stats
.filter((x) => x.module === "writing" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.total, 0),
"level",
info.row.original.focus || "academic",
) || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "writing" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.speaking", {
header: "Speaking",
cell: (info) =>
!isShowingAmount
? calculateBandScore(
stats
.filter((x) => x.module === "speaking" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.correct, 0),
stats
.filter((x) => x.module === "speaking" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.total, 0),
"level",
info.row.original.focus || "academic",
) || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "speaking" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.level", {
header: "Level",
cell: (info) =>
!isShowingAmount
? calculateBandScore(
stats
.filter((x) => x.module === "level" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.correct, 0),
stats
.filter((x) => x.module === "level" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.total, 0),
"level",
info.row.original.focus || "academic",
) || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "level" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels", {
id: "overall_level",
header: "Overall",
cell: (info) =>
!isShowingAmount
? averageLevelCalculator(
users,
stats.filter((x) => x.user === info.row.original.id),
).toFixed(1)
: `${Object.keys(groupByExam(stats.filter((x) => x.user === info.row.original.id))).length} exams`,
}),
];
const filterUsers = (data: StudentPerformanceItem[]) => {
const filterByCorporate = (item: StudentPerformanceItem) => item.corporate?.id === selectedCorporate?.id;
const filterByGroup = (item: StudentPerformanceItem) => item.group?.id === selectedGroup?.id;
const filters: ((item: StudentPerformanceItem) => boolean)[] = [];
if (selectedCorporate !== null) filters.push(filterByCorporate);
if (selectedGroup !== null) filters.push(filterByGroup);
return filters.reduce((d, f) => d.filter(f), data);
};
return (
<div className="flex flex-col gap-4 w-full h-full">
<div className="w-full flex gap-4 justify-between items-center">
<Checkbox isChecked={isShowingAmount} onChange={setIsShowingAmount}>
Show Utilization
</Checkbox>
<Popover>
<PopoverTrigger>
<div className="flex items-center justify-center p-2 hover:bg-neutral-300/50 rounded-full transition ease-in-out duration-300">
<BsFilter size={20} />
</div>
</PopoverTrigger>
<PopoverContent className="w-96">
<div className="flex flex-col gap-4">
<span className="font-bold text-lg">Filters</span>
<Select
options={availableCorporates.map((x) => ({
value: x?.id || "N/A",
label: x?.corporateInformation?.companyInformation?.name || x?.name || "N/A",
}))}
isClearable
value={
selectedCorporate === null
? null
: {
value: selectedCorporate?.id || "N/A",
label:
selectedCorporate?.corporateInformation?.companyInformation?.name ||
selectedCorporate?.name ||
"N/A",
}
}
placeholder="Select a Corporate..."
onChange={(value) =>
!value
? setSelectedCorporate(null)
: setSelectedCorporate(
value.value === "N/A" ? undefined : availableCorporates.find((x) => x?.id === value.value),
)
}
/>
<Select
options={availableGroups.map((x) => ({
value: x?.id || "N/A",
label: x?.name || "N/A",
}))}
isClearable
value={
selectedGroup === null
? null
: {
value: selectedGroup?.id || "N/A",
label: selectedGroup?.name || "N/A",
}
}
placeholder="Select a Group..."
onChange={(value) =>
!value
? setSelectedGroup(null)
: setSelectedGroup(value.value === "N/A" ? undefined : availableGroups.find((x) => x?.id === value.value))
}
/>
</div>
</PopoverContent>
</Popover>
</div>
<List<StudentPerformanceItem>
data={filterUsers(
items.sort(
(a, b) =>
averageLevelCalculator(
users,
stats.filter((x) => x.user === b.id),
) -
averageLevelCalculator(
users,
stats.filter((x) => x.user === a.id),
),
),
)}
columns={columns}
/>
</div>
);
};
export default function MasterCorporateDashboard({user}: Props) {
const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false);
const [corporateAssignments, setCorporateAssignments] = useState<(Assignment & {corporate?: CorporateUser})[]>([]);
const {data: stats} = useFilterRecordsByUser<Stat[]>();
const {users: students, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(userHashStudent);
const {users: teachers, reload: reloadTeachers, isLoading: isTeachersLoading} = useUsers(userHashTeacher);
const {users: corporates, reload: reloadCorporates, isLoading: isCorporatesLoading} = useUsers(userHashCorporate);
const {groups} = useGroups({admin: user.id, userType: user.type});
const {balance} = useUserBalance();
const users = useMemo(() => uniqBy([...students, ...teachers, ...corporates, user], "id"), [corporates, students, teachers, user]);
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id});
const assignmentsGroups = useMemo(() => groups.filter((x) => x.admin === user.id || x.participants.includes(user.id)), [groups, user.id]);
const assignmentsUsers = useMemo(
() =>
[...students, ...teachers].filter((x) =>
!!selectedUser
? groups
.filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants)
.includes(x.id) || false
: groups.flatMap((g) => g.participants).includes(x.id),
),
[groups, selectedUser, teachers, students],
);
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter();
useEffect(() => {
setShowModal(!!selectedUser && router.asPath === "/");
}, [selectedUser, router.asPath]);
useEffect(() => {
setCorporateAssignments(
assignments.filter(activeAssignmentFilter).map((a) => {
const assigner = [...teachers, ...corporates].find((x) => x.id === a.assigner);
return {
...a,
corporate: assigner ? getCorporateUser(assigner, [...teachers, ...corporates], groups) : undefined,
};
}),
);
}, [assignments, groups, teachers, corporates]);
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
const UserDisplay = (displayUser: User) => (
<div
onClick={() => setSelectedUser(displayUser)}
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
<div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
const corporateUserFilter = (x: User) => x.type === "corporate";
const GroupsList = () => {
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Groups ({groups.length})</h2>
</div>
<GroupList user={user} />
</>
);
};
const StudentPerformancePage = () => {
return (
<>
<div className="w-full flex justify-between items-center">
<div
onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<div
onClick={reloadAssignments}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<span>Reload</span>
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
</div>
</div>
<StudentPerformanceList items={students} stats={stats} users={corporates} groups={groups} />
</>
);
};
const MasterStatisticalPage = () => {
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Master Statistical</h2>
</div>
<MasterStatistical users={users} corporateUsers={corporates} />
</>
);
};
const DefaultDashboard = () => (
<>
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between text-center">
<IconCard
onClick={() => router.push("/#students")}
Icon={BsPersonFill}
isLoading={isStudentsLoading}
label="Students"
value={students.length}
color="purple"
/>
<IconCard
onClick={() => router.push("/#teachers")}
Icon={BsPencilSquare}
isLoading={isTeachersLoading}
label="Teachers"
value={teachers.length}
color="purple"
/>
<IconCard
Icon={BsClipboard2Data}
label="Exams Performed"
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
color="purple"
/>
<IconCard
Icon={BsPaperclip}
label="Average Level"
value={averageLevelCalculator(
students,
stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)),
).toFixed(1)}
color="purple"
/>
<IconCard onClick={() => router.push("/#groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
<IconCard
Icon={BsPersonCheck}
label="User Balance"
value={`${balance}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
color="purple"
/>
<IconCard
Icon={BsClock}
label="Expiration Date"
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
color="rose"
/>
<IconCard
Icon={BsBank}
label="Corporate"
value={corporates.length}
isLoading={isCorporatesLoading}
color="purple"
onClick={() => router.push("/#corporate")}
/>
<IconCard
Icon={BsPersonFillGear}
isLoading={isStudentsLoading}
label="Student Performance"
value={students.length}
color="purple"
onClick={() => router.push("/#studentsPerformance")}
/>
<IconCard
Icon={BsDatabase}
label="Master Statistical"
// value={masterCorporateUserGroups.length}
color="purple"
onClick={() => router.push("/#statistical")}
/>
<button
disabled={isAssignmentsLoading}
onClick={() => router.push("/#assignments")}
className="bg-white col-span-2 rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
<span className="flex flex-col gap-1 items-center text-xl">
<span className="text-lg">Assignments</span>
<span className="font-semibold text-mti-purple-light">
{isAssignmentsLoading ? "Loading..." : assignments.filter((a) => !a.archived).length}
</span>
</span>
</button>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest teachers</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{teachers
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest level students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest exam count students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort(
(a, b) =>
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
</section>
</>
);
return (
<>
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
<>
{selectedUser && (
<div className="w-full flex flex-col gap-8">
<UserCard
maxUserAmount={
user.type === "mastercorporate"
? (user.corporateInformation?.companyInformation?.userAmount || 0) - balance
: undefined
}
loggedInUser={user}
onClose={(shouldReload) => {
setSelectedUser(undefined);
if (shouldReload && selectedUser!.type === "student") reloadStudents();
if (shouldReload && selectedUser!.type === "teacher") reloadTeachers();
if (shouldReload && selectedUser!.type === "corporate") reloadCorporates();
}}
onViewStudents={
selectedUser.type === "corporate" || selectedUser.type === "teacher"
? () => {
appendUserFilters({
id: "view-students",
filter: (x: User) => x.type === "student",
});
appendUserFilters({
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.flatMap((g) => g.participants)
.includes(x.id),
});
router.push("/list/users");
}
: undefined
}
onViewTeachers={
selectedUser.type === "corporate" || selectedUser.type === "student"
? () => {
appendUserFilters({
id: "view-teachers",
filter: (x: User) => x.type === "teacher",
});
appendUserFilters({
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.flatMap((g) => g.participants)
.includes(x.id),
});
router.push("/list/users");
}
: undefined
}
user={selectedUser}
/>
</div>
)}
</>
</Modal>
{router.asPath === "/#students" && (
<UserList
user={user}
type="student"
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Students ({total})</h2>
</div>
)}
/>
)}
{router.asPath === "/#teachers" && (
<UserList
user={user}
type="teacher"
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Teachers ({total})</h2>
</div>
)}
/>
)}
{router.asPath === "/#groups" && <GroupsList />}
{router.asPath === "/#corporate" && (
<UserList
user={user}
type="corporate"
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Corporate ({total})</h2>
</div>
)}
/>
)}
{router.asPath === "/#assignments" && (
<AssignmentsPage
assignments={assignments}
corporateAssignments={corporateAssignments}
groups={assignmentsGroups}
user={user}
users={assignmentsUsers}
reloadAssignments={reloadAssignments}
isLoading={isAssignmentsLoading}
onBack={() => router.push("/")}
/>
)}
{router.asPath === "/#studentsPerformance" && <StudentPerformancePage />}
{router.asPath === "/#statistical" && <MasterStatisticalPage />}
{router.asPath === "/" && <DefaultDashboard />}
</>
);
}

View File

@@ -0,0 +1,385 @@
import React from "react";
import {CorporateUser, User} from "@/interfaces/user";
import {BsFileExcel, BsBank, BsPersonFill} from "react-icons/bs";
import IconCard from "../IconCard";
import useAssignmentsCorporates from "@/hooks/useAssignmentCorporates";
import ReactDatePicker from "react-datepicker";
import moment from "moment";
import {AssignmentWithCorporateId} from "@/interfaces/results";
import {flexRender, createColumnHelper, getCoreRowModel, useReactTable} from "@tanstack/react-table";
import Checkbox from "@/components/Low/Checkbox";
import {useListSearch} from "@/hooks/useListSearch";
import axios from "axios";
import {toast} from "react-toastify";
import Button from "@/components/Low/Button";
interface GroupedCorporateUsers {
// list of user Ids
[key: string]: string[];
}
interface Props {
corporateUsers: GroupedCorporateUsers;
users: User[];
displaySelection?: boolean;
}
interface TableData {
user: string;
email: string;
correct: number;
corporate: string;
submitted: boolean;
date: moment.Moment;
assignment: string;
corporateId: string;
}
interface UserCount {
userCount: number;
maxUserCount: number;
}
const searchFilters = [["email"], ["user"], ["userId"]];
const MasterStatistical = (props: Props) => {
const {users, corporateUsers, displaySelection = true} = props;
// const corporateRelevantUsers = React.useMemo(
// () => corporateUsers.filter((x) => x.type !== "student") as CorporateUser[],
// [corporateUsers]
// );
const corporates = React.useMemo(() => Object.values(corporateUsers).flat(), [corporateUsers]);
const [selectedCorporates, setSelectedCorporates] = React.useState<string[]>(corporates);
const [startDate, setStartDate] = React.useState<Date | null>(moment("01/01/2023").toDate());
const [endDate, setEndDate] = React.useState<Date | null>(moment().endOf("year").toDate());
const {assignments} = useAssignmentsCorporates({
// corporates: [...corporates, "tYU0HTiJdjMsS8SB7XJsUdMMP892"],
corporates: selectedCorporates,
startDate,
endDate,
});
const [downloading, setDownloading] = React.useState<boolean>(false);
const tableResults = React.useMemo(
() =>
assignments.reduce((accmA: TableData[], a: AssignmentWithCorporateId) => {
const userResults = a.assignees.map((assignee) => {
const userStats = a.results.find((r) => r.user === assignee)?.stats || [];
const userData = users.find((u) => u.id === assignee);
const corporate = users.find((u) => u.id === a.assigner)?.name || "";
const commonData = {
user: userData?.name || "",
email: userData?.email || "",
userId: assignee,
corporateId: a.corporateId,
corporate,
assignment: a.name,
};
if (userStats.length === 0) {
return {
...commonData,
correct: 0,
submitted: false,
// date: moment(),
};
}
return {
...commonData,
correct: userStats.reduce((n, e) => n + e.score.correct, 0),
submitted: true,
date: moment.max(userStats.map((e) => moment(e.date))),
};
}) as TableData[];
return [...accmA, ...userResults];
}, []),
[assignments, users],
);
const getCorporateScores = (corporateId: string): UserCount => {
const corporateAssignmentsUsers = assignments.filter((a) => a.corporateId === corporateId).reduce((acc, a) => acc + a.assignees.length, 0);
const corporateResults = tableResults.filter((r) => r.corporateId === corporateId).length;
return {
maxUserCount: corporateAssignmentsUsers,
userCount: corporateResults,
};
};
const getCorporatesScoresHash = (data: string[]) =>
data.reduce(
(accm, id) => ({
...accm,
[id]: getCorporateScores(id),
}),
{},
) as Record<string, UserCount>;
const getConsolidateScore = (data: Record<string, UserCount>) =>
Object.values(data).reduce(
(acc: UserCount, {userCount, maxUserCount}: UserCount) => ({
userCount: acc.userCount + userCount,
maxUserCount: acc.maxUserCount + maxUserCount,
}),
{userCount: 0, maxUserCount: 0},
);
const corporateScores = getCorporatesScoresHash(corporates);
const consolidateScore = getConsolidateScore(corporateScores);
const getConsolidateScoreStr = (data: UserCount) => `${data.userCount}/${data.maxUserCount}`;
const columnHelper = createColumnHelper<TableData>();
const defaultColumns = [
columnHelper.accessor("user", {
header: "User",
id: "user",
cell: (info) => {
return <span>{info.getValue()}</span>;
},
}),
columnHelper.accessor("email", {
header: "Email",
id: "email",
cell: (info) => {
return <span>{info.getValue()}</span>;
},
}),
...(displaySelection
? [
columnHelper.accessor("corporate", {
header: "Corporate",
id: "corporate",
cell: (info) => {
return <span>{info.getValue()}</span>;
},
}),
]
: []),
columnHelper.accessor("corporate", {
header: "Corporate",
id: "corporate",
cell: (info) => {
return <span>{info.getValue()}</span>;
},
}),
columnHelper.accessor("assignment", {
header: "Assignment",
id: "assignment",
cell: (info) => {
return <span>{info.getValue()}</span>;
},
}),
columnHelper.accessor("submitted", {
header: "Submitted",
id: "submitted",
cell: (info) => {
return (
<Checkbox isChecked={info.getValue()} disabled onChange={() => {}}>
<span></span>
</Checkbox>
);
},
}),
columnHelper.accessor("correct", {
header: "Correct",
id: "correct",
cell: (info) => {
return <span>{info.getValue()}</span>;
},
}),
columnHelper.accessor("date", {
header: "Date",
id: "date",
cell: (info) => {
const date = info.getValue();
if (date) {
return <span>{date.format("DD/MM/YYYY")}</span>;
}
return <span>{""}</span>;
},
}),
];
const {rows: filteredRows, renderSearch, text: searchText} = useListSearch(searchFilters, tableResults);
const table = useReactTable({
data: filteredRows,
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
const areAllSelected = selectedCorporates.length === corporates.length;
const getStudentsConsolidateScore = () => {
if (tableResults.length === 0) {
return {highest: null, lowest: null};
}
// Find the student with the highest and lowest score
return tableResults.reduce(
(acc, curr) => {
if (curr.correct > acc.highest.correct) {
acc.highest = curr;
}
if (curr.correct < acc.lowest.correct) {
acc.lowest = curr;
}
return acc;
},
{highest: tableResults[0], lowest: tableResults[0]},
);
};
const triggerDownload = async () => {
try {
setDownloading(true);
const res = await axios.post("/api/assignments/statistical/excel", {
ids: selectedCorporates,
...(startDate ? {startDate: startDate.toISOString()} : {}),
...(endDate ? {endDate: endDate.toISOString()} : {}),
searchText,
});
toast.success("Report ready!");
const link = document.createElement("a");
link.href = res.data;
// download should have worked but there are some CORS issues
// https://firebase.google.com/docs/storage/web/download-files#cors_configuration
// link.download="report.pdf";
link.target = "_blank";
link.rel = "noreferrer";
link.click();
setDownloading(false);
} catch (err) {
toast.error("Failed to display the report!");
console.error(err);
setDownloading(false);
}
};
const consolidateResults = getStudentsConsolidateScore();
return (
<>
{displaySelection && (
<div className="flex flex-wrap gap-2 items-center text-center">
<IconCard
Icon={BsBank}
label="Consolidate"
value={getConsolidateScoreStr(consolidateScore)}
color="purple"
onClick={() => {
if (areAllSelected) {
setSelectedCorporates([]);
return;
}
setSelectedCorporates(corporates);
}}
isSelected={areAllSelected}
/>
{Object.keys(corporateUsers).map((corporateName) => {
const group = corporateUsers[corporateName];
const isSelected = group.every((id) => selectedCorporates.includes(id));
const valueHash = getCorporatesScoresHash(group);
const value = getConsolidateScoreStr(getConsolidateScore(valueHash));
return (
<IconCard
key={corporateName}
Icon={BsBank}
label={corporateName}
value={value}
color="purple"
onClick={() => {
if (isSelected) {
setSelectedCorporates((prev) => prev.filter((x) => !group.includes(x)));
return;
}
setSelectedCorporates((prev) => [...new Set([...prev, ...group])]);
}}
isSelected={isSelected}
/>
);
})}
</div>
)}
<div className="flex gap-3 w-full">
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Date</label>
<ReactDatePicker
dateFormat="dd/MM/yyyy"
className="px-4 py-6 w-52 text-sm text-center font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed rounded-full border border-mti-gray-platinum focus:outline-none"
selected={startDate}
startDate={startDate}
endDate={endDate}
selectsRange
showMonthDropdown
onChange={([initialDate, finalDate]: [Date, Date]) => {
setStartDate(initialDate ?? moment("01/01/2023").toDate());
if (finalDate) {
// basicly selecting a final day works as if I'm selecting the first
// minute of that day. this way it covers the whole day
setEndDate(moment(finalDate).endOf("day").toDate());
return;
}
setEndDate(null);
}}
/>
</div>
{renderSearch()}
<div className="flex flex-col gap-3 justify-end">
<Button className="max-w-[200px] h-[70px]" variant="outline" onClick={triggerDownload}>
Download
</Button>
</div>
</div>
<div>
<table className="rounded-xl h-full bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="p-4 text-left" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="flex flex-wrap gap-2 items-center text-center">
{consolidateResults.highest && (
<IconCard onClick={() => {}} Icon={BsPersonFill} label={`Highest result: ${consolidateResults.highest.user}`} color="purple" />
)}
{consolidateResults.lowest && (
<IconCard onClick={() => {}} Icon={BsPersonFill} label={`Lowest result: ${consolidateResults.lowest.user}`} color="purple" />
)}
</div>
</>
);
};
export default MasterStatistical;

View File

@@ -0,0 +1,43 @@
import useUsers from "@/hooks/useUsers";
import {CorporateUser, User} from "@/interfaces/user";
import {groupBy} from "lodash";
import {useRouter} from "next/router";
import {useMemo} from "react";
import {BsArrowLeft} from "react-icons/bs";
import MasterStatistical from "./MasterStatistical";
interface Props {
groupedByNameCorporates: Record<string, CorporateUser[]>;
}
const MasterStatisticalPage = ({ groupedByNameCorporates }: Props) => {
const {users} = useUsers();
const router = useRouter();
const groupedByNameCorporateIds = useMemo(
() =>
Object.keys(groupedByNameCorporates).reduce((accm, x) => {
const corporateUserIds = (groupedByNameCorporates[x] as CorporateUser[]).map((y) => y.id);
return {...accm, [x]: corporateUserIds};
}, {}),
[groupedByNameCorporates],
);
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Master Statistical</h2>
</div>
<MasterStatistical users={users} corporateUsers={groupedByNameCorporateIds} />
</>
);
};
export default MasterStatisticalPage;

View File

@@ -0,0 +1,252 @@
/* eslint-disable @next/next/no-img-element */
import {CorporateUser, Group, Stat, User} from "@/interfaces/user";
import {useState} from "react";
import {BsFilter} from "react-icons/bs";
import {averageLevelCalculator, calculateBandScore} from "@/utils/score";
import {groupByExam} from "@/utils/stats";
import {createColumnHelper} from "@tanstack/react-table";
import List from "@/components/List";
import {getUserCompanyName} from "@/resources/user";
import Checkbox from "@/components/Low/Checkbox";
import {uniqBy} from "lodash";
import Select from "@/components/Low/Select";
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
type StudentPerformanceItem = User & {
corporate?: CorporateUser;
group?: Group;
};
const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPerformanceItem[]; stats: Stat[]; users: User[]; groups: Group[]}) => {
const [isShowingAmount, setIsShowingAmount] = useState(false);
const [availableCorporates] = useState(
uniqBy(
items.map((x) => x.corporate),
"id",
),
);
const [availableGroups] = useState(
uniqBy(
items.map((x) => x.group),
"id",
),
);
const [selectedCorporate, setSelectedCorporate] = useState<CorporateUser | null | undefined>(null);
const [selectedGroup, setSelectedGroup] = useState<Group | null | undefined>(null);
const columnHelper = createColumnHelper<StudentPerformanceItem>();
const columns = [
columnHelper.accessor("name", {
header: "Student Name",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("email", {
header: "E-mail",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("demographicInformation.passport_id", {
header: "ID",
cell: (info) => info.getValue() || "N/A",
}),
columnHelper.accessor("group", {
header: "Group",
cell: (info) => info.getValue()?.name || "N/A",
}),
columnHelper.accessor("corporate", {
header: "Corporate",
cell: (info) => (!!info.getValue() ? getUserCompanyName(info.getValue() as User, users, groups) : "N/A"),
}),
columnHelper.accessor("levels.reading", {
header: "Reading",
cell: (info) =>
!isShowingAmount
? calculateBandScore(
stats
.filter((x) => x.module === "reading" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.correct, 0),
stats
.filter((x) => x.module === "reading" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.total, 0),
"level",
info.row.original.focus || "academic",
) || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "reading" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.listening", {
header: "Listening",
cell: (info) =>
!isShowingAmount
? calculateBandScore(
stats
.filter((x) => x.module === "listening" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.correct, 0),
stats
.filter((x) => x.module === "listening" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.total, 0),
"level",
info.row.original.focus || "academic",
) || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "listening" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.writing", {
header: "Writing",
cell: (info) =>
!isShowingAmount
? calculateBandScore(
stats
.filter((x) => x.module === "writing" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.correct, 0),
stats
.filter((x) => x.module === "writing" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.total, 0),
"level",
info.row.original.focus || "academic",
) || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "writing" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.speaking", {
header: "Speaking",
cell: (info) =>
!isShowingAmount
? calculateBandScore(
stats
.filter((x) => x.module === "speaking" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.correct, 0),
stats
.filter((x) => x.module === "speaking" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.total, 0),
"level",
info.row.original.focus || "academic",
) || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "speaking" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.level", {
header: "Level",
cell: (info) =>
!isShowingAmount
? calculateBandScore(
stats
.filter((x) => x.module === "level" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.correct, 0),
stats
.filter((x) => x.module === "level" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.total, 0),
"level",
info.row.original.focus || "academic",
) || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "level" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels", {
id: "overall_level",
header: "Overall",
cell: (info) =>
!isShowingAmount
? averageLevelCalculator(
users,
stats.filter((x) => x.user === info.row.original.id),
).toFixed(1)
: `${Object.keys(groupByExam(stats.filter((x) => x.user === info.row.original.id))).length} exams`,
}),
];
const filterUsers = (data: StudentPerformanceItem[]) => {
const filterByCorporate = (item: StudentPerformanceItem) => item.corporate?.id === selectedCorporate?.id;
const filterByGroup = (item: StudentPerformanceItem) => item.group?.id === selectedGroup?.id;
const filters: ((item: StudentPerformanceItem) => boolean)[] = [];
if (selectedCorporate !== null) filters.push(filterByCorporate);
if (selectedGroup !== null) filters.push(filterByGroup);
return filters.reduce((d, f) => d.filter(f), data);
};
return (
<div className="flex flex-col gap-4 w-full h-full">
<div className="w-full flex gap-4 justify-between items-center">
<Checkbox isChecked={isShowingAmount} onChange={setIsShowingAmount}>
Show Utilization
</Checkbox>
<Popover>
<PopoverTrigger>
<div className="flex items-center justify-center p-2 hover:bg-neutral-300/50 rounded-full transition ease-in-out duration-300">
<BsFilter size={20} />
</div>
</PopoverTrigger>
<PopoverContent className="w-96">
<div className="flex flex-col gap-4">
<span className="font-bold text-lg">Filters</span>
<Select
options={availableCorporates.map((x) => ({
value: x?.id || "N/A",
label: x?.corporateInformation?.companyInformation?.name || x?.name || "N/A",
}))}
isClearable
value={
selectedCorporate === null
? null
: {
value: selectedCorporate?.id || "N/A",
label:
selectedCorporate?.corporateInformation?.companyInformation?.name ||
selectedCorporate?.name ||
"N/A",
}
}
placeholder="Select a Corporate..."
onChange={(value) =>
!value
? setSelectedCorporate(null)
: setSelectedCorporate(
value.value === "N/A" ? undefined : availableCorporates.find((x) => x?.id === value.value),
)
}
/>
<Select
options={availableGroups.map((x) => ({
value: x?.id || "N/A",
label: x?.name || "N/A",
}))}
isClearable
value={
selectedGroup === null
? null
: {
value: selectedGroup?.id || "N/A",
label: selectedGroup?.name || "N/A",
}
}
placeholder="Select a Group..."
onChange={(value) =>
!value
? setSelectedGroup(null)
: setSelectedGroup(value.value === "N/A" ? undefined : availableGroups.find((x) => x?.id === value.value))
}
/>
</div>
</PopoverContent>
</Popover>
</div>
<List<StudentPerformanceItem>
data={filterUsers(
items.sort(
(a, b) =>
averageLevelCalculator(
users,
stats.filter((x) => x.user === b.id),
) -
averageLevelCalculator(
users,
stats.filter((x) => x.user === a.id),
),
),
)}
columns={columns}
/>
</div>
);
};
export default StudentPerformanceList;

View File

@@ -0,0 +1,46 @@
import useAssignments from "@/hooks/useAssignments";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import useGroups from "@/hooks/useGroups";
import useUsers, {userHashCorporate, userHashStudent} from "@/hooks/useUsers";
import {Stat, User} from "@/interfaces/user";
import clsx from "clsx";
import {useRouter} from "next/router";
import {BsArrowLeft, BsArrowRepeat} from "react-icons/bs";
import StudentPerformanceList from "./StudentPerformanceList";
interface Props {
user: User;
}
const StudentPerformancePage = ({user}: Props) => {
const {users: students} = useUsers(userHashStudent);
const {users: corporates} = useUsers(userHashCorporate);
const {groups} = useGroups({admin: user.id, userType: user.type});
const {data: stats} = useFilterRecordsByUser<Stat[]>();
const {reload: reloadAssignments, isLoading: isAssignmentsLoading} = useAssignments({corporate: user.id});
const router = useRouter();
return (
<>
<div className="w-full flex justify-between items-center">
<div
onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<div
onClick={reloadAssignments}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<span>Reload</span>
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
</div>
</div>
<StudentPerformanceList items={students} stats={stats} users={corporates} groups={groups} />
</>
);
};
export default StudentPerformancePage;

View File

@@ -0,0 +1,455 @@
/* eslint-disable @next/next/no-img-element */
import Modal from "@/components/Modal";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import useUsers from "@/hooks/useUsers";
import {CorporateUser, MasterCorporateUser, Stat, User} from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils";
import moment from "moment";
import {useEffect, useState, useMemo} from "react";
import {
BsArrowLeft,
BsClipboard2Data,
BsClock,
BsPaperclip,
BsPersonFill,
BsPencilSquare,
BsPersonCheck,
BsPeople,
BsBank,
BsEnvelopePaper,
BsArrowRepeat,
BsPersonFillGear,
BsDatabase,
} from "react-icons/bs";
import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups";
import {averageLevelCalculator, calculateAverageLevel} from "@/utils/score";
import {groupByExam} from "@/utils/stats";
import IconCard from "../IconCard";
import GroupList from "@/pages/(admin)/Lists/GroupList";
import useFilterStore from "@/stores/listFilterStore";
import {useRouter} from "next/router";
import useAssignments from "@/hooks/useAssignments";
import {Assignment} from "@/interfaces/results";
import clsx from "clsx";
import {getCorporateUser} from "@/resources/user";
import {groupBy, uniqBy} from "lodash";
import MasterStatistical from "./MasterStatistical";
import {activeAssignmentFilter} from "@/utils/assignments";
import useUserBalance from "@/hooks/useUserBalance";
import AssignmentsPage from "../views/AssignmentsPage";
import StudentPerformanceList from "./StudentPerformanceList";
import StudentPerformancePage from "./StudentPerformancePage";
import MasterStatisticalPage from "./MasterStatisticalPage";
interface Props {
user: MasterCorporateUser;
}
const studentHash = {
type: "student",
size: 25,
orderBy: "registrationDate",
};
const teacherHash = {
type: "teacher",
size: 25,
orderBy: "registrationDate",
};
const corporateHash = {
type: "corporate",
size: 25,
orderBy: "registrationDate",
};
export default function MasterCorporateDashboard({user}: Props) {
const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false);
const [corporateAssignments, setCorporateAssignments] = useState<(Assignment & {corporate?: CorporateUser})[]>([]);
const {data: stats} = useFilterRecordsByUser<Stat[]>();
const {users: students, total: totalStudents, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(studentHash);
const {users: teachers, total: totalTeachers, reload: reloadTeachers, isLoading: isTeachersLoading} = useUsers(teacherHash);
const {users: corporates, total: totalCorporate, reload: reloadCorporates, isLoading: isCorporatesLoading} = useUsers(corporateHash);
const {groups} = useGroups({admin: user.id, userType: user.type});
const {balance} = useUserBalance();
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id});
const assignmentsGroups = useMemo(() => groups.filter((x) => x.admin === user.id || x.participants.includes(user.id)), [groups, user.id]);
const assignmentsUsers = useMemo(
() =>
[...students, ...teachers].filter((x) =>
!!selectedUser
? groups
.filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants)
.includes(x.id) || false
: groups.flatMap((g) => g.participants).includes(x.id),
),
[groups, selectedUser, teachers, students],
);
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter();
useEffect(() => {
setShowModal(!!selectedUser && router.asPath === "/");
}, [selectedUser, router.asPath]);
useEffect(() => {
setCorporateAssignments(
assignments.filter(activeAssignmentFilter).map((a) => {
const assigner = [...teachers, ...corporates].find((x) => x.id === a.assigner);
return {
...a,
corporate: assigner ? getCorporateUser(assigner, [...teachers, ...corporates], groups) : undefined,
};
}),
);
}, [assignments, groups, teachers, corporates]);
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
const UserDisplay = (displayUser: User) => (
<div
onClick={() => setSelectedUser(displayUser)}
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
<div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
const {users} = useUsers();
const groupedByNameCorporates = useMemo(
() =>
groupBy(
users.filter((x) => x.type === "corporate"),
(x: CorporateUser) => x.corporateInformation?.companyInformation?.name || "N/A",
) as Record<string, CorporateUser[]>,
[users],
);
const groupedByNameCorporatesKeys = Object.keys(groupedByNameCorporates);
const GroupsList = () => {
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Groups ({groups.length})</h2>
</div>
<GroupList user={user} />
</>
);
};
if (router.asPath === "/#studentsPerformance") return <StudentPerformancePage user={user} />;
if (router.asPath === "/#statistical") return <MasterStatisticalPage groupedByNameCorporates={groupedByNameCorporates} />;
if (router.asPath === "/#groups") return <GroupsList />;
if (router.asPath === "/#students")
return (
<UserList
user={user}
type="student"
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Students ({total})</h2>
</div>
)}
/>
);
if (router.asPath === "/#assignments")
return (
<AssignmentsPage
assignments={assignments}
corporateAssignments={corporateAssignments}
groups={assignmentsGroups}
user={user}
reloadAssignments={reloadAssignments}
isLoading={isAssignmentsLoading}
onBack={() => router.push("/")}
/>
);
if (router.asPath === "/#corporate")
return (
<UserList
user={user}
type="corporate"
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Corporate ({total})</h2>
</div>
)}
/>
);
if (router.asPath === "/#students")
return (
<UserList
user={user}
type="student"
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Students ({total})</h2>
</div>
)}
/>
);
if (router.asPath === "/#teachers")
return (
<UserList
user={user}
type="teacher"
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Teachers ({total})</h2>
</div>
)}
/>
);
return (
<>
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
<>
{selectedUser && (
<div className="w-full flex flex-col gap-8">
<UserCard
maxUserAmount={
user.type === "mastercorporate"
? (user.corporateInformation?.companyInformation?.userAmount || 0) - balance
: undefined
}
loggedInUser={user}
onClose={(shouldReload) => {
setSelectedUser(undefined);
if (shouldReload && selectedUser!.type === "student") reloadStudents();
if (shouldReload && selectedUser!.type === "teacher") reloadTeachers();
if (shouldReload && selectedUser!.type === "corporate") reloadCorporates();
}}
onViewStudents={
selectedUser.type === "corporate" || selectedUser.type === "teacher"
? () => {
appendUserFilters({
id: "view-students",
filter: (x: User) => x.type === "student",
});
appendUserFilters({
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.flatMap((g) => g.participants)
.includes(x.id),
});
router.push("/list/users");
}
: undefined
}
onViewTeachers={
selectedUser.type === "corporate" || selectedUser.type === "student"
? () => {
appendUserFilters({
id: "view-teachers",
filter: (x: User) => x.type === "teacher",
});
appendUserFilters({
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.flatMap((g) => g.participants)
.includes(x.id),
});
router.push("/list/users");
}
: undefined
}
user={selectedUser}
/>
</div>
)}
</>
</Modal>
<>
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between text-center">
<IconCard
onClick={() => router.push("/#students")}
Icon={BsPersonFill}
isLoading={isStudentsLoading}
label="Students"
value={totalStudents}
color="purple"
/>
<IconCard
onClick={() => router.push("/#teachers")}
Icon={BsPencilSquare}
isLoading={isTeachersLoading}
label="Teachers"
value={totalTeachers}
color="purple"
/>
<IconCard
Icon={BsClipboard2Data}
label="Exams Performed"
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
color="purple"
/>
<IconCard
Icon={BsPaperclip}
label="Average Level"
value={averageLevelCalculator(
students,
stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)),
).toFixed(1)}
color="purple"
/>
<IconCard onClick={() => router.push("/#groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
<IconCard
Icon={BsPersonCheck}
label="User Balance"
value={`${balance}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
color="purple"
/>
<IconCard
Icon={BsClock}
label="Expiration Date"
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
color="rose"
/>
<IconCard
Icon={BsBank}
label="Corporate Accounts"
value={totalCorporate}
isLoading={isCorporatesLoading}
color="purple"
onClick={() => router.push("/#corporate")}
/>
<IconCard Icon={BsBank} label="Corporate" value={groupedByNameCorporatesKeys.length} isLoading={isCorporatesLoading} color="purple" />
<IconCard
Icon={BsPersonFillGear}
isLoading={isStudentsLoading}
label="Student Performance"
value={students.length}
color="purple"
onClick={() => router.push("/#studentsPerformance")}
/>
<IconCard
Icon={BsDatabase}
label="Master Statistical"
// value={masterCorporateUserGroups.length}
color="purple"
onClick={() => router.push("/#statistical")}
/>
<button
disabled={isAssignmentsLoading}
onClick={() => router.push("/#assignments")}
className="bg-white col-span-2 rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
<span className="flex flex-col gap-1 items-center text-xl">
<span className="text-lg">Assignments</span>
<span className="font-semibold text-mti-purple-light">
{isAssignmentsLoading ? "Loading..." : assignments.filter((a) => !a.archived).length}
</span>
</span>
</button>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest teachers</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{teachers
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest level students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest exam count students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort(
(a, b) =>
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
</section>
</>
</>
);
}

View File

@@ -1,405 +0,0 @@
import React from "react";
import { CorporateUser, User } from "@/interfaces/user";
import { BsFileExcel, BsBank, BsPersonFill } from "react-icons/bs";
import IconCard from "./IconCard";
import useAssignmentsCorporates from "@/hooks/useAssignmentCorporates";
import ReactDatePicker from "react-datepicker";
import moment from "moment";
import { AssignmentWithCorporateId } from "@/interfaces/results";
import {
flexRender,
createColumnHelper,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import Checkbox from "@/components/Low/Checkbox";
import { useListSearch } from "@/hooks/useListSearch";
import axios from "axios";
import { toast } from "react-toastify";
import Button from "@/components/Low/Button";
interface Props {
corporateUsers: User[];
users: User[];
}
interface TableData {
user: string;
email: string;
correct: number;
corporate: string;
submitted: boolean;
date: moment.Moment;
assignment: string;
corporateId: string;
}
interface UserCount {
userCount: number;
maxUserCount: number;
}
const searchFilters = [["email"], ["user"], ["userId"]];
const MasterStatistical = (props: Props) => {
const { users, corporateUsers } = props;
const corporateRelevantUsers = React.useMemo(
() => corporateUsers.filter((x) => x.type !== "student") as CorporateUser[],
[corporateUsers]
);
const corporates = React.useMemo(
() => corporateRelevantUsers.map((x) => x.id),
[corporateRelevantUsers]
);
const [selectedCorporates, setSelectedCorporates] =
React.useState<string[]>(corporates);
const [startDate, setStartDate] = React.useState<Date | null>(
moment("01/01/2023").toDate()
);
const [endDate, setEndDate] = React.useState<Date | null>(
moment().endOf("year").toDate()
);
const { assignments } = useAssignmentsCorporates({
// corporates: [...corporates, "tYU0HTiJdjMsS8SB7XJsUdMMP892"],
corporates: selectedCorporates,
startDate,
endDate,
});
const [downloading, setDownloading] = React.useState<boolean>(false);
const tableResults = React.useMemo(
() =>
assignments.reduce((accmA: TableData[], a: AssignmentWithCorporateId) => {
const userResults = a.assignees.map((assignee) => {
const userStats =
a.results.find((r) => r.user === assignee)?.stats || [];
const userData = users.find((u) => u.id === assignee);
const corporate = users.find((u) => u.id === a.assigner)?.name || "";
const commonData = {
user: userData?.name || "",
email: userData?.email || "",
userId: assignee,
corporateId: a.corporateId,
corporate,
assignment: a.name,
};
if (userStats.length === 0) {
return {
...commonData,
correct: 0,
submitted: false,
// date: moment(),
};
}
return {
...commonData,
correct: userStats.reduce((n, e) => n + e.score.correct, 0),
submitted: true,
date: moment.max(userStats.map((e) => moment(e.date))),
};
}) as TableData[];
return [...accmA, ...userResults];
}, []),
[assignments, users]
);
const getCorporateScores = (corporateId: string): UserCount => {
const corporateAssignmentsUsers = assignments
.filter((a) => a.corporateId === corporateId)
.reduce((acc, a) => acc + a.assignees.length, 0);
const corporateResults = tableResults.filter(
(r) => r.corporateId === corporateId
).length;
return {
maxUserCount: corporateAssignmentsUsers,
userCount: corporateResults,
};
};
const corporateScores = corporates.reduce(
(accm, id) => ({
...accm,
[id]: getCorporateScores(id),
}),
{}
) as Record<string, UserCount>;
const consolidateScore = Object.values(corporateScores).reduce(
(acc: UserCount, { userCount, maxUserCount }: UserCount) => ({
userCount: acc.userCount + userCount,
maxUserCount: acc.maxUserCount + maxUserCount,
}),
{ userCount: 0, maxUserCount: 0 }
);
const getConsolidateScoreStr = (data: UserCount) =>
`${data.userCount}/${data.maxUserCount}`;
const columnHelper = createColumnHelper<TableData>();
const defaultColumns = [
columnHelper.accessor("user", {
header: "User",
id: "user",
cell: (info) => {
return <span>{info.getValue()}</span>;
},
}),
columnHelper.accessor("email", {
header: "Email",
id: "email",
cell: (info) => {
return <span>{info.getValue()}</span>;
},
}),
columnHelper.accessor("corporate", {
header: "Corporate",
id: "corporate",
cell: (info) => {
return <span>{info.getValue()}</span>;
},
}),
columnHelper.accessor("assignment", {
header: "Assignment",
id: "assignment",
cell: (info) => {
return <span>{info.getValue()}</span>;
},
}),
columnHelper.accessor("submitted", {
header: "Submitted",
id: "submitted",
cell: (info) => {
return (
<Checkbox isChecked={info.getValue()} disabled onChange={() => {}}>
<span></span>
</Checkbox>
);
},
}),
columnHelper.accessor("correct", {
header: "Correct",
id: "correct",
cell: (info) => {
return <span>{info.getValue()}</span>;
},
}),
columnHelper.accessor("date", {
header: "Date",
id: "date",
cell: (info) => {
const date = info.getValue();
if (date) {
return <span>{date.format("DD/MM/YYYY")}</span>;
}
return <span>{""}</span>;
},
}),
];
const {
rows: filteredRows,
renderSearch,
text: searchText,
} = useListSearch(searchFilters, tableResults);
const table = useReactTable({
data: filteredRows,
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
const areAllSelected = selectedCorporates.length === corporates.length;
const getStudentsConsolidateScore = () => {
if (tableResults.length === 0) {
return { highest: null, lowest: null };
}
// Find the student with the highest and lowest score
return tableResults.reduce(
(acc, curr) => {
if (curr.correct > acc.highest.correct) {
acc.highest = curr;
}
if (curr.correct < acc.lowest.correct) {
acc.lowest = curr;
}
return acc;
},
{ highest: tableResults[0], lowest: tableResults[0] }
);
};
const triggerDownload = async () => {
try {
setDownloading(true);
const res = await axios.post("/api/assignments/statistical/excel", {
ids: selectedCorporates,
...(startDate ? { startDate: startDate.toISOString() } : {}),
...(endDate ? { endDate: endDate.toISOString() } : {}),
searchText,
});
toast.success("Report ready!");
const link = document.createElement("a");
link.href = res.data;
// download should have worked but there are some CORS issues
// https://firebase.google.com/docs/storage/web/download-files#cors_configuration
// link.download="report.pdf";
link.target = "_blank";
link.rel = "noreferrer";
link.click();
setDownloading(false);
} catch (err) {
toast.error("Failed to display the report!");
console.error(err);
setDownloading(false);
}
};
const consolidateResults = getStudentsConsolidateScore();
return (
<>
<div className="flex flex-wrap gap-2 items-center text-center">
<IconCard
Icon={BsBank}
label="Consolidate"
value={getConsolidateScoreStr(consolidateScore)}
color="purple"
onClick={() => {
if (areAllSelected) {
setSelectedCorporates([]);
return;
}
setSelectedCorporates(corporates);
}}
isSelected={areAllSelected}
/>
{corporateRelevantUsers.map((group) => {
const isSelected = selectedCorporates.includes(group.id);
return (
<IconCard
key={group.id}
Icon={BsBank}
label={group.corporateInformation?.companyInformation?.name}
value={getConsolidateScoreStr(corporateScores[group.id])}
color="purple"
onClick={() => {
if (isSelected) {
setSelectedCorporates(
selectedCorporates.filter((x) => x !== group.id)
);
return;
}
setSelectedCorporates([...selectedCorporates, group.id]);
}}
isSelected={isSelected}
/>
);
})}
</div>
<div className="flex gap-3 w-full">
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">
Date
</label>
<ReactDatePicker
dateFormat="dd/MM/yyyy"
className="px-4 py-6 w-52 text-sm text-center font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed rounded-full border border-mti-gray-platinum focus:outline-none"
selected={startDate}
startDate={startDate}
endDate={endDate}
selectsRange
showMonthDropdown
onChange={([initialDate, finalDate]: [Date, Date]) => {
setStartDate(initialDate ?? moment("01/01/2023").toDate());
if (finalDate) {
// basicly selecting a final day works as if I'm selecting the first
// minute of that day. this way it covers the whole day
setEndDate(moment(finalDate).endOf("day").toDate());
return;
}
setEndDate(null);
}}
/>
</div>
{renderSearch()}
<div className="flex flex-col gap-3 justify-end">
<Button
className="max-w-[200px] h-[70px]"
variant="outline"
onClick={triggerDownload}
>
Download
</Button>
</div>
</div>
<div>
<table className="rounded-xl h-full bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="p-4 text-left" key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
key={row.id}
>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="flex flex-wrap gap-2 items-center text-center">
{consolidateResults.highest && (
<IconCard
onClick={() => {}}
Icon={BsPersonFill}
label={`Highest result: ${consolidateResults.highest.user}`}
color="purple"
/>
)}
{consolidateResults.lowest && (
<IconCard
onClick={() => {}}
Icon={BsPersonFill}
label={`Lowest result: ${consolidateResults.lowest.user}`}
color="purple"
/>
)}
</div>
</>
);
};
export default MasterStatistical;

View File

@@ -58,6 +58,12 @@ interface Props {
linkedCorporate?: CorporateUser | MasterCorporateUser; linkedCorporate?: CorporateUser | MasterCorporateUser;
} }
const studentHash = {
type: "student",
orderBy: "registrationDate",
size: 25,
};
export default function TeacherDashboard({user, linkedCorporate}: Props) { export default function TeacherDashboard({user, linkedCorporate}: Props) {
const [selectedUser, setSelectedUser] = useState<User>(); const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
@@ -67,26 +73,13 @@ export default function TeacherDashboard({user, linkedCorporate}: Props) {
const {permissions} = usePermissions(user.id); const {permissions} = usePermissions(user.id);
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assigner: user.id}); const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assigner: user.id});
const {users: students, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(userHashStudent); const {users: students, total: totalStudents, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(studentHash);
const appendUserFilters = useFilterStore((state) => state.appendUserFilter); const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter(); const router = useRouter();
const assignmentsGroups = useMemo(() => groups.filter((x) => x.admin === user.id || x.participants.includes(user.id)), [groups, user.id]); const assignmentsGroups = useMemo(() => groups.filter((x) => x.admin === user.id || x.participants.includes(user.id)), [groups, user.id]);
const assignmentsUsers = useMemo(
() =>
students.filter((x) =>
!!selectedUser
? groups
.filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants)
.includes(x.id)
: groups.flatMap((g) => g.participants).includes(x.id),
),
[groups, students, selectedUser],
);
useEffect(() => { useEffect(() => {
setShowModal(!!selectedUser && router.asPath === "/#"); setShowModal(!!selectedUser && router.asPath === "/#");
}, [selectedUser, router.asPath]); }, [selectedUser, router.asPath]);
@@ -150,7 +143,96 @@ export default function TeacherDashboard({user, linkedCorporate}: Props) {
return calculateAverageLevel(levels); return calculateAverageLevel(levels);
}; };
const DefaultDashboard = () => ( if (router.asPath === "/#students")
return (
<UserList
user={user}
type="student"
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Students ({total})</h2>
</div>
)}
/>
);
if (router.asPath === "/#assignments")
return (
<AssignmentsPage
assignments={assignments}
groups={assignmentsGroups}
user={user}
reloadAssignments={reloadAssignments}
isLoading={isAssignmentsLoading}
onBack={() => router.push("/")}
/>
);
if (router.asPath === "/#groups") return <GroupsList />;
return (
<>
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
<>
{selectedUser && (
<div className="w-full flex flex-col gap-8">
<UserCard
loggedInUser={user}
onClose={(shouldReload) => {
setSelectedUser(undefined);
if (shouldReload && selectedUser!.type === "student") reloadStudents();
}}
onViewStudents={
selectedUser.type === "corporate" || selectedUser.type === "teacher"
? () => {
appendUserFilters({
id: "view-students",
filter: (x: User) => x.type === "student",
});
appendUserFilters({
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.flatMap((g) => g.participants)
.includes(x.id),
});
router.push("/list/users");
}
: undefined
}
onViewTeachers={
selectedUser.type === "corporate" || selectedUser.type === "student"
? () => {
appendUserFilters({
id: "view-teachers",
filter: (x: User) => x.type === "teacher",
});
appendUserFilters({
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.flatMap((g) => g.participants)
.includes(x.id),
});
router.push("/list/users");
}
: undefined
}
user={selectedUser}
/>
</div>
)}
</>
</Modal>
<> <>
{linkedCorporate && ( {linkedCorporate && (
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1"> <div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
@@ -167,7 +249,7 @@ export default function TeacherDashboard({user, linkedCorporate}: Props) {
isLoading={isStudentsLoading} isLoading={isStudentsLoading}
Icon={BsPersonFill} Icon={BsPersonFill}
label="Students" label="Students"
value={students.length} value={totalStudents}
color="purple" color="purple"
/> />
<IconCard <IconCard
@@ -239,96 +321,6 @@ export default function TeacherDashboard({user, linkedCorporate}: Props) {
</div> </div>
</section> </section>
</> </>
);
return (
<>
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
<>
{selectedUser && (
<div className="w-full flex flex-col gap-8">
<UserCard
loggedInUser={user}
onClose={(shouldReload) => {
setSelectedUser(undefined);
if (shouldReload && selectedUser!.type === "student") reloadStudents();
}}
onViewStudents={
selectedUser.type === "corporate" || selectedUser.type === "teacher"
? () => {
appendUserFilters({
id: "view-students",
filter: (x: User) => x.type === "student",
});
appendUserFilters({
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.flatMap((g) => g.participants)
.includes(x.id),
});
router.push("/list/users");
}
: undefined
}
onViewTeachers={
selectedUser.type === "corporate" || selectedUser.type === "student"
? () => {
appendUserFilters({
id: "view-teachers",
filter: (x: User) => x.type === "teacher",
});
appendUserFilters({
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.flatMap((g) => g.participants)
.includes(x.id),
});
router.push("/list/users");
}
: undefined
}
user={selectedUser}
/>
</div>
)}
</>
</Modal>
{router.asPath === "/#students" && (
<UserList
user={user}
type="student"
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Students ({total})</h2>
</div>
)}
/>
)}
{router.asPath === "/#groups" && <GroupsList />}
{router.asPath === "/#assignments" && (
<AssignmentsPage
assignments={assignments}
groups={assignmentsGroups}
users={assignmentsUsers}
user={user}
reloadAssignments={reloadAssignments}
isLoading={isAssignmentsLoading}
onBack={() => router.push("/")}
/>
)}
{router.asPath === "/" && <DefaultDashboard />}
</> </>
); );
} }

View File

@@ -1,3 +1,4 @@
import useUsers from "@/hooks/useUsers";
import {Assignment} from "@/interfaces/results"; import {Assignment} from "@/interfaces/results";
import {CorporateUser, Group, User} from "@/interfaces/user"; import {CorporateUser, Group, User} from "@/interfaces/user";
import {getUserCompanyName} from "@/resources/user"; import {getUserCompanyName} from "@/resources/user";
@@ -20,26 +21,18 @@ interface Props {
assignments: Assignment[]; assignments: Assignment[];
corporateAssignments?: ({corporate?: CorporateUser} & Assignment)[]; corporateAssignments?: ({corporate?: CorporateUser} & Assignment)[];
groups: Group[]; groups: Group[];
users: User[];
isLoading: boolean; isLoading: boolean;
user: User; user: User;
onBack: () => void; onBack: () => void;
reloadAssignments: () => void; reloadAssignments: () => void;
} }
export default function AssignmentsPage({ export default function AssignmentsPage({assignments, corporateAssignments, user, groups, isLoading, onBack, reloadAssignments}: Props) {
assignments,
corporateAssignments,
user,
groups,
users,
isLoading,
onBack,
reloadAssignments,
}: Props) {
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>(); const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false); const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
const {users} = useUsers();
const displayAssignmentView = !!selectedAssignment && !isCreatingAssignment; const displayAssignmentView = !!selectedAssignment && !isCreatingAssignment;
const assignmentsPastExpiredStart = assignments.filter(startHasExpiredAssignmentFilter); const assignmentsPastExpiredStart = assignments.filter(startHasExpiredAssignmentFilter);
@@ -75,85 +68,49 @@ export default function AssignmentsPage({
<div className="w-full flex justify-between items-center"> <div className="w-full flex justify-between items-center">
<div <div
onClick={onBack} onClick={onBack}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300" className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
>
<BsArrowLeft className="text-xl" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
</div> </div>
<div <div
onClick={reloadAssignments} onClick={reloadAssignments}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300" className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
>
<span>Reload</span> <span>Reload</span>
<BsArrowRepeat <BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} />
className={clsx("text-xl", isLoading && "animate-spin")}
/>
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className="text-lg font-bold">Active Assignments Status</span> <span className="text-lg font-bold">Active Assignments Status</span>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span> <span>
<b>Total:</b>{" "} <b>Total:</b> {assignments.filter(activeAssignmentFilter).reduce((acc, curr) => acc + curr.results.length, 0)}/
{assignments {assignments.filter(activeAssignmentFilter).reduce((acc, curr) => curr.exams.length + acc, 0)}
.filter(activeAssignmentFilter)
.reduce((acc, curr) => acc + curr.results.length, 0)}
/
{assignments
.filter(activeAssignmentFilter)
.reduce((acc, curr) => curr.exams.length + acc, 0)}
</span> </span>
{Object.keys( {Object.keys(groupBy(corporateAssignments, (x) => x.corporate?.id)).map((x) => (
groupBy(corporateAssignments, (x) => x.corporate?.id)
).map((x) => (
<div key={x}> <div key={x}>
<span className="font-semibold"> <span className="font-semibold">{getUserCompanyName(users.find((u) => u.id === x)!, users, groups)}: </span>
{getUserCompanyName(
users.find((u) => u.id === x)!,
users,
groups
)}
:{" "}
</span>
<span> <span>
{groupBy(corporateAssignments, (x) => x.corporate?.id)[ {groupBy(corporateAssignments, (x) => x.corporate?.id)[x].reduce((acc, curr) => curr.results.length + acc, 0)}/
x {groupBy(corporateAssignments, (x) => x.corporate?.id)[x].reduce((acc, curr) => curr.exams.length + acc, 0)}
].reduce((acc, curr) => curr.results.length + acc, 0)}
/
{groupBy(corporateAssignments, (x) => x.corporate?.id)[
x
].reduce((acc, curr) => curr.exams.length + acc, 0)}
</span> </span>
</div> </div>
))} ))}
</div> </div>
</div> </div>
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold"> <h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeAssignmentFilter).length})</h2>
Active Assignments (
{assignments.filter(activeAssignmentFilter).length})
</h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{assignments.filter(activeAssignmentFilter).map((a) => ( {assignments.filter(activeAssignmentFilter).map((a) => (
<AssignmentCard <AssignmentCard {...a} users={users} onClick={() => setSelectedAssignment(a)} key={a.id} />
{...a}
users={users}
onClick={() => setSelectedAssignment(a)}
key={a.id}
/>
))} ))}
</div> </div>
</section> </section>
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold"> <h2 className="text-2xl font-semibold">Planned Assignments ({assignments.filter(futureAssignmentFilter).length})</h2>
Planned Assignments (
{assignments.filter(futureAssignmentFilter).length})
</h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<div <div
onClick={() => setIsCreatingAssignment(true)} onClick={() => setIsCreatingAssignment(true)}
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300" className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
>
<BsPlus className="text-6xl" /> <BsPlus className="text-6xl" />
<span className="text-lg">New Assignment</span> <span className="text-lg">New Assignment</span>
</div> </div>
@@ -171,9 +128,7 @@ export default function AssignmentsPage({
</div> </div>
</section> </section>
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold"> <h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastAssignmentFilter).length})</h2>
Past Assignments ({assignments.filter(pastAssignmentFilter).length})
</h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{assignments.filter(pastAssignmentFilter).map((a) => ( {assignments.filter(pastAssignmentFilter).map((a) => (
<AssignmentCard <AssignmentCard
@@ -190,9 +145,7 @@ export default function AssignmentsPage({
</div> </div>
</section> </section>
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold"> <h2 className="text-2xl font-semibold">Assignments start expired ({assignmentsPastExpiredStart.length})</h2>
Assignments start expired ({assignmentsPastExpiredStart.length})
</h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{assignments.filter(startHasExpiredAssignmentFilter).map((a) => ( {assignments.filter(startHasExpiredAssignmentFilter).map((a) => (
<AssignmentCard <AssignmentCard
@@ -209,10 +162,7 @@ export default function AssignmentsPage({
</div> </div>
</section> </section>
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold"> <h2 className="text-2xl font-semibold">Archived Assignments ({assignments.filter(archivedAssignmentFilter).length})</h2>
Archived Assignments (
{assignments.filter(archivedAssignmentFilter).length})
</h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{assignments.filter(archivedAssignmentFilter).map((a) => ( {assignments.filter(archivedAssignmentFilter).map((a) => (
<AssignmentCard <AssignmentCard

View File

@@ -382,6 +382,7 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
setChangedPrompt(true); setChangedPrompt(true);
} }
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contextWordLines]); }, [contextWordLines]);

View File

@@ -12,7 +12,7 @@ import {calculateAverageLevel} from "@/utils/score";
import {sortByModuleName} from "@/utils/moduleUtils"; import {sortByModuleName} from "@/utils/moduleUtils";
import {capitalize} from "lodash"; import {capitalize} from "lodash";
import ProfileSummary from "@/components/ProfileSummary"; import ProfileSummary from "@/components/ProfileSummary";
import {Variant} from "@/interfaces/exam"; import {ShuffleMap, Shuffles, Variant} from "@/interfaces/exam";
import useSessions, {Session} from "@/hooks/useSessions"; import useSessions, {Session} from "@/hooks/useSessions";
import SessionCard from "@/components/Medium/SessionCard"; import SessionCard from "@/components/Medium/SessionCard";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
@@ -41,6 +41,7 @@ export default function Selection({user, page, onStart, disableSelection = false
}; };
const loadSession = async (session: Session) => { const loadSession = async (session: Session) => {
state.setShuffles(session.userSolutions.map((x) => ({exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : []})));
state.setSelectedModules(session.selectedModules); state.setSelectedModules(session.selectedModules);
state.setExam(session.exam); state.setExam(session.exam);
state.setExams(session.exams); state.setExams(session.exams);

View File

@@ -16,9 +16,9 @@ export default function useUser({redirectTo = "", redirectIfFound = false} = {})
if ( if (
// If redirectIfFound is also set, redirect if the user was found // If redirectIfFound is also set, redirect if the user was found
(redirectIfFound && user && user.isVerified) || (redirectIfFound && user) ||
// If redirectTo is set, redirect if the user was not found. // If redirectTo is set, redirect if the user was not found.
(redirectTo && !redirectIfFound && (!user || (user && !user.isVerified))) (redirectTo && !redirectIfFound && !user)
) { ) {
Router.push(redirectTo); Router.push(redirectTo);
} }

View File

@@ -9,25 +9,21 @@ export const userHashStudent = { type: "student" } as { type: Type };
export const userHashTeacher = {type: "teacher"} as {type: Type}; export const userHashTeacher = {type: "teacher"} as {type: Type};
export const userHashCorporate = {type: "corporate"} as {type: Type}; export const userHashCorporate = {type: "corporate"} as {type: Type};
export default function useUsers(props?: {type?: Type; page?: number; size?: number}) { export default function useUsers(props?: {type?: string; page?: number; size?: number; orderBy?: string; direction?: "asc" | "desc"}) {
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false); const [isError, setIsError] = useState(false);
const [latestID, setLatestID] = useState<string>();
const [firstID, setFirstID] = useState<string>();
const [page, setPage] = useState(0);
const getData = () => { const getData = () => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (!!props) if (!!props)
Object.keys(props).forEach((key) => { Object.keys(props).forEach((key) => {
if (!!props[key as keyof typeof props]) params.append(key, props[key as keyof typeof props]!.toString()); if (props[key as keyof typeof props] !== undefined) params.append(key, props[key as keyof typeof props]!.toString());
}); });
if (!!latestID) params.append("latestID", latestID); console.log(params.toString());
if (!!firstID) params.append("firstID", firstID);
setIsLoading(true); setIsLoading(true);
axios axios
@@ -38,21 +34,8 @@ export default function useUsers(props?: {type?: Type; page?: number; size?: num
}) })
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
const next = () => {
setLatestID(users[users.length - 1]?.id);
setFirstID(undefined);
setPage((prev) => prev + 1);
};
const previous = () => {
setLatestID(undefined);
setFirstID(page > 1 ? users[0]?.id : undefined);
setPage((prev) => prev - 1);
};
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(getData, [props, latestID, firstID]); useEffect(getData, [props?.page, props?.size, props?.type, props?.orderBy, props?.direction]);
return {users, total, page, isLoading, isError, reload: getData, next, previous}; return {users, total, isLoading, isError, reload: getData};
} }

View File

@@ -152,6 +152,7 @@ export interface Group {
} }
export interface Code { export interface Code {
id: string;
code: string; code: string;
creator: string; creator: string;
expiryDate: Date; expiryDate: Date;

30
src/lib/mongodb.ts Normal file
View File

@@ -0,0 +1,30 @@
import {MongoClient} from "mongodb";
if (!process.env.MONGODB_URI) {
throw new Error('Invalid/Missing environment variable: "MONGODB_URI"');
}
const uri = process.env.MONGODB_URI || "";
const options = {};
let client: MongoClient;
if (process.env.NODE_ENV === "development") {
// In development mode, use a global variable so that the value
// is preserved across module reloads caused by HMR (Hot Module Replacement).
let globalWithMongo = global as typeof globalThis & {
_mongoClient?: MongoClient;
};
if (!globalWithMongo._mongoClient) {
globalWithMongo._mongoClient = new MongoClient(uri, options);
}
client = globalWithMongo._mongoClient;
} else {
// In production mode, it's best to not use a global variable.
client = new MongoClient(uri, options);
}
// Export a module-scoped MongoClient. By doing this in a
// separate module, the client can be shared across functions.
export default client;

5
src/mongodb.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
import {MongoClient} from "mongodb";
declare global {
var _mongoClientPromise: Promise<MongoClient>;
}

View File

@@ -58,13 +58,18 @@ export default function UserList({
const [sorter, setSorter] = useState<string>(); const [sorter, setSorter] = useState<string>();
const [displayUsers, setDisplayUsers] = useState<User[]>([]); const [displayUsers, setDisplayUsers] = useState<User[]>([]);
const [selectedUser, setSelectedUser] = useState<User>(); const [selectedUser, setSelectedUser] = useState<User>();
const [page, setPage] = useState(0);
const userHash = useMemo(() => ({ const userHash = useMemo(
() => ({
type, type,
size: 25, size: 16,
}), [type]) page,
}),
[type, page],
);
const {users, page, total, reload, next, previous} = useUsers(userHash); const {users, total, isLoading, reload} = useUsers(userHash);
const {permissions} = usePermissions(user?.id || ""); const {permissions} = usePermissions(user?.id || "");
const {balance} = useUserBalance(); const {balance} = useUserBalance();
const {groups} = useGroups({ const {groups} = useGroups({
@@ -620,13 +625,26 @@ export default function UserList({
</Button> </Button>
</div> </div>
<div className="w-full flex gap-2 justify-between"> <div className="w-full flex gap-2 justify-between">
<Button className="w-full max-w-[200px]" disabled={page === 0} onClick={previous}> <Button
isLoading={isLoading}
className="w-full max-w-[200px]"
disabled={page === 0}
onClick={() => setPage((prev) => prev - 1)}>
Previous Page Previous Page
</Button> </Button>
<Button className="w-full max-w-[200px]" disabled={page * 25 >= total} onClick={next}> <div className="flex items-center gap-4 w-fit">
<span className="opacity-80">
{page * 16 + 1} - {(page + 1) * 16 > total ? total : (page + 1) * 16} / {total}
</span>
<Button
isLoading={isLoading}
className="w-[200px]"
disabled={page * 16 >= total}
onClick={() => setPage((prev) => prev + 1)}>
Next Page Next Page
</Button> </Button>
</div> </div>
</div>
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full"> <table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead> <thead>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
@@ -639,7 +657,7 @@ export default function UserList({
</tr> </tr>
))} ))}
</thead> </thead>
<tbody className="px-2"> <tbody className="px-2 w-full">
{table.getRowModel().rows.map((row) => ( {table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}> <tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (

View File

@@ -1,16 +1,6 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { app, storage } from "@/firebase"; import { app, storage } from "@/firebase";
import { import client from "@/lib/mongodb";
getFirestore,
doc,
getDoc,
updateDoc,
getDocs,
query,
collection,
where,
documentId,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { ref, uploadBytes, getDownloadURL } from "firebase/storage"; import { ref, uploadBytes, getDownloadURL } from "firebase/storage";
@@ -29,6 +19,7 @@ interface GroupScoreSummaryHelper {
} }
interface AssignmentData { interface AssignmentData {
id: string;
assigner: string; assigner: string;
assignees: string[]; assignees: string[];
results: any; results: any;
@@ -41,7 +32,7 @@ interface AssignmentData {
name: string; name: string;
} }
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -392,9 +383,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
if (req.session.user) { if (req.session.user) {
const { id } = req.query as { id: string }; const { id } = req.query as { id: string };
const docSnap = await getDoc(doc(db, "assignments", id)); const assignment = await db.collection("assignments").findOne<AssignmentData>({ id: id });
const data = docSnap.data() as AssignmentData; if (!assignment) {
if (!data) {
res.status(400).end(); res.status(400).end();
return; return;
} }
@@ -411,19 +401,16 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
// return; // return;
// } // }
const docsSnap = await getDocs( const objectIds = assignment.assignees.map(id => id);
query(collection(db, "users"), where(documentId(), "in", data.assignees))
); const users = await db.collection("users").find<User>({
const users = docsSnap.docs.map((d) => ({ id: { $in: objectIds }
...d.data(), }).toArray();
id: d.id,
})) as User[]; const user = await db.collection("users").findOne<User>({ id: assignment.assigner });
const docUser = await getDoc(doc(db, "users", data.assigner));
if (docUser.exists()) {
// we'll need the user in order to get the user data (name, email, focus, etc); // we'll need the user in order to get the user data (name, email, focus, etc);
const user = docUser.data() as User; if (user && users) {
// generate the file ref for storage // generate the file ref for storage
const fileName = `${Date.now().toString()}.xlsx`; const fileName = `${Date.now().toString()}.xlsx`;
const refName = `assignment_report/${fileName}`; const refName = `assignment_report/${fileName}`;
@@ -433,11 +420,11 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
switch (user.type) { switch (user.type) {
case "teacher": case "teacher":
case "corporate": case "corporate":
return corporateAssignment(user as CorporateUser, data, users); return corporateAssignment(user as CorporateUser, assignment, users);
case "mastercorporate": case "mastercorporate":
return mastercorporateAssignment( return mastercorporateAssignment(
user as MasterCorporateUser, user as MasterCorporateUser,
data, assignment,
users users
); );
default: default:
@@ -447,18 +434,24 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const buffer = await getExcelFn(); const buffer = await getExcelFn();
// upload the pdf to storage // upload the pdf to storage
const snapshot = await uploadBytes(fileRef, buffer, { await uploadBytes(fileRef, buffer, {
contentType: contentType:
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
}); });
// update the stats entries with the pdf url to prevent duplication // update the stats entries with the pdf url to prevent duplication
await updateDoc(docSnap.ref, { await db.collection("assignments").updateOne(
{ id: assignment.id },
{
$set: {
excel: { excel: {
path: refName, path: refName,
version: process.env.EXCEL_VERSION, version: process.env.EXCEL_VERSION,
}, }
}); }
}
);
const url = await getDownloadURL(fileRef); const url = await getDownloadURL(fileRef);
res.status(200).end(url); res.status(200).end(url);

View File

@@ -1,6 +1,6 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import {app, storage} from "@/firebase"; import { storage } from "@/firebase";
import {getFirestore, doc, getDoc, updateDoc, getDocs, query, collection, where, documentId} from "firebase/firestore"; import client from "@/lib/mongodb";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import ReactPDF from "@react-pdf/renderer"; import ReactPDF from "@react-pdf/renderer";
@@ -22,7 +22,7 @@ interface GroupScoreSummaryHelper {
label: string; label: string;
sessions: string[]; sessions: string[];
} }
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -99,8 +99,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
if (req.session.user) { if (req.session.user) {
const { id } = req.query as { id: string }; const { id } = req.query as { id: string };
const docSnap = await getDoc(doc(db, "assignments", id)); const data = await db.collection("assignments").findOne({ id: id }) as {
const data = docSnap.data() as { id: string;
assigner: string; assigner: string;
assignees: string[]; assignees: string[];
results: any; results: any;
@@ -110,7 +110,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
path: string, path: string,
version: string, version: string,
}, },
}; } | null;
if (!data) { if (!data) {
res.status(400).end(); res.status(400).end();
return; return;
@@ -125,11 +126,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
} }
try { try {
const docUser = await getDoc(doc(db, "users", req.session.user.id)); const user = await db.collection("users").findOne<User>({ id: req.session.user.id });
if (docUser.exists()) {
// we'll need the user in order to get the user data (name, email, focus, etc);
const user = docUser.data() as User;
// we'll need the user in order to get the user data (name, email, focus, etc);
if (user) {
// generate the QR code for the report // generate the QR code for the report
const qrcode = await generateQRCode((req.headers.origin || "") + req.url); const qrcode = await generateQRCode((req.headers.origin || "") + req.url);
@@ -143,11 +143,9 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
return [...accm, ...stats]; return [...accm, ...stats];
}, []) as Stat[]; }, []) as Stat[];
const docsSnap = await getDocs(query(collection(db, "users"), where(documentId(), "in", data.assignees))); const users = await db.collection("users").find<User>({
const users = docsSnap.docs.map((d) => ({ id: { $in: data.assignees.map(id => id) }
...d.data(), }).toArray();
id: d.id,
})) as User[];
const flattenResultsWithGrade = flattenResults.map((e) => { const flattenResultsWithGrade = flattenResults.map((e) => {
const focus = users.find((u) => u.id === e.user)?.focus || "academic"; const focus = users.find((u) => u.id === e.user)?.focus || "academic";
@@ -300,36 +298,20 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const getInstitution = async () => { const getInstitution = async () => {
try { try {
// due to database inconsistencies, I'll be overprotective here // due to database inconsistencies, I'll be overprotective here
const assignerUserSnap = await getDoc(doc(db, "users", data.assigner)); const assignerUser = await db.collection("users").findOne<User>({ id: data.assigner });
if (assignerUserSnap.exists()) {
// we'll need the user in order to get the user data (name, email, focus, etc);
const assignerUser = assignerUserSnap.data() as User;
// we'll need the user in order to get the user data (name, email, focus, etc);
if (assignerUser) {
if (assignerUser.type === "teacher") { if (assignerUser.type === "teacher") {
// also search for groups where this user belongs // also search for groups where this user belongs
const queryGroups = query(collection(db, "groups"), where("participants", "array-contains", assignerUser.id)); const groups = await db.collection("groups")
const groupSnapshot = await getDocs(queryGroups); .find<Group>({ participants: assignerUser.id })
.toArray();
const groups = groupSnapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as Group[];
if (groups.length > 0) { if (groups.length > 0) {
const adminQuery = query( const admins = await db.collection("users")
collection(db, "users"), .find<CorporateUser>({ id: { $in: groups.map(g => g.admin).map(id => id)} })
where( .toArray();
documentId(),
"in",
groups.map((g) => g.admin),
),
);
const adminUsersSnap = await getDocs(adminQuery);
const admins = adminUsersSnap.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as CorporateUser[];
const adminData = admins.find((a) => a.corporateInformation?.companyInformation?.name); const adminData = admins.find((a) => a.corporateInformation?.companyInformation?.name);
if (adminData) { if (adminData) {
@@ -388,12 +370,18 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
}); });
// update the stats entries with the pdf url to prevent duplication // update the stats entries with the pdf url to prevent duplication
await updateDoc(docSnap.ref, { await db.collection("assignments").updateOne(
{ id: data.id },
{
$set: {
pdf: { pdf: {
path: refName, path: refName,
version: process.env.PDF_VERSION, version: process.env.PDF_VERSION,
}, }
}); }
}
);
const url = await getDownloadURL(fileRef); const url = await getDownloadURL(fileRef);
res.status(200).end(url); res.status(200).end(url);
return; return;
@@ -412,8 +400,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
if (req.session.user) { if (req.session.user) {
const { id } = req.query as { id: string }; const { id } = req.query as { id: string };
const docSnap = await getDoc(doc(db, "assignments", id)); const data = await db.collection("assignments").findOne({ id: id });
const data = docSnap.data();
if (!data) { if (!data) {
res.status(400).end(); res.status(400).end();
return; return;

View File

@@ -1,10 +1,10 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase"; import client from "@/lib/mongodb";
import { getFirestore, doc, getDoc, setDoc } from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -12,14 +12,17 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
// verify if it's a logged user that is trying to archive // verify if it's a logged user that is trying to archive
if (req.session.user) { if (req.session.user) {
const { id } = req.query as { id: string }; const { id } = req.query as { id: string };
const docSnap = await getDoc(doc(db, "assignments", id)); const docSnap = await db.collection("assignments").findOne({ id: id });
if (!docSnap.exists()) { if (!docSnap) {
res.status(404).json({ ok: false }); res.status(404).json({ ok: false });
return; return;
} }
await setDoc(docSnap.ref, { archived: true }, { merge: true }); await db.collection("assignments").updateOne(
{ id: docSnap.id },
{ $set: { archived: true } }
);
res.status(200).json({ ok: true }); res.status(200).json({ ok: true });
return; return;
} }

View File

@@ -1,12 +1,11 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase"; import client from "@/lib/mongodb";
import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc, deleteDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -26,15 +25,19 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
async function GET(req: NextApiRequest, res: NextApiResponse) { async function GET(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query; const {id} = req.query;
const snapshot = await getDoc(doc(db, "assignments", id as string)); const snapshot = await db.collection("assignments").findOne({ id: id as string });
res.status(200).json({...snapshot.data(), id: snapshot.id}); if (snapshot) {
res.status(200).json({...snapshot, id: snapshot.id});
}
} }
async function DELETE(req: NextApiRequest, res: NextApiResponse) { async function DELETE(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query; const {id} = req.query;
await deleteDoc(doc(db, "assignments", id as string)); await db.collection("assignments").deleteOne(
{ id: id as string }
);
res.status(200).json({ok: true}); res.status(200).json({ok: true});
} }
@@ -42,7 +45,10 @@ async function DELETE(req: NextApiRequest, res: NextApiResponse) {
async function PATCH(req: NextApiRequest, res: NextApiResponse) { async function PATCH(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query; const {id} = req.query;
await setDoc(doc(db, "assignments", id as string), {assigner: req.session.user?.id, ...req.body}, {merge: true}); await db.collection("assignments").updateOne(
{ id: id as string },
{ $set: {assigner: req.session.user?.id, ...req.body} }
);
res.status(200).json({ok: true}); res.status(200).json({ok: true});
} }

View File

@@ -1,10 +1,10 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase"; import client from "@/lib/mongodb";
import { getFirestore, doc, getDoc, setDoc } from "firebase/firestore"; import { ObjectId } from 'mongodb';
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -12,14 +12,18 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
// verify if it's a logged user that is trying to archive // verify if it's a logged user that is trying to archive
if (req.session.user) { if (req.session.user) {
const { id } = req.query as { id: string }; const { id } = req.query as { id: string };
const docSnap = await getDoc(doc(db, "assignments", id)); const doc = await db.collection("assignments").findOne({ id: id });
if (!docSnap.exists()) { if (!doc) {
res.status(404).json({ ok: false }); res.status(404).json({ ok: false });
return; return;
} }
await setDoc(docSnap.ref, { released: true }, { merge: true }); await db.collection("assignments").updateOne(
{ id: id },
{ $set: { released: true } }
);
res.status(200).json({ ok: true }); res.status(200).json({ ok: true });
return; return;
} }

View File

@@ -1,11 +1,10 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import moment from "moment"; import moment from "moment";
import { getFirestore, doc, getDoc, setDoc } from "firebase/firestore"; import client from "@/lib/mongodb";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -13,14 +12,13 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
// verify if it's a logged user that is trying to archive // verify if it's a logged user that is trying to archive
if (req.session.user) { if (req.session.user) {
const { id } = req.query as { id: string }; const { id } = req.query as { id: string };
const docSnap = await getDoc(doc(db, "assignments", id)); const data = await db.collection("assignments").findOne({ id: id });
if (!docSnap.exists()) { if (!data) {
res.status(404).json({ ok: false }); res.status(404).json({ ok: false });
return; return;
} }
const data = docSnap.data();
if (moment().isAfter(moment(data.startDate))) { if (moment().isAfter(moment(data.startDate))) {
res res
.status(400) .status(400)
@@ -28,11 +26,11 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
return; return;
} }
await setDoc( await db.collection("assignments").updateOne(
docSnap.ref, { id: id },
{ start: true }, { $set: { start: true } }
{ merge: true }
); );
res.status(200).json({ ok: true }); res.status(200).json({ ok: true });
return; return;
} }

View File

@@ -1,10 +1,9 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import {app} from "@/firebase"; import client from "@/lib/mongodb";
import {getFirestore, doc, getDoc, setDoc} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -12,14 +11,18 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
// verify if it's a logged user that is trying to archive // verify if it's a logged user that is trying to archive
if (req.session.user) { if (req.session.user) {
const { id } = req.query as { id: string }; const { id } = req.query as { id: string };
const docSnap = await getDoc(doc(db, "assignments", id)); const docSnap = await db.collection("assignments").findOne({ id: id });
if (!docSnap.exists()) { if (!docSnap) {
res.status(404).json({ ok: false }); res.status(404).json({ ok: false });
return; return;
} }
await setDoc(docSnap.ref, {archived: false}, {merge: true}); await db.collection("assignments").updateOne(
{ id: id },
{ $set: { archived: false } }
);
res.status(200).json({ ok: true }); res.status(200).json({ ok: true });
return; return;
} }

View File

@@ -1,22 +1,11 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {uuidv4} from "@firebase/util"; import {uniqBy} from "lodash";
import {Module} from "@/interfaces";
import {getExams} from "@/utils/exams.be";
import {Exam, InstructorGender, Variant} from "@/interfaces/exam";
import {capitalize, flatten, uniqBy} from "lodash";
import {User} from "@/interfaces/user";
import moment from "moment";
import {sendEmail} from "@/email";
import {getAllAssignersByCorporate} from "@/utils/groups.be"; import {getAllAssignersByCorporate} from "@/utils/groups.be";
import {getAssignmentsByAssigners} from "@/utils/assignments.be"; import {getAssignmentsByAssigners} from "@/utils/assignments.be";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {

View File

@@ -1,7 +1,6 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase"; import client from "@/lib/mongodb";
import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {uuidv4} from "@firebase/util"; import {uuidv4} from "@firebase/util";
@@ -14,7 +13,7 @@ import moment from "moment";
import {sendEmail} from "@/email"; import {sendEmail} from "@/email";
import {release} from "os"; import {release} from "os";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -31,13 +30,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
} }
async function GET(req: NextApiRequest, res: NextApiResponse) { async function GET(req: NextApiRequest, res: NextApiResponse) {
const q = query(collection(db, "assignments")); const docs = await db.collection("assignments").find({}).toArray();
const snapshot = await getDocs(q);
const docs = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
}));
res.status(200).json(docs); res.status(200).json(docs);
} }
@@ -135,7 +128,8 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
return; return;
} }
await setDoc(doc(db, "assignments", uuidv4()), { await db.collection("assignments").insertOne({
id: uuidv4(),
assigner: req.session.user?.id, assigner: req.session.user?.id,
assignees, assignees,
results: [], results: [],
@@ -147,10 +141,10 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
res.status(200).json({ok: true}); res.status(200).json({ok: true});
for (const assigneeID of assignees) { for (const assigneeID of assignees) {
const assigneeSnapshot = await getDoc(doc(db, "users", assigneeID));
if (!assigneeSnapshot.exists()) continue;
const assignee = {id: assigneeID, ...assigneeSnapshot.data()} as User; const assignee = await db.collection("users").findOne<User>({ id: assigneeID });
if (!assignee) continue;
const name = body.name; const name = body.name;
const teacher = req.session.user!; const teacher = req.session.user!;
const examModulesLabel = uniqBy(exams, (x) => x.module) const examModulesLabel = uniqBy(exams, (x) => x.module)

View File

@@ -1,6 +1,5 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { app, storage } from "@/firebase"; import { storage } from "@/firebase";
import { getFirestore } from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { ref, uploadBytes, getDownloadURL } from "firebase/storage"; import { ref, uploadBytes, getDownloadURL } from "firebase/storage";
@@ -12,11 +11,9 @@ import { checkAccess } from "@/utils/permissions";
import { getAssignmentsForCorporates } from "@/utils/assignments.be"; import { getAssignmentsForCorporates } from "@/utils/assignments.be";
import { search } from "@/utils/search"; import { search } from "@/utils/search";
import { getGradingSystem } from "@/utils/grading.be"; import { getGradingSystem } from "@/utils/grading.be";
import { Exam } from "@/interfaces/exam";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { calculateBandScore, getGradingLabel } from "@/utils/score"; import { calculateBandScore, getGradingLabel } from "@/utils/score";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -49,15 +46,22 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
// verify if it's a logged user that is trying to export // verify if it's a logged user that is trying to export
if (req.session.user) { if (req.session.user) {
if ( if (
!checkAccess(req.session.user, ["mastercorporate", "developer", "admin"]) !checkAccess(req.session.user, ["mastercorporate", "corporate", "developer", "admin"])
) { ) {
return res.status(401).json({ error: "Unauthorized" }); return res.status(403).json({ error: "Unauthorized" });
} }
const { ids, startDate, endDate, searchText } = req.body as { const {
ids,
startDate,
endDate,
searchText,
displaySelection = true,
} = req.body as {
ids: string[]; ids: string[];
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
searchText: string; searchText: string;
displaySelection?: boolean;
}; };
const startDateParsed = startDate ? new Date(startDate) : undefined; const startDateParsed = startDate ? new Date(startDate) : undefined;
const endDateParsed = endDate ? new Date(endDate) : undefined; const endDateParsed = endDate ? new Date(endDate) : undefined;
@@ -100,15 +104,18 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
"level", "level",
user.focus user.focus
); );
return { label: getGradingLabel(bandScore, gradingSystem?.steps || []), score: bandScore }; return {
label: getGradingLabel(bandScore, gradingSystem?.steps || []),
score: bandScore,
};
} }
} }
return { score: -1, label: "N/A" }; return { score: -1, label: "N/A" };
}; };
const tableResults = assignments.reduce( const tableResults = assignments
(accmA: TableData[], a: AssignmentWithCorporateId) => { .reduce((accmA: TableData[], a: AssignmentWithCorporateId) => {
const userResults = a.assignees.map((assignee) => { const userResults = a.assignees.map((assignee) => {
const userStats = const userStats =
a.results.find((r) => r.user === assignee)?.stats || []; a.results.find((r) => r.user === assignee)?.stats || [];
@@ -124,7 +131,6 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
total total
); );
console.log("Level", level); console.log("Level", level);
const commonData = { const commonData = {
user: userData?.name || "", user: userData?.name || "",
@@ -145,12 +151,14 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
}; };
} }
const partsData = userStats.every((e) => e.module === "level") ? userStats.reduce((acc, e, index) => { const partsData = userStats.every((e) => e.module === "level")
? userStats.reduce((acc, e, index) => {
return { return {
...acc, ...acc,
[`part${index}`]: `${e.score.correct}/${e.score.total}` [`part${index}`]: `${e.score.correct}/${e.score.total}`,
} };
}, {}) : {}; }, {})
: {};
return { return {
...commonData, ...commonData,
@@ -162,9 +170,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
}) as TableData[]; }) as TableData[];
return [...accmA, ...userResults]; return [...accmA, ...userResults];
}, }, [])
[] .sort((a, b) => b.score - a.score);
).sort((a,b) => b.score - a.score);
// Create a new workbook and add a worksheet // Create a new workbook and add a worksheet
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
@@ -179,10 +186,14 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
label: "Email", label: "Email",
value: (entry: TableData) => entry.email, value: (entry: TableData) => entry.email,
}, },
...(displaySelection
? [
{ {
label: "Corporate", label: "Corporate",
value: (entry: TableData) => entry.corporate, value: (entry: TableData) => entry.corporate,
}, },
]
: []),
{ {
label: "Assignment", label: "Assignment",
value: (entry: TableData) => entry.assignment, value: (entry: TableData) => entry.assignment,
@@ -229,7 +240,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const refName = `statistical/${fileName}`; const refName = `statistical/${fileName}`;
const fileRef = ref(storage, refName); const fileRef = ref(storage, refName);
// upload the pdf to storage // upload the pdf to storage
const snapshot = await uploadBytes(fileRef, buffer, { await uploadBytes(fileRef, buffer, {
contentType: contentType:
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
}); });

View File

@@ -1,12 +1,11 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase"; import client from "@/lib/mongodb";
import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc, deleteDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {uuidv4} from "@firebase/util"; import {uuidv4} from "@firebase/util";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return GET(req, res); if (req.method === "GET") return GET(req, res);
@@ -17,18 +16,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
async function GET(req: NextApiRequest, res: NextApiResponse) { async function GET(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query; const {id} = req.query;
const code = await db.collection("codes").findOne({ id: id as string });
const snapshot = await getDoc(doc(db, "codes", id as string)); res.status(200).json(code);
res.status(200).json({...snapshot.data(), id: snapshot.id});
} }
async function DELETE(req: NextApiRequest, res: NextApiResponse) { async function DELETE(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query; const {id} = req.query;
const code = await db.collection("codes").findOne({ id: id as string });
const snapshot = await getDoc(doc(db, "codes", id as string)); if (!code) return res.status(404).json;
if (!snapshot.exists()) return res.status(404).json; await db.collection("codes").deleteOne({ id: id as string });
await deleteDoc(snapshot.ref); res.status(200).json(code);
res.status(200).json({...snapshot.data(), id: snapshot.id});
} }

View File

@@ -1,15 +1,13 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import {app} from "@/firebase"; import client from "@/lib/mongodb";
import {getFirestore, setDoc, doc, query, collection, where, getDocs, getDoc, deleteDoc} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { Code, Group, Type } from "@/interfaces/user"; import { Code, Group, Type } from "@/interfaces/user";
import { PERMISSIONS } from "@/constants/userPermissions"; import { PERMISSIONS } from "@/constants/userPermissions";
import {uuidv4} from "@firebase/util";
import { prepareMailer, prepareMailOptions } from "@/email"; import { prepareMailer, prepareMailOptions } from "@/email";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -28,10 +26,9 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
} }
const { creator } = req.query as { creator?: string }; const { creator } = req.query as { creator?: string };
const q = query(collection(db, "codes"), where("creator", "==", creator || "")); const snapshot = await db.collection("codes").find(creator ? { creator: creator } : {}).toArray();
const snapshot = await getDocs(creator ? q : collection(db, "codes"));
res.status(200).json(snapshot.docs.map((doc) => doc.data())); res.status(200).json(snapshot);
} }
async function post(req: NextApiRequest, res: NextApiResponse) { async function post(req: NextApiRequest, res: NextApiResponse) {
@@ -56,19 +53,12 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
return; return;
} }
const codesGeneratedByUserSnapshot = await getDocs(query(collection(db, "codes"), where("creator", "==", req.session.user.id))); const userCodes = await db.collection("codes").find<Code>({ creator: req.session.user.id }).toArray()
const creatorGroupsSnapshot = await getDocs(query(collection(db, "groups"), where("admin", "==", req.session.user.id))); const creatorGroupsSnapshot = await db.collection("groups").find<Group>({ admin: req.session.user.id }).toArray()
const creatorGroups = (
creatorGroupsSnapshot.docs.map((x) => ({
...x.data(),
})) as Group[]
).filter((x) => x.name === "Students" || x.name === "Teachers" || x.name === "Corporate");
const creatorGroups = creatorGroupsSnapshot.filter((x) => x.name === "Students" || x.name === "Teachers" || x.name === "Corporate");
const usersInGroups = creatorGroups.flatMap((x) => x.participants); const usersInGroups = creatorGroups.flatMap((x) => x.participants);
const userCodes = codesGeneratedByUserSnapshot.docs.map((x) => ({
...x.data(),
})) as Code[];
if (req.session.user.type === "corporate") { if (req.session.user.type === "corporate") {
const totalCodes = userCodes.filter((x) => !x.userId || !usersInGroups.includes(x.userId)).length + usersInGroups.length + codes.length; const totalCodes = userCodes.filter((x) => !x.userId || !usersInGroups.includes(x.userId)).length + usersInGroups.length + codes.length;
@@ -77,8 +67,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
if (totalCodes > allowedCodes) { if (totalCodes > allowedCodes) {
res.status(403).json({ res.status(403).json({
ok: false, ok: false,
reason: `You have or would have exceeded your amount of allowed codes, you currently are allowed to generate ${ reason: `You have or would have exceeded your amount of allowed codes, you currently are allowed to generate ${allowedCodes - userCodes.length
allowedCodes - codesGeneratedByUserSnapshot.docs.length
} codes.`, } codes.`,
}); });
return; return;
@@ -86,7 +75,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
} }
const codePromises = codes.map(async (code, index) => { const codePromises = codes.map(async (code, index) => {
const codeRef = doc(db, "codes", code); const codeRef = await db.collection("codes").findOne<Code>({ id: code });
let codeInformation = { let codeInformation = {
type, type,
code, code,
@@ -114,16 +103,19 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
try { try {
await transport.sendMail(mailOptions); await transport.sendMail(mailOptions);
if (!previousCode) { if (!previousCode && codeRef) {
await setDoc( await db.collection("codes").updateOne(
codeRef, { id: codeRef.id },
{ {
$set: {
id: codeRef.id,
...codeInformation, ...codeInformation,
email: email.trim().toLowerCase(), email: email.trim().toLowerCase(),
name: name.trim(), name: name.trim(),
...(passport_id ? { passport_id: passport_id.trim() } : {}), ...(passport_id ? { passport_id: passport_id.trim() } : {}),
}
}, },
{merge: true}, { upsert: true }
); );
} }
@@ -132,7 +124,12 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
return false; return false;
} }
} else { } else {
await setDoc(codeRef, codeInformation); // upsert: true -> if it doesnt exist insert
await db.collection("codes").updateOne(
{ id: code },
{ $set: { id: code, ...codeInformation} },
{ upsert: true }
);
} }
}); });
@@ -150,10 +147,10 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
const codes = req.query.code as string[]; const codes = req.query.code as string[];
for (const code of codes) { for (const code of codes) {
const snapshot = await getDoc(doc(db, "codes", code as string)); const snapshot = await db.collection("codes").findOne<Code>({ id: code as string });
if (!snapshot.exists()) continue; if (!snapshot) continue;
await deleteDoc(snapshot.ref); await db.collection("codes").deleteOne({ id: snapshot.id });
} }
res.status(200).json({ codes }); res.status(200).json({ codes });

View File

@@ -1,18 +1,11 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import { app } from "@/firebase"; import client from "@/lib/mongodb";
import {
getFirestore,
doc,
getDoc,
deleteDoc,
setDoc,
} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {PERMISSIONS} from "@/constants/userPermissions"; import {PERMISSIONS} from "@/constants/userPermissions";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -29,15 +22,10 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
} }
const {id} = req.query as {id: string}; const {id} = req.query as {id: string};
const docSnap = await db.collection("discounts").findOne({id: id});
const docRef = doc(db, "discounts", id); if (docSnap) {
const docSnap = await getDoc(docRef); res.status(200).json(docSnap);
if (docSnap.exists()) {
res.status(200).json({
id: docSnap.id,
...docSnap.data(),
});
} else { } else {
res.status(404).json(undefined); res.status(404).json(undefined);
} }
@@ -50,17 +38,15 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
} }
const {id} = req.query as {id: string}; const {id} = req.query as {id: string};
const docSnap = await db.collection("discounts").findOne({id: id});
const docRef = doc(db, "discounts", id); if (docSnap) {
const docSnap = await getDoc(docRef);
if (docSnap.exists()) {
if (!["developer", "admin"].includes(req.session.user.type)) { if (!["developer", "admin"].includes(req.session.user.type)) {
res.status(403).json({ok: false}); res.status(403).json({ok: false});
return; return;
} }
await setDoc(docRef, req.body, { merge: true }); await db.collection("discounts").updateOne({id: id}, {$set: {id: id, ...req.body}}, {upsert: true});
res.status(200).json({ok: true}); res.status(200).json({ok: true});
} else { } else {
@@ -75,17 +61,15 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
} }
const {id} = req.query as {id: string}; const {id} = req.query as {id: string};
const docSnap = await db.collection("discounts").findOne({id: id});
const docRef = doc(db, "discounts", id); if (docSnap) {
const docSnap = await getDoc(docRef);
if (docSnap.exists()) {
if (!["developer", "admin"].includes(req.session.user.type)) { if (!["developer", "admin"].includes(req.session.user.type)) {
res.status(403).json({ok: false}); res.status(403).json({ok: false});
return; return;
} }
await deleteDoc(docRef); await db.collection("discounts").deleteOne({id: id});
res.status(200).json({ok: true}); res.status(200).json({ok: true});
} else { } else {

View File

@@ -1,22 +1,13 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase"; import client from "@/lib/mongodb";
import {
getFirestore,
collection,
getDocs,
setDoc,
doc,
getDoc,
deleteDoc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { Group } from "@/interfaces/user"; import { Group } from "@/interfaces/user";
import { Discount, Package } from "@/interfaces/paypal"; import { Discount, Package } from "@/interfaces/paypal";
import { v4 } from "uuid"; import { v4 } from "uuid";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -32,14 +23,8 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
return; return;
} }
const snapshot = await getDocs(collection(db, "discounts")); const snapshot = await db.collection("discounts").find({}).toArray();
res.status(200).json(snapshot);
res.status(200).json(
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})),
);
} }
async function post(req: NextApiRequest, res: NextApiResponse) { async function post(req: NextApiRequest, res: NextApiResponse) {
@@ -56,7 +41,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const body = req.body as Discount; const body = req.body as Discount;
await setDoc(doc(db, "discounts", v4()), body); await db.collection("discounts").insertOne({ ...body });
res.status(200).json({ ok: true }); res.status(200).json({ ok: true });
} }
@@ -71,10 +57,10 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
const discounts = req.query.discount as string[]; const discounts = req.query.discount as string[];
for (const discount of discounts) { for (const discount of discounts) {
const snapshot = await getDoc(doc(db, "discounts", discount as string)); const snapshot = await db.collection("discounts").findOne({ id: discount as string });
if (!snapshot.exists()) continue; if (!snapshot) continue;
await deleteDoc(snapshot.ref); await db.collection("discounts").deleteOne({ id: discount as string });
} }
res.status(200).json({ discounts }); res.status(200).json({ discounts });

View File

@@ -6,12 +6,13 @@ import axios, {AxiosResponse} from "axios";
import formidable from "formidable-serverless"; import formidable from "formidable-serverless";
import {ref, uploadBytes} from "firebase/storage"; import {ref, uploadBytes} from "firebase/storage";
import fs from "fs"; import fs from "fs";
import {app, storage} from "@/firebase"; import {storage} from "@/firebase";
import {doc, getDoc, getFirestore, setDoc} from "firebase/firestore"; import client from "@/lib/mongodb";
import {Stat} from "@/interfaces/user"; import {Stat} from "@/interfaces/user";
import {speakingReverseMarking} from "@/utils/score"; import {speakingReverseMarking} from "@/utils/score";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
function delay(ms: number) { function delay(ms: number) {
@@ -53,18 +54,21 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const correspondingStat = await getCorrespondingStat(fields.id, 1); const correspondingStat = await getCorrespondingStat(fields.id, 1);
const solutions = correspondingStat.solutions.map((x) => ({...x, evaluation: backendRequest.data, solution: uploadingAudios})); const solutions = correspondingStat.solutions.map((x) => ({...x, evaluation: backendRequest.data, solution: uploadingAudios}));
await setDoc( await db.collection("stats").updateOne(
doc(db, "stats", fields.id), { id: fields.id },
{ {
$set: {
id: fields.id,
solutions, solutions,
score: { score: {
correct: speakingReverseMarking[backendRequest.data.overall || 0] || 0, correct: speakingReverseMarking[backendRequest.data.overall || 0] || 0,
missing: 0, missing: 0,
total: 100, total: 100,
}, },
isDisabled: false, isDisabled: false
}
}, },
{merge: true}, { upsert: true }
); );
console.log("🌱 - Updated the DB"); console.log("🌱 - Updated the DB");
}); });
@@ -72,9 +76,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
async function getCorrespondingStat(id: string, index: number): Promise<Stat> { async function getCorrespondingStat(id: string, index: number): Promise<Stat> {
console.log(`🌱 - Try number ${index} - ${id}`); console.log(`🌱 - Try number ${index} - ${id}`);
const correspondingStat = await getDoc(doc(db, "stats", id)); const correspondingStat = await db.collection("stats").findOne<Stat>({ id: id });
if (correspondingStat.exists()) return {...correspondingStat.data(), id} as Stat; if (correspondingStat) return correspondingStat;
await delay(3 * 10000); await delay(3 * 10000);
return getCorrespondingStat(id, index + 1); return getCorrespondingStat(id, index + 1);
} }

View File

@@ -6,12 +6,12 @@ import axios, {AxiosResponse} from "axios";
import formidable from "formidable-serverless"; import formidable from "formidable-serverless";
import {getDownloadURL, ref, uploadBytes} from "firebase/storage"; import {getDownloadURL, ref, uploadBytes} from "firebase/storage";
import fs from "fs"; import fs from "fs";
import {app, storage} from "@/firebase"; import {storage} from "@/firebase";
import {doc, getDoc, getFirestore, setDoc} from "firebase/firestore"; import client from "@/lib/mongodb";
import {Stat} from "@/interfaces/user"; import {Stat} from "@/interfaces/user";
import {speakingReverseMarking} from "@/utils/score"; import {speakingReverseMarking} from "@/utils/score";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
function delay(ms: number) { function delay(ms: number) {
@@ -51,9 +51,10 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
solution: url, solution: url,
})); }));
await setDoc( await db.collection("stats").updateOne(
doc(db, "stats", fields.id), { id: fields.id },
{ {
id: fields.id,
solutions, solutions,
score: { score: {
correct: speakingReverseMarking[backendRequest.data.overall || 0] || 0, correct: speakingReverseMarking[backendRequest.data.overall || 0] || 0,
@@ -62,7 +63,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
}, },
isDisabled: false, isDisabled: false,
}, },
{merge: true}, {upsert: true},
); );
console.log("🌱 - Updated the DB"); console.log("🌱 - Updated the DB");
}); });
@@ -70,9 +71,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
async function getCorrespondingStat(id: string, index: number): Promise<Stat> { async function getCorrespondingStat(id: string, index: number): Promise<Stat> {
console.log(`🌱 - Try number ${index} - ${id}`); console.log(`🌱 - Try number ${index} - ${id}`);
const correspondingStat = await getDoc(doc(db, "stats", id)); const correspondingStat = await db.collection("stats").findOne<Stat>({ id: id });
if (correspondingStat.exists()) return {...correspondingStat.data(), id} as Stat; if (correspondingStat) return correspondingStat;
await delay(3 * 10000); await delay(3 * 10000);
return getCorrespondingStat(id, index + 1); return getCorrespondingStat(id, index + 1);
} }

View File

@@ -1,10 +1,9 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {getFirestore, doc, getDoc, setDoc} from "firebase/firestore"; import client from "@/lib/mongodb";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import axios, {AxiosResponse} from "axios"; import axios, {AxiosResponse} from "axios";
import {app} from "@/firebase";
import {Stat} from "@/interfaces/user"; import {Stat} from "@/interfaces/user";
import {writingReverseMarking} from "@/utils/score"; import {writingReverseMarking} from "@/utils/score";
@@ -19,7 +18,8 @@ function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
@@ -37,9 +37,10 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const correspondingStat = await getCorrespondingStat(req.body.id, 1); const correspondingStat = await getCorrespondingStat(req.body.id, 1);
const solutions = correspondingStat.solutions.map((x) => ({...x, evaluation: backendRequest.data})); const solutions = correspondingStat.solutions.map((x) => ({...x, evaluation: backendRequest.data}));
await setDoc( await db.collection("stats").updateOne(
doc(db, "stats", (req.body as Body).id), { id: (req.body as Body).id},
{ {
id: (req.body as Body).id,
solutions, solutions,
score: { score: {
correct: writingReverseMarking[backendRequest.data.overall], correct: writingReverseMarking[backendRequest.data.overall],
@@ -48,16 +49,17 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
}, },
isDisabled: false, isDisabled: false,
}, },
{merge: true}, {upsert: true},
); );
console.log("🌱 - Updated the DB"); console.log("🌱 - Updated the DB");
} }
async function getCorrespondingStat(id: string, index: number): Promise<Stat> { async function getCorrespondingStat(id: string, index: number): Promise<Stat> {
console.log(`🌱 - Try number ${index} - ${id}`); console.log(`🌱 - Try number ${index} - ${id}`);
const correspondingStat = await getDoc(doc(db, "stats", id)); const correspondingStat = await db.collection("stats").findOne<Stat>({ id: id});
if (correspondingStat) return correspondingStat;
if (correspondingStat.exists()) return {...correspondingStat.data(), id} as Stat;
await delay(3 * 10000); await delay(3 * 10000);
return getCorrespondingStat(id, index + 1); return getCorrespondingStat(id, index + 1);
} }

View File

@@ -1,12 +1,11 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase"; import client from "@/lib/mongodb";
import {getFirestore, doc, getDoc, deleteDoc, setDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {PERMISSIONS} from "@/constants/userPermissions"; import {PERMISSIONS} from "@/constants/userPermissions";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -24,13 +23,11 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
const {module, id} = req.query as {module: string; id: string}; const {module, id} = req.query as {module: string; id: string};
const docRef = doc(db, module, id); const docSnap = await db.collection(module).findOne({ id: id});
const docSnap = await getDoc(docRef);
if (docSnap.exists()) { if (docSnap) {
res.status(200).json({ res.status(200).json({
id: docSnap.id, ...docSnap,
...docSnap.data(),
module, module,
}); });
} else { } else {
@@ -46,11 +43,14 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
const {module, id} = req.query as {module: string; id: string}; const {module, id} = req.query as {module: string; id: string};
const docRef = doc(db, module, id); const docSnap = await db.collection(module).findOne({ id: id});
const docSnap = await getDoc(docRef);
if (docSnap.exists()) { if (docSnap) {
await setDoc(docRef, req.body, {merge: true}); await db.collection(module).updateOne(
{ id: id},
{ $set: { id: id, ...req.body }},
{ upsert: true }
);
res.status(200).json({ok: true}); res.status(200).json({ok: true});
} else { } else {
res.status(404).json({ok: false}); res.status(404).json({ok: false});
@@ -65,16 +65,15 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
const {module, id} = req.query as {module: string; id: string}; const {module, id} = req.query as {module: string; id: string};
const docRef = doc(db, module, id); const docSnap = await db.collection(module).findOne({ id: id});
const docSnap = await getDoc(docRef);
if (docSnap.exists()) { if (docSnap) {
if (!PERMISSIONS.examManagement.delete.includes(req.session.user.type)) { if (!PERMISSIONS.examManagement.delete.includes(req.session.user.type)) {
res.status(403).json({ok: false}); res.status(403).json({ok: false});
return; return;
} }
await deleteDoc(docRef); await db.collection(module).deleteOne({ id: id });
res.status(200).json({ok: true}); res.status(200).json({ok: true});
} else { } else {

View File

@@ -1,17 +1,11 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, collection, getDocs, query, where} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {shuffle} from "lodash";
import {Difficulty, Exam} from "@/interfaces/exam"; import {Difficulty, Exam} from "@/interfaces/exam";
import {Stat} from "@/interfaces/user";
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import axios from "axios"; import axios from "axios";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {

View File

@@ -1,17 +1,9 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, collection, getDocs, query, where} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {shuffle} from "lodash";
import {Difficulty, Exam} from "@/interfaces/exam";
import {Stat} from "@/interfaces/user";
import {Module} from "@/interfaces";
import axios from "axios"; import axios from "axios";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {

View File

@@ -1,14 +1,14 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import {app} from "@/firebase"; import client from "@/lib/mongodb";
import {getFirestore, setDoc, doc, runTransaction, collection, query, where, getDocs} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { Exam, InstructorGender, Variant } from "@/interfaces/exam"; import { Exam, InstructorGender, Variant } from "@/interfaces/exam";
import { getExams } from "@/utils/exams.be"; import { getExams } from "@/utils/exams.be";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { getUserCorporate } from "@/utils/groups.be"; import { getUserCorporate } from "@/utils/groups.be";
const db = getFirestore(app);
const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -45,6 +45,8 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
const { module } = req.query as { module: string }; const { module } = req.query as { module: string };
const corporate = await getUserCorporate(req.session.user.id); const corporate = await getUserCorporate(req.session.user.id);
const session = client.startSession();
try { try {
const exam = { const exam = {
...req.body, ...req.body,
@@ -57,20 +59,25 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
await runTransaction(db, async (transaction) => { await session.withTransaction(async () => {
const docRef = doc(db, module, req.body.id); const docSnap = await db.collection(module).findOne({ id: req.body.id }, { session });
const docSnap = await transaction.get(docRef);
if (docSnap.exists()) { if (docSnap) {
throw new Error("Name already exists"); throw new Error("Name already exists");
} }
const newDocRef = doc(db, module, req.body.id); await db.collection(module).insertOne(
transaction.set(newDocRef, exam); { id: req.body.id, ...exam },
{ session }
);
}); });
res.status(200).json(exam); res.status(200).json(exam);
} catch (error) { } catch (error) {
console.error("Transaction failed: ", error); console.error("Transaction failed: ", error);
res.status(500).json({ ok: false, error: (error as any).message }); res.status(500).json({ ok: false, error: (error as any).message });
} finally {
session.endSession();
} }
} }

View File

@@ -1,14 +1,13 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase"; import client from "@/lib/mongodb";
import {getFirestore, collection, getDocs, query, where} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {flatten} from "lodash"; import {flatten} from "lodash";
import {Exam} from "@/interfaces/exam"; import {Exam} from "@/interfaces/exam";
import {MODULE_ARRAY} from "@/utils/moduleUtils"; import {MODULE_ARRAY} from "@/utils/moduleUtils";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -25,16 +24,12 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
} }
const moduleExamsPromises = MODULE_ARRAY.map(async (module) => { const moduleExamsPromises = MODULE_ARRAY.map(async (module) => {
const moduleRef = collection(db, module); const snapshot = await db.collection(module).find<Exam>({ isDiagnostic: false }).toArray();
const q = query(moduleRef, where("isDiagnostic", "==", false)); return snapshot.map((doc) => ({
const snapshot = await getDocs(q); ...doc,
return snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
module, module,
})) as Exam[]; }));
}); });
const moduleExams = await Promise.all(moduleExamsPromises); const moduleExams = await Promise.all(moduleExamsPromises);

View File

@@ -1,7 +1,6 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase"; import {app} from "@/firebase";
import {getFirestore, collection, getDocs, setDoc, doc, getDoc, deleteDoc, query} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {CorporateUser, Group} from "@/interfaces/user"; import {CorporateUser, Group} from "@/interfaces/user";
@@ -14,10 +13,11 @@ import {getUserCorporate} from "@/utils/groups.be";
import {Grading} from "@/interfaces"; import {Grading} from "@/interfaces";
import {getGroupsForUser} from "@/utils/groups.be"; import {getGroupsForUser} from "@/utils/groups.be";
import {uniq} from "lodash"; import {uniq} from "lodash";
import {getUser} from "@/utils/users.be"; import {getSpecificUsers, getUser} from "@/utils/users.be";
import {getGradingSystem} from "@/utils/grading.be"; import {getGradingSystem} from "@/utils/grading.be";
import client from "@/lib/mongodb";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -36,6 +36,14 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
return res.status(200).json(gradingSystem); return res.status(200).json(gradingSystem);
} }
async function updateGrading(id: string, body: Grading) {
if (await db.collection("grading").findOne({id})) {
await db.collection("grading").updateOne({id}, {$set: body});
} else {
await db.collection("grading").insertOne({id, ...body});
}
}
async function post(req: NextApiRequest, res: NextApiResponse) { async function post(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ok: false}); res.status(401).json({ok: false});
@@ -49,16 +57,16 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
}); });
const body = req.body as Grading; const body = req.body as Grading;
await setDoc(doc(db, "grading", req.session.user.id), body); await updateGrading(req.session.user.id, body);
if (req.session.user.type === "mastercorporate") { if (req.session.user.type === "mastercorporate") {
const groups = await getGroupsForUser(req.session.user.id); const groups = await getGroupsForUser(req.session.user.id);
const participants = uniq(groups.flatMap((x) => x.participants)); const participants = uniq(groups.flatMap((x) => x.participants));
const participantUsers = await Promise.all(participants.map(getUser)); const participantUsers = await getSpecificUsers(participants);
const corporateUsers = participantUsers.filter((x) => x.type === "corporate") as CorporateUser[]; const corporateUsers = participantUsers.filter((x) => x?.type === "corporate") as CorporateUser[];
await Promise.all(corporateUsers.map(async (g) => await setDoc(doc(db, "grading", g.id), body))); await Promise.all(corporateUsers.map(async (g) => await updateGrading(g.id, body)));
} }
res.status(200).json({ok: true}); res.status(200).json({ok: true});

View File

@@ -1,21 +1,12 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase"; import client from "@/lib/mongodb";
import {
getFirestore,
collection,
getDocs,
getDoc,
doc,
deleteDoc,
setDoc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { Group } from "@/interfaces/user"; import { Group } from "@/interfaces/user";
import { updateExpiryDateOnGroup } from "@/utils/groups.be"; import { updateExpiryDateOnGroup } from "@/utils/groups.be";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -35,10 +26,10 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.query as { id: string }; const { id } = req.query as { id: string };
const snapshot = await getDoc(doc(db, "groups", id)); const snapshot = await db.collection("groups").findOne({ id: id});
if (snapshot.exists()) { if (snapshot) {
res.status(200).json({ ...snapshot.data(), id: snapshot.id }); res.status(200).json({ ...snapshot });
} else { } else {
res.status(404).json(undefined); res.status(404).json(undefined);
} }
@@ -51,9 +42,12 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
} }
const { id } = req.query as { id: string }; const { id } = req.query as { id: string };
const group = await db.collection("groups").findOne<Group>({id: id});
const snapshot = await getDoc(doc(db, "groups", id)); if (!group) {
const group = { ...snapshot.data(), id: snapshot.id } as Group; res.status(404);
return;
}
const user = req.session.user; const user = req.session.user;
if ( if (
@@ -61,7 +55,7 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
user.type === "developer" || user.type === "developer" ||
user.id === group.admin user.id === group.admin
) { ) {
await deleteDoc(snapshot.ref); await db.collection("groups").deleteOne({ id: id });
res.status(200).json({ ok: true }); res.status(200).json({ ok: true });
return; return;
@@ -78,8 +72,11 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.query as { id: string }; const { id } = req.query as { id: string };
const snapshot = await getDoc(doc(db, "groups", id)); const group = await db.collection("groups").findOne<Group>({id: id});
const group = { ...snapshot.data(), id: snapshot.id } as Group; if (!group) {
res.status(404);
return;
}
const user = req.session.user; const user = req.session.user;
if ( if (
@@ -98,7 +95,11 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
); );
} }
await setDoc(snapshot.ref, req.body, { merge: true }); await db.collection("grading").updateOne(
{ id: req.session.user.id },
{ $set: {id: id, ...req.body} },
{ upsert: true }
);
res.status(200).json({ ok: true }); res.status(200).json({ ok: true });
return; return;

View File

@@ -1,16 +1,14 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase"; import client from "@/lib/mongodb";
import {getFirestore, collection, getDocs, setDoc, doc, query, where} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {Group} from "@/interfaces/user"; import {Group} from "@/interfaces/user";
import {v4} from "uuid"; import {v4} from "uuid";
import {updateExpiryDateOnGroup, getGroupsForUser} from "@/utils/groups.be"; import {updateExpiryDateOnGroup, getGroupsForUser} from "@/utils/groups.be";
import {uniq, uniqBy} from "lodash"; import {uniq, uniqBy} from "lodash";
import {getUser} from "@/utils/users.be";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -41,10 +39,11 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
await Promise.all(body.participants.map(async (p) => await updateExpiryDateOnGroup(p, body.admin))); await Promise.all(body.participants.map(async (p) => await updateExpiryDateOnGroup(p, body.admin)));
await setDoc(doc(db, "groups", v4()), { await db.collection("groups").insertOne({
id: v4(),
name: body.name, name: body.name,
admin: body.admin, admin: body.admin,
participants: body.participants, participants: body.participants,
}); })
res.status(200).json({ok: true}); res.status(200).json({ok: true});
} }

View File

@@ -1,19 +1,11 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase"; import client from "@/lib/mongodb";
import {
getFirestore,
getDoc,
doc,
deleteDoc,
setDoc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { Ticket } from "@/interfaces/ticket";
import { Invite } from "@/interfaces/invite"; import { Invite } from "@/interfaces/invite";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -33,10 +25,10 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.query as { id: string }; const { id } = req.query as { id: string };
const snapshot = await getDoc(doc(db, "invites", id)); const snapshot = await db.collection("invites").findOne({ id: id });
if (snapshot.exists()) { if (snapshot) {
res.status(200).json({ ...snapshot.data(), id: snapshot.id }); res.status(200).json(snapshot);
} else { } else {
res.status(404).json(undefined); res.status(404).json(undefined);
} }
@@ -50,12 +42,15 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.query as { id: string }; const { id } = req.query as { id: string };
const snapshot = await getDoc(doc(db, "invites", id)); const snapshot = await db.collection("invites").findOne<Invite>({ id: id });
const data = snapshot.data() as Invite; if(!snapshot){
res.status(404);
return;
}
const user = req.session.user; const user = req.session.user;
if (user.type === "admin" || user.type === "developer") { if (user.type === "admin" || user.type === "developer") {
await deleteDoc(snapshot.ref); await db.collection("invites").deleteOne({ id: id });
res.status(200).json({ ok: true }); res.status(200).json({ ok: true });
return; return;
} }
@@ -70,11 +65,14 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
} }
const { id } = req.query as { id: string }; const { id } = req.query as { id: string };
const snapshot = await getDoc(doc(db, "invites", id));
const user = req.session.user; const user = req.session.user;
if (user.type === "admin" || user.type === "developer") { if (user.type === "admin" || user.type === "developer") {
await setDoc(snapshot.ref, req.body, { merge: true }); await db.collection("invites").updateOne(
{ id: id },
{ $set: {id: id, ...req.body} },
{ upsert: true }
);
return res.status(200).json({ ok: true }); return res.status(200).json({ ok: true });
} }

View File

@@ -1,17 +1,15 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import {app} from "@/firebase"; import client from "@/lib/mongodb";
import {getFirestore, getDoc, doc, deleteDoc, setDoc, getDocs, collection, where, query} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import {Ticket} from "@/interfaces/ticket";
import { Invite } from "@/interfaces/invite"; import { Invite } from "@/interfaces/invite";
import { CorporateUser, Group, User } from "@/interfaces/user"; import { CorporateUser, Group, User } from "@/interfaces/user";
import { v4 } from "uuid"; import { v4 } from "uuid";
import { sendEmail } from "@/email"; import { sendEmail } from "@/email";
import { updateExpiryDateOnGroup } from "@/utils/groups.be"; import { updateExpiryDateOnGroup } from "@/utils/groups.be";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -22,12 +20,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
} }
async function addToInviterGroup(user: User, invitedBy: User) { async function addToInviterGroup(user: User, invitedBy: User) {
const invitedByGroupsRef = await getDocs(query(collection(db, "groups"), where("admin", "==", invitedBy.id))); const invitedByGroups = await db.collection("groups").find<Group>({ admin: invitedBy.id }).toArray();
const invitedByGroups = invitedByGroupsRef.docs.map((g) => ({
...g.data(),
id: g.id,
})) as Group[];
const typeGroupName = user.type === "student" ? "Students" : user.type === "teacher" ? "Teachers" : undefined; const typeGroupName = user.type === "student" ? "Students" : user.type === "teacher" ? "Teachers" : undefined;
if (typeGroupName) { if (typeGroupName) {
@@ -38,14 +31,18 @@ async function addToInviterGroup(user: User, invitedBy: User) {
participants: [], participants: [],
disableEditing: true, disableEditing: true,
}; };
await setDoc(
doc(db, "groups", typeGroup.id), await db.collection("groups").updateOne(
{ id: typeGroup.id },
{ {
$set: {
...typeGroup, ...typeGroup,
participants: [...typeGroup.participants.filter((x) => x !== user.id), user.id], participants: [...typeGroup.participants.filter((x) => x !== user.id), user.id],
}, },
{merge: true}, },
{ upsert: true }
); );
} }
const invitationsGroup: Group = invitedByGroups.find((g) => g.name === "Invited") || { const invitationsGroup: Group = invitedByGroups.find((g) => g.name === "Invited") || {
@@ -55,29 +52,35 @@ async function addToInviterGroup(user: User, invitedBy: User) {
participants: [], participants: [],
disableEditing: true, disableEditing: true,
}; };
await setDoc(
doc(db, "groups", invitationsGroup.id), await db.collection("groups").updateOne(
{ id: invitationsGroup.id },
{ {
$set: {
...invitationsGroup, ...invitationsGroup,
participants: [...invitationsGroup.participants.filter((x) => x !== user.id), user.id], participants: [...invitationsGroup.participants.filter((x) => x !== user.id), user.id],
}
}, },
{ { upsert: true }
merge: true,
},
); );
} }
async function deleteFromPreviousCorporateGroups(user: User, invitedBy: User) { async function deleteFromPreviousCorporateGroups(user: User, invitedBy: User) {
const corporatesRef = await getDocs(query(collection(db, "users"), where("type", "==", "corporate"))); const corporatesRef = await db.collection("users").find<CorporateUser>({ type: "corporate" }).toArray();
const corporates = (corporatesRef.docs.map((x) => ({...x.data(), id: x.id})) as CorporateUser[]).filter((x) => x.id !== invitedBy.id); const corporates = corporatesRef.filter((x) => x.id !== invitedBy.id);
const userGroupsRef = await getDocs(query(collection(db, "groups"), where("participants", "array-contains", user.id))); const userGroups = await db.collection("groups").find<Group>({
const userGroups = userGroupsRef.docs.map((x) => ({...x.data(), id: x.id})) as Group[]; participants: user.id
}).toArray();
const corporateGroups = userGroups.filter((x) => corporates.map((c) => c.id).includes(x.admin)); const corporateGroups = userGroups.filter((x) => corporates.map((c) => c.id).includes(x.admin));
await Promise.all( await Promise.all(
corporateGroups.map(async (group) => { corporateGroups.map(async (group) => {
await setDoc(doc(db, "groups", group.id), {participants: group.participants.filter((x) => x !== user.id)}, {merge: true}); await db.collection("groups").updateOne(
{ id: group.id },
{ $set: { participants: group.participants.filter((x) => x !== user.id) } },
{ upsert: true }
);
}), }),
); );
} }
@@ -89,20 +92,18 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
} }
const { id } = req.query as { id: string }; const { id } = req.query as { id: string };
const snapshot = await getDoc(doc(db, "invites", id)); const invite = await db.collection("invites").findOne<Invite>({ id: id});
if (snapshot.exists()) { if (invite) {
const invite = {...snapshot.data(), id: snapshot.id} as Invite;
if (invite.to !== req.session.user.id) return res.status(403).json({ ok: false }); if (invite.to !== req.session.user.id) return res.status(403).json({ ok: false });
await deleteDoc(snapshot.ref); await db.collection("invites").deleteOne({ id: id });
const invitedByRef = await getDoc(doc(db, "users", invite.from));
if (!invitedByRef.exists()) return res.status(404).json({ok: false}); const invitedBy = await db.collection("users").findOne<User>({ id: invite.from});
if (!invitedBy) return res.status(404).json({ ok: false });
await updateExpiryDateOnGroup(invite.to, invite.from); await updateExpiryDateOnGroup(invite.to, invite.from);
const invitedBy = {...invitedByRef.data(), id: invitedByRef.id} as User;
if (invitedBy.type === "corporate") await deleteFromPreviousCorporateGroups(req.session.user, invitedBy); if (invitedBy.type === "corporate") await deleteFromPreviousCorporateGroups(req.session.user, invitedBy);
await addToInviterGroup(req.session.user, invitedBy); await addToInviterGroup(req.session.user, invitedBy);

View File

@@ -1,16 +1,13 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase"; import client from "@/lib/mongodb";
import {getFirestore, getDoc, doc, deleteDoc, setDoc, getDocs, collection, where, query} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {Ticket} from "@/interfaces/ticket";
import {Invite} from "@/interfaces/invite"; import {Invite} from "@/interfaces/invite";
import {Group, User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import {v4} from "uuid";
import {sendEmail} from "@/email"; import {sendEmail} from "@/email";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -27,17 +24,14 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
} }
const {id} = req.query as {id: string}; const {id} = req.query as {id: string};
const snapshot = await getDoc(doc(db, "invites", id)); const invite = await db.collection("invites").findOne<Invite>({ id: id});
if (snapshot.exists()) { if (invite) {
const invite = {...snapshot.data(), id: snapshot.id} as Invite;
if (invite.to !== req.session.user.id) return res.status(403).json({ok: false}); if (invite.to !== req.session.user.id) return res.status(403).json({ok: false});
await deleteDoc(snapshot.ref); await db.collection("invites").deleteOne({ id: id });
const invitedByRef = await getDoc(doc(db, "users", invite.from)); const invitedBy = await db.collection("users").findOne<User>({ id: invite.from });
if (!invitedByRef.exists()) return res.status(404).json({ok: false}); if (!invitedBy) return res.status(404).json({ok: false});
const invitedBy = {...invitedByRef.data(), id: invitedByRef.id} as User;
try { try {
await sendEmail( await sendEmail(

View File

@@ -1,16 +1,15 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import {sendEmail} from "@/email"; import {sendEmail} from "@/email";
import {app} from "@/firebase";
import {Invite} from "@/interfaces/invite"; import {Invite} from "@/interfaces/invite";
import {Ticket} from "@/interfaces/ticket"; import {Ticket} from "@/interfaces/ticket";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {collection, doc, getDoc, getDocs, getFirestore, setDoc} from "firebase/firestore"; import client from "@/lib/mongodb";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import ShortUniqueId from "short-unique-id"; import ShortUniqueId from "short-unique-id";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -25,29 +24,20 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
} }
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
const snapshot = await getDocs(collection(db, "invites")); const snapshot = await db.collection("invites").find({}).toArray();
res.status(200).json(snapshot);
res.status(200).json(
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})),
);
} }
async function post(req: NextApiRequest, res: NextApiResponse) { async function post(req: NextApiRequest, res: NextApiResponse) {
const body = req.body as Invite; const body = req.body as Invite;
const existingInvites = (await getDocs(collection(db, "invites"))).docs.map((x) => ({...x.data(), id: x.id})) as Invite[]; const existingInvites = await db.collection("invites").find<Invite>({}).toArray();
const invitedRef = await getDoc(doc(db, "users", body.to)); const invited = await db.collection("users").findOne<User>({ id: body.to});
if (!invitedRef.exists()) return res.status(404).json({ok: false}); if (!invited) return res.status(404).json({ok: false});
const invitedByRef = await getDoc(doc(db, "users", body.from)); const invitedBy = await db.collection("users").findOne<User>({ id: body.from});
if (!invitedByRef.exists()) return res.status(404).json({ok: false}); if (!invitedBy) return res.status(404).json({ok: false});
const invited = {...invitedRef.data(), id: invitedRef.id} as User;
const invitedBy = {...invitedByRef.data(), id: invitedByRef.id} as User;
try { try {
await sendEmail( await sendEmail(
@@ -67,7 +57,11 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
if (existingInvites.filter((i) => i.to === body.to && i.from === body.from).length == 0) { if (existingInvites.filter((i) => i.to === body.to && i.from === body.from).length == 0) {
const shortUID = new ShortUniqueId(); const shortUID = new ShortUniqueId();
await setDoc(doc(db, "invites", body.id || shortUID.randomUUID(8)), body); await db.collection("invites").updateOne(
{ id: body.id || shortUID.randomUUID(8)},
{ $set: body },
{ upsert: true}
);
} }
res.status(200).json({ok: true}); res.status(200).json({ok: true});

View File

@@ -4,10 +4,9 @@ import { app } from "@/firebase";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import { getFirestore, getDoc, doc } from "firebase/firestore"; import client from "@/lib/mongodb";
const auth = getAuth(app); const auth = getAuth(app);
const db = getFirestore(app);
export default withIronSessionApiRoute(login, sessionOptions); export default withIronSessionApiRoute(login, sessionOptions);
@@ -18,13 +17,10 @@ async function login(req: NextApiRequest, res: NextApiResponse) {
.then(async (userCredentials) => { .then(async (userCredentials) => {
const userId = userCredentials.user.uid; const userId = userCredentials.user.uid;
const docUser = await getDoc(doc(db, "users", userId)); const db = client.db(process.env.MONGODB_DB);
if (!docUser.exists()) { const user = await db.collection("users").findOne<User>({id: userId});
res.status(401).json({ error: 401, message: "User does not exist!" });
return;
}
const user = docUser.data() as User; if (!user) return res.status(401).json({error: 401, message: "User does not exist!"});
req.session.user = {...user, id: userId}; req.session.user = {...user, id: userId};
await req.session.save(); await req.session.save();

View File

@@ -1,15 +1,15 @@
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase"; import {app} from "@/firebase";
import {getFirestore, setDoc, doc, query, collection, where, getDocs, getDoc, deleteDoc, limit, updateDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {v4} from "uuid"; import {v4} from "uuid";
import {CorporateUser, Group, Type} from "@/interfaces/user"; import {CorporateUser, Group, Type, User} from "@/interfaces/user";
import {createUserWithEmailAndPassword, getAuth} from "firebase/auth"; import {createUserWithEmailAndPassword, getAuth} from "firebase/auth";
import ShortUniqueId from "short-unique-id"; import ShortUniqueId from "short-unique-id";
import {getUserCorporate, getUserGroups} from "@/utils/groups.be"; import {getGroup, getGroups, getUserCorporate, getUserGroups, getUserNamedGroup} from "@/utils/groups.be";
import {uniq} from "lodash"; import {uniq} from "lodash";
import {getUser} from "@/utils/users.be"; import {getSpecificUsers, getUser} from "@/utils/users.be";
import client from "@/lib/mongodb";
const DEFAULT_DESIRED_LEVELS = { const DEFAULT_DESIRED_LEVELS = {
reading: 9, reading: 9,
@@ -26,15 +26,16 @@ const DEFAULT_LEVELS = {
}; };
const auth = getAuth(app); const auth = getAuth(app);
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
const getUsersOfType = async (admin: string, type: Type) => { const getUsersOfType = async (admin: string, type: Type) => {
const groups = await getUserGroups(admin); const groups = await getUserGroups(admin);
const users = await Promise.all(uniq(groups.flatMap((x) => x.participants)).map(getUser)); const participants = groups.flatMap((x) => x.participants);
const users = await getSpecificUsers(participants);
return users.filter((x) => x.type === type).map((x) => x.id); return users.filter((x) => x?.type === type).map((x) => x?.id);
}; };
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
@@ -76,6 +77,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const user = { const user = {
...req.body, ...req.body,
bio: "", bio: "",
id: userId,
type: type, type: type,
focus: "academic", focus: "academic",
status: "active", status: "active",
@@ -101,8 +103,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const uid = new ShortUniqueId(); const uid = new ShortUniqueId();
const code = uid.randomUUID(6); const code = uid.randomUUID(6);
await setDoc(doc(db, "users", userId), user); await db.collection("users").insertOne(user);
await setDoc(doc(db, "codes", code), { await db.collection("codes").insertOne({
code, code,
creator: maker.id, creator: maker.id,
expiryDate, expiryDate,
@@ -134,34 +136,21 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
disableEditing: true, disableEditing: true,
}; };
await setDoc(doc(db, "groups", defaultTeachersGroup.id), defaultTeachersGroup); await db.collection("groups").insertMany([defaultStudentsGroup, defaultTeachersGroup]);
await setDoc(doc(db, "groups", defaultStudentsGroup.id), defaultStudentsGroup);
} }
if (!!corporate) { if (!!corporate) {
const corporateQ = query(collection(db, "users"), where("email", "==", corporate.trim().toLowerCase())); const corporateUser = await db.collection("users").findOne<CorporateUser>({email: corporate.trim().toLowerCase()});
const corporateSnapshot = await getDocs(corporateQ);
if (!corporateSnapshot.empty) { if (!!corporateUser) {
const corporateUser = {...corporateSnapshot.docs[0].data(), id: corporateSnapshot.docs[0].id} as CorporateUser; await db.collection("codes").updateOne({code}, {$set: {creator: corporateUser.id}});
await setDoc(doc(db, "codes", code), {creator: corporateUser.id}, {merge: true}); const typeGroup = await db
.collection("groups")
.findOne<Group>({creator: corporateUser.id, name: type === "student" ? "Students" : "Teachers"});
const q = query( if (!!typeGroup) {
collection(db, "groups"), if (!typeGroup.participants.includes(userId)) {
where("admin", "==", corporateUser.id), await db.collection("groups").updateOne({id: typeGroup.id}, {$set: {participants: [...typeGroup.participants, userId]}});
where("name", "==", type === "student" ? "Students" : "Teachers"),
limit(1),
);
const snapshot = await getDocs(q);
if (!snapshot.empty) {
const doc = snapshot.docs[0];
const participants: string[] = doc.get("participants");
if (!participants.includes(userId)) {
await updateDoc(doc.ref, {
participants: [...participants, userId],
});
} }
} else { } else {
const defaultGroup: Group = { const defaultGroup: Group = {
@@ -172,30 +161,18 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
disableEditing: true, disableEditing: true,
}; };
await setDoc(doc(db, "groups", defaultGroup.id), defaultGroup); await db.collection("groups").insertOne(defaultGroup);
} }
} }
} }
if (maker.type === "corporate") { if (maker.type === "corporate") {
await setDoc(doc(db, "codes", code), {creator: maker.id}, {merge: true}); await db.collection("codes").updateOne({code}, {$set: {creator: maker.id}});
const typeGroup = await getUserNamedGroup(maker.id, type === "student" ? "Students" : "Teachers");
const q = query( if (!!typeGroup) {
collection(db, "groups"), if (!typeGroup.participants.includes(userId)) {
where("admin", "==", maker.id), await db.collection("groups").updateOne({id: typeGroup.id}, {$set: {participants: [...typeGroup.participants, userId]}});
where("name", "==", type === "student" ? "Students" : "Teachers"),
limit(1),
);
const snapshot = await getDocs(q);
if (!snapshot.empty) {
const doc = snapshot.docs[0];
const participants: string[] = doc.get("participants");
if (!participants.includes(userId)) {
await updateDoc(doc.ref, {
participants: [...participants, userId],
});
} }
} else { } else {
const defaultGroup: Group = { const defaultGroup: Group = {
@@ -206,22 +183,18 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
disableEditing: true, disableEditing: true,
}; };
await setDoc(doc(db, "groups", defaultGroup.id), defaultGroup); await db.collection("groups").insertOne(defaultGroup);
} }
} }
if (!!corporateCorporate && corporateCorporate.type === "mastercorporate" && type === "corporate") { if (!!corporateCorporate && corporateCorporate.type === "mastercorporate" && type === "corporate") {
const q = query(collection(db, "groups"), where("admin", "==", corporateCorporate.id), where("name", "==", "corporate"), limit(1)); const corporateGroup = await getUserNamedGroup(corporateCorporate.id, "Corporate");
const snapshot = await getDocs(q);
if (!snapshot.empty) { if (!!corporateGroup) {
const doc = snapshot.docs[0]; if (!corporateGroup.participants.includes(userId)) {
const participants: string[] = doc.get("participants"); await db
.collection("groups")
if (!participants.includes(userId)) { .updateOne({id: corporateGroup.id}, {$set: {participants: [...corporateGroup.participants, userId]}});
await updateDoc(doc.ref, {
participants: [...participants, userId],
});
} }
} else { } else {
const defaultGroup: Group = { const defaultGroup: Group = {
@@ -232,13 +205,13 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
disableEditing: true, disableEditing: true,
}; };
await setDoc(doc(db, "groups", defaultGroup.id), defaultGroup); await db.collection("groups").insertOne(defaultGroup);
} }
} }
if (!!groupID) { if (!!groupID) {
const groupSnapshot = await getDoc(doc(db, "groups", groupID)); const group = await getGroup(groupID);
await setDoc(groupSnapshot.ref, {participants: [...groupSnapshot.data()!.participants, userId]}, {merge: true}); if (!!group) await db.collection("groups").updateOne({id: group.id}, {$set: {participants: [...group.participants, userId]}});
} }
console.log(`Returning - ${email}`); console.log(`Returning - ${email}`);

View File

@@ -1,12 +1,11 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase"; import client from "@/lib/mongodb";
import {getFirestore, doc, getDoc, deleteDoc, setDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {PERMISSIONS} from "@/constants/userPermissions"; import {PERMISSIONS} from "@/constants/userPermissions";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -23,14 +22,11 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
} }
const {id} = req.query as {id: string}; const {id} = req.query as {id: string};
const docSnap = await db.collection("packages").findOne({ id: id});
const docRef = doc(db, "packages", id); if (docSnap) {
const docSnap = await getDoc(docRef);
if (docSnap.exists()) {
res.status(200).json({ res.status(200).json({
id: docSnap.id, ...docSnap,
...docSnap.data(),
module, module,
}); });
} else { } else {
@@ -46,16 +42,16 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query as {id: string}; const {id} = req.query as {id: string};
const docRef = doc(db, "packages", id); const docSnap = await db.collection("packages").findOne({ id: id});
const docSnap = await getDoc(docRef); if (docSnap) {
if (docSnap.exists()) {
if (!["developer", "admin"].includes(req.session.user.type)) { if (!["developer", "admin"].includes(req.session.user.type)) {
res.status(403).json({ok: false}); res.status(403).json({ok: false});
return; return;
} }
await db.collection("packages").updateOne(
await setDoc(docRef, req.body, {merge: true}); { id: id },
{ $set: req.body }
);
res.status(200).json({ok: true}); res.status(200).json({ok: true});
} else { } else {
@@ -71,16 +67,14 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query as {id: string}; const {id} = req.query as {id: string};
const docRef = doc(db, "packages", id); const docSnap = await db.collection("packages").findOne({ id: id});
const docSnap = await getDoc(docRef);
if (docSnap.exists()) { if (docSnap) {
if (!["developer", "admin"].includes(req.session.user.type)) { if (!["developer", "admin"].includes(req.session.user.type)) {
res.status(403).json({ok: false}); res.status(403).json({ok: false});
return; return;
} }
await db.collection("packages").deleteOne({ id: id });
await deleteDoc(docRef);
res.status(200).json({ok: true}); res.status(200).json({ok: true});
} else { } else {

View File

@@ -1,14 +1,13 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase"; import client from "@/lib/mongodb";
import {getFirestore, collection, getDocs, setDoc, doc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {Group} from "@/interfaces/user"; import {Group} from "@/interfaces/user";
import {Package} from "@/interfaces/paypal"; import {Package} from "@/interfaces/paypal";
import {v4} from "uuid"; import {v4} from "uuid";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -18,14 +17,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
} }
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
const snapshot = await getDocs(collection(db, "packages")); const snapshot = await db.collection("packages").find({}).toArray();
res.status(200).json(snapshot);
res.status(200).json(
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})),
);
} }
async function post(req: NextApiRequest, res: NextApiResponse) { async function post(req: NextApiRequest, res: NextApiResponse) {
@@ -38,7 +31,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
return res.status(403).json({ok: false, reason: "You do not have permission to create a new package"}); return res.status(403).json({ok: false, reason: "You do not have permission to create a new package"});
const body = req.body as Package; const body = req.body as Package;
// Package already had an id but a new one was being set
await setDoc(doc(db, "packages", v4()), body); // with v4() don't know if intentional or not, recreated the behaviour as was
await db.collection("packages").insertOne({...body, id: v4()})
res.status(200).json({ok: true}); res.status(200).json({ok: true});
} }

View File

@@ -1,14 +1,14 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app, storage} from "@/firebase"; import {storage} from "@/firebase";
import {getFirestore, collection, getDocs, getDoc, doc, deleteDoc, setDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {Group} from "@/interfaces/user"; import {Group} from "@/interfaces/user";
import {Payment} from "@/interfaces/paypal"; import {Payment} from "@/interfaces/paypal";
import {deleteObject, ref} from "firebase/storage"; import {deleteObject, ref} from "firebase/storage";
import client from "@/lib/mongodb";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -28,10 +28,10 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query as {id: string}; const {id} = req.query as {id: string};
const snapshot = await getDoc(doc(db, "payments", id)); const payment = await db.collection("payments").findOne<Payment>({id});
if (snapshot.exists()) { if (!!payment) {
res.status(200).json({...snapshot.data(), id: snapshot.id}); res.status(200).json(payment);
} else { } else {
res.status(404).json(undefined); res.status(404).json(undefined);
} }
@@ -45,15 +45,15 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query as {id: string}; const {id} = req.query as {id: string};
const snapshot = await getDoc(doc(db, "payments", id)); const payment = await db.collection("payments").findOne<Payment>({id});
const data = snapshot.data() as Payment; if (!payment) return res.status(404).json({ok: false});
const user = req.session.user; const user = req.session.user;
if (user.type === "admin" || user.type === "developer") { if (user.type === "admin" || user.type === "developer") {
if (data.commissionTransfer) await deleteObject(ref(storage, data.commissionTransfer)); if (payment.commissionTransfer) await deleteObject(ref(storage, payment.commissionTransfer));
if (data.corporateTransfer) await deleteObject(ref(storage, data.corporateTransfer)); if (payment.corporateTransfer) await deleteObject(ref(storage, payment.corporateTransfer));
await deleteDoc(snapshot.ref); await db.collection("payments").deleteOne({id: payment.id});
res.status(200).json({ok: true}); res.status(200).json({ok: true});
return; return;
} }
@@ -68,15 +68,17 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
} }
const {id} = req.query as {id: string}; const {id} = req.query as {id: string};
const snapshot = await getDoc(doc(db, "payments", id));
const payment = await db.collection("payments").findOne<Payment>({id});
if (!payment) return res.status(404).json({ok: false});
const user = req.session.user; const user = req.session.user;
if (user.type === "admin" || user.type === "developer") { if (user.type === "admin" || user.type === "developer") {
await setDoc(snapshot.ref, req.body, {merge: true}); await db.collection("payments").updateOne({id: payment.id}, {$set: req.body});
if (req.body.isPaid) { if (req.body.isPaid) {
const corporateID = req.body.corporate; const corporateID = req.body.corporate;
await setDoc(doc(db, "users", corporateID), {status: "active"}, {merge: true}); await db.collection("users").updateOne({id: corporateID}, {$set: {status: "active"}});
} }
return res.status(200).json({ok: true}); return res.status(200).json({ok: true});
} }

View File

@@ -1,19 +1,12 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import { app } from "@/firebase";
import {
getFirestore,
collection,
getDocs,
query,
where,
} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {Payment} from "@/interfaces/paypal"; import {Payment} from "@/interfaces/paypal";
import {PaymentsStatus} from "@/interfaces/user.payments"; import {PaymentsStatus} from "@/interfaces/user.payments";
import client from "@/lib/mongodb";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -30,23 +23,12 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
return; return;
} }
// if it's an admin, don't apply query filters const payments = await db
const whereClauses = ["admin", "developer"].includes(req.session.user.type) .collection("payments")
? [] .find(["admin", "developer"].includes(req.session.user.type) ? {} : {[req.session.user.type]: req.session.user.id})
: [ .toArray();
// where("agent", "==", "xRMirufz6PPQqxKBgvPTWiWKBD63"),
where(req.session.user.type, "==", req.session.user.id),
// Based on the logic of query we should be able to do this:
// where("isPaid", "==", paid === "paid"),
// but for some reason it is ignoring all but the first clause
// I opted into only fetching relevant content for the user
// and then filter it with JS
];
const codeQuery = query(collection(db, "payments"), ...whereClauses); if (payments.length === 0) {
const snapshot = await getDocs(codeQuery);
if (snapshot.empty) {
res.status(200).json({ res.status(200).json({
pending: [], pending: [],
done: [], done: [],
@@ -54,12 +36,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
return; return;
} }
const docs = snapshot.docs.map((doc) => ({ const paidStatusEntries = payments.reduce(
id: doc.id,
...doc.data(),
})) as Payment[];
const paidStatusEntries = docs.reduce(
(acc: PaymentsStatus, doc) => { (acc: PaymentsStatus, doc) => {
if (doc.isPaid) { if (doc.isPaid) {
return { return {
@@ -76,7 +53,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
{ {
pending: [], pending: [],
done: [], done: [],
} },
); );
res.status(200).json({ res.status(200).json({
pending: [...new Set(paidStatusEntries.pending)], pending: [...new Set(paidStatusEntries.pending)],

View File

@@ -1,7 +1,7 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import {app, storage} from "@/firebase"; import { storage } from "@/firebase";
import {getFirestore, getDoc, doc, updateDoc, deleteField, setDoc} from "firebase/firestore"; import client from "@/lib/mongodb";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { FilesStorage } from "@/interfaces/storage.files"; import { FilesStorage } from "@/interfaces/storage.files";
@@ -11,7 +11,7 @@ import fs from "fs";
import { ref, uploadBytes, deleteObject, getDownloadURL } from "firebase/storage"; import { ref, uploadBytes, deleteObject, getDownloadURL } from "firebase/storage";
import formidable from "formidable-serverless"; import formidable from "formidable-serverless";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
const getPaymentField = (type: FilesStorage) => { const getPaymentField = (type: FilesStorage) => {
switch (type) { switch (type) {
@@ -25,16 +25,24 @@ const getPaymentField = (type: FilesStorage) => {
}; };
const handleDelete = async (paymentId: string, paymentField: "commissionTransfer" | "corporateTransfer") => { const handleDelete = async (paymentId: string, paymentField: "commissionTransfer" | "corporateTransfer") => {
const paymentRef = doc(db, "payments", paymentId); const paymentDoc = await db.collection("payments").findOne<Payment>({ id: paymentId })
const paymentDoc = await getDoc(paymentRef);
const {[paymentField]: paymentFieldPath} = paymentDoc.data() as Payment; if (paymentDoc) {
const { [paymentField]: paymentFieldPath } = paymentDoc;
// Create a reference to the file to delete // Create a reference to the file to delete
const documentRef = ref(storage, paymentFieldPath); const documentRef = ref(storage, paymentFieldPath);
await deleteObject(documentRef); await deleteObject(documentRef);
await updateDoc(paymentRef, { await db.collection("payments").deleteOne({ id: paymentId });
[paymentField]: deleteField(),
isPaid: false, await db.collection("payments").updateOne(
}); { id: paymentId },
{
$unset: { [paymentField]: "" },
$set: { isPaid: false }
}
);
}
}; };
const handleUpload = async (req: NextApiRequest, paymentId: string, paymentField: "commissionTransfer" | "corporateTransfer") => const handleUpload = async (req: NextApiRequest, paymentId: string, paymentField: "commissionTransfer" | "corporateTransfer") =>
@@ -54,11 +62,13 @@ const handleUpload = async (req: NextApiRequest, paymentId: string, paymentField
const snapshot = await uploadBytes(fileRef, binary); const snapshot = await uploadBytes(fileRef, binary);
fs.rmSync(file.path); fs.rmSync(file.path);
const paymentRef = doc(db, "payments", paymentId); await db.collection("payments").updateOne(
{ id: paymentId },
{
$set: { [paymentField]: snapshot.ref.fullPath }
}
);
await updateDoc(paymentRef, {
[paymentField]: snapshot.ref.fullPath,
});
resolve(snapshot.ref.fullPath); resolve(snapshot.ref.fullPath);
} catch (err) { } catch (err) {
reject(err); reject(err);
@@ -92,13 +102,16 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
res.status(500).json({ error: "Failed to identify payment field" }); res.status(500).json({ error: "Failed to identify payment field" });
return; return;
} }
const paymentRef = doc(db, "payments", paymentId); const paymentRef = await db.collection("payments").findOne<Payment>({ id: paymentId })
const {[paymentField]: paymentFieldPath} = (await getDoc(paymentRef)).data() as Payment; if (paymentRef) {
const { [paymentField]: paymentFieldPath } = paymentRef;
// Create a reference to the file to delete // Create a reference to the file to delete
const documentRef = ref(storage, paymentFieldPath); const documentRef = ref(storage, paymentFieldPath);
const url = await getDownloadURL(documentRef); const url = await getDownloadURL(documentRef);
res.status(200).json({ url, name: documentRef.name }); res.status(200).json({ url, name: documentRef.name });
return;
}
} }
async function post(req: NextApiRequest, res: NextApiResponse) { async function post(req: NextApiRequest, res: NextApiResponse) {
@@ -116,11 +129,17 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
try { try {
const ref = await handleUpload(req, paymentId, paymentField); const ref = await handleUpload(req, paymentId, paymentField);
const updatedDoc = (await getDoc(doc(db, "payments", paymentId))).data() as Payment; const updatedDoc = await db.collection("payments").findOne<Payment>({ id: paymentId })
if (updatedDoc.commissionTransfer && updatedDoc.corporateTransfer) { if (updatedDoc && updatedDoc.commissionTransfer && updatedDoc.corporateTransfer) {
await setDoc(doc(db, "payments", paymentId), {isPaid: true}, {merge: true}); await db.collection("payments").updateOne(
{ id: paymentId },
{ $set: { isPaid: true } }
);
await setDoc(doc(db, "users", updatedDoc.corporate), {status: "active"}, {merge: true}); await db.collection("users").updateOne(
{ id: updatedDoc.corporate },
{ $set: { status: "active" } }
);
} }
res.status(200).json({ ref }); res.status(200).json({ ref });
} catch (error) { } catch (error) {

View File

@@ -1,15 +1,14 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, collection, getDocs, setDoc, doc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {Group} from "@/interfaces/user"; import {Group} from "@/interfaces/user";
import {Payment} from "@/interfaces/paypal"; import {Payment} from "@/interfaces/paypal";
import {v4} from "uuid"; import {v4} from "uuid";
import ShortUniqueId from "short-unique-id"; import ShortUniqueId from "short-unique-id";
import client from "@/lib/mongodb";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -24,20 +23,15 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
} }
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
const snapshot = await getDocs(collection(db, "payments")); const payments = await db.collection("payments").find({}).toArray();
res.status(200).json( res.status(200).json(payments);
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})),
);
} }
async function post(req: NextApiRequest, res: NextApiResponse) { async function post(req: NextApiRequest, res: NextApiResponse) {
const body = req.body as Payment; const body = req.body as Payment;
const shortUID = new ShortUniqueId(); const shortUID = new ShortUniqueId();
await setDoc(doc(db, "payments", shortUID.randomUUID(8)), body); await db.collection("payments").insertOne({...body, id: shortUID.randomUUID(8)});
res.status(200).json({ok: true}); res.status(200).json({ok: true});
} }

View File

@@ -1,23 +1,17 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import { app } from "@/firebase";
import {
getFirestore,
getDocs,
collection,
} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import client from "@/lib/mongodb";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
const payments = await getDocs(collection(db, "paypalpayments")); const payments = await db.collection("paypalpayments").find({}).toArray();
const data = payments.docs.map((doc) => doc.data()); res.status(200).json(payments);
res.status(200).json(data);
} }
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {

View File

@@ -1,7 +1,6 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase"; import client from "@/lib/mongodb";
import {getFirestore, collection, getDocs, setDoc, doc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {Group} from "@/interfaces/user"; import {Group} from "@/interfaces/user";
@@ -11,7 +10,7 @@ import ShortUniqueId from "short-unique-id";
import axios from "axios"; import axios from "axios";
import {IntentionResult, PaymentIntention} from "@/interfaces/paymob"; import {IntentionResult, PaymentIntention} from "@/interfaces/paymob";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -26,14 +25,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
} }
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
const snapshot = await getDocs(collection(db, "payments")); const snapshot = await db.collection("payments").find().toArray();
res.status(200).json(snapshot);
res.status(200).json(
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})),
);
} }
async function post(req: NextApiRequest, res: NextApiResponse) { async function post(req: NextApiRequest, res: NextApiResponse) {

View File

@@ -1,7 +1,5 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, collection, getDocs, setDoc, doc, getDoc, query, where} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {Group, User} from "@/interfaces/user"; import {Group, User} from "@/interfaces/user";
@@ -11,8 +9,9 @@ import ShortUniqueId from "short-unique-id";
import axios from "axios"; import axios from "axios";
import {IntentionResult, PaymentIntention, TransactionResult} from "@/interfaces/paymob"; import {IntentionResult, PaymentIntention, TransactionResult} from "@/interfaces/paymob";
import moment from "moment"; import moment from "moment";
import client from "@/lib/mongodb";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") await post(req, res); if (req.method === "POST") await post(req, res);
@@ -32,11 +31,9 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
duration_unit: DurationUnit; duration_unit: DurationUnit;
}; };
const userSnapshot = await getDoc(doc(db, "users", userID as string)); const user = await db.collection("users").findOne<User>({ id: userID as string });
if (!userSnapshot.exists() || !duration || !duration_unit) return res.status(404).json({ok: false}); if (!user || !duration || !duration_unit) return res.status(404).json({ok: false});
const user = {...userSnapshot.data(), id: userSnapshot.id} as User;
const subscriptionExpirationDate = user.subscriptionExpirationDate; const subscriptionExpirationDate = user.subscriptionExpirationDate;
if (!subscriptionExpirationDate) return res.status(200).json({ok: false}); if (!subscriptionExpirationDate) return res.status(200).json({ok: false});
@@ -45,8 +42,13 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const updatedSubscriptionExpirationDate = moment(initialDate).add(duration, duration_unit).endOf("day").subtract(2, "hours").toISOString(); const updatedSubscriptionExpirationDate = moment(initialDate).add(duration, duration_unit).endOf("day").subtract(2, "hours").toISOString();
await setDoc(userSnapshot.ref, {subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active"}, {merge: true}); await db.collection("users").updateOne(
await setDoc(doc(db, "paypalpayments", v4()), { { id: userID as string },
{ $set: {subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active"} }
);
await db.collection("paypalpayments").insertOne({
id: v4(),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
currency: transactionResult.transaction.currency, currency: transactionResult.transaction.currency,
orderId: transactionResult.transaction.id, orderId: transactionResult.transaction.id,
@@ -59,21 +61,19 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
}); });
if (user.type === "corporate") { if (user.type === "corporate") {
const groupsSnapshot = await getDocs(query(collection(db, "groups"), where("admin", "==", user.id))); const groups = await db.collection("groups").find<Group>({ admin: user.id }).toArray();
const groups = groupsSnapshot.docs.map((g) => ({...g.data(), id: g.id})) as Group[];
const participants = (await Promise.all( const participants = (await Promise.all(
groups.flatMap((x) => x.participants).map(async (x) => ({...(await getDoc(doc(db, "users", x))).data(), id: x})), groups.flatMap((x) => x.participants).map(async (x) => ({...(await db.collection("users").findOne({ id: x}))})),
)) as User[]; )) as User[];
const sameExpiryDateParticipants = participants.filter( const sameExpiryDateParticipants = participants.filter(
(x) => x.subscriptionExpirationDate === subscriptionExpirationDate && x.status !== "disabled", (x) => x.subscriptionExpirationDate === subscriptionExpirationDate && x.status !== "disabled",
); );
for (const participant of sameExpiryDateParticipants) { for (const participant of sameExpiryDateParticipants) {
await setDoc( await db.collection("users").updateOne(
doc(db, "users", participant.id), { id: participant.id },
{subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active"}, { $set: {subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active"} }
{merge: true},
); );
} }
} }

View File

@@ -1,13 +1,6 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase"; import client from "@/lib/mongodb";
import {
getFirestore,
collection,
getDocs,
setDoc,
doc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import axios from "axios"; import axios from "axios";
@@ -19,7 +12,7 @@ import { getAccessToken } from "@/utils/paypal";
import moment from "moment"; import moment from "moment";
import { Group } from "@/interfaces/user"; import { Group } from "@/interfaces/user";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -67,26 +60,26 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
duration, duration,
duration_unit duration_unit
); );
await setDoc(
doc(db, "users", req.session.user!.id), await db.collection("users").updateOne(
{ id: req.session.user!.id },
{ {
$set: {
subscriptionExpirationDate: updatedExpirationDate.toISOString(), subscriptionExpirationDate: updatedExpirationDate.toISOString(),
status: "active", status: "active",
}, }
{ merge: true } }
); );
try { try {
await setDoc(doc(db, "paypalpayments", v4()), { await db.collection("paypalpayments").insertOne({
id: v4(),
orderId: id, orderId: id,
userId: req.session.user!.id, userId: req.session.user!.id,
status: request.data.status, status: request.data.status,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
value: value: request.data.purchase_units[0].payments.captures[0].amount.value,
request.data.purchase_units[0].payments.captures[0].amount.value, currency: request.data.purchase_units[0].payments.captures[0].amount.currency_code,
currency:
request.data.purchase_units[0].payments.captures[0].amount
.currency_code,
subscriptionDuration: duration, subscriptionDuration: duration,
subscriptionDurationUnit: duration_unit, subscriptionDurationUnit: duration_unit,
subscriptionExpirationDate: updatedExpirationDate.toISOString(), subscriptionExpirationDate: updatedExpirationDate.toISOString(),
@@ -96,12 +89,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
} }
if (user!.type === "corporate") { if (user!.type === "corporate") {
const snapshot = await getDocs(collection(db, "groups")); const groups = (
const groups: Group[] = ( await db.collection("groups").find<Group>({}).toArray()
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as Group[]
).filter((x) => x.admin === user!.id); ).filter((x) => x.admin === user!.id);
await Promise.all( await Promise.all(
@@ -109,14 +98,14 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
.flatMap((x) => x.participants) .flatMap((x) => x.participants)
.map( .map(
async (x) => async (x) =>
await setDoc( await db.collection("users").updateOne(
doc(db, "users", x), { id: x },
{ {
subscriptionExpirationDate: $set: {
updatedExpirationDate.toISOString(), subscriptionExpirationDate: updatedExpirationDate.toISOString(),
status: "active", status: "active",
}, }
{ merge: true } }
) )
) )
); );

View File

@@ -1,7 +1,5 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import { app } from "@/firebase";
import { getFirestore, collection, getDocs } from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import axios from "axios"; import axios from "axios";
@@ -9,18 +7,14 @@ import { v4 } from "uuid";
import {OrderResponseBody} from "@paypal/paypal-js"; import {OrderResponseBody} from "@paypal/paypal-js";
import {getAccessToken} from "@/utils/paypal"; import {getAccessToken} from "@/utils/paypal";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") if (req.method !== "POST") return res.status(404).json({ok: false, reason: "Method not supported!"});
return res.status(404).json({ ok: false, reason: "Method not supported!" });
if (!req.session.user) return res.status(401).json({ok: false}); if (!req.session.user) return res.status(401).json({ok: false});
const accessToken = await getAccessToken(); const accessToken = await getAccessToken();
if (!accessToken) if (!accessToken) return res.status(401).json({ok: false, reason: "Authorization failed!"});
return res.status(401).json({ ok: false, reason: "Authorization failed!" });
const {currencyCode, price, trackingId} = req.body as { const {currencyCode, price, trackingId} = req.body as {
currencyCode: string; currencyCode: string;
@@ -28,8 +22,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
trackingId: string; trackingId: string;
}; };
if (!trackingId) if (!trackingId) return res.status(401).json({ok: false, reason: "Missing tracking id!"});
return res.status(401).json({ ok: false, reason: "Missing tracking id!" });
const url = `${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders`; const url = `${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders`;
const amount = { const amount = {
@@ -95,7 +88,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
url, url,
data, data,
headers, headers,
}) }),
); );
axios axios

View File

@@ -1,7 +1,5 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import { app } from "@/firebase";
import { getFirestore, collection, getDocs } from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import axios from "axios"; import axios from "axios";
@@ -9,19 +7,15 @@ import { v4 } from "uuid";
import {OrderResponseBody} from "@paypal/paypal-js"; import {OrderResponseBody} from "@paypal/paypal-js";
import {getAccessToken} from "@/utils/paypal"; import {getAccessToken} from "@/utils/paypal";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "PUT") if (req.method !== "PUT") return res.status(404).json({ok: false, reason: "Method not supported!"});
return res.status(404).json({ ok: false, reason: "Method not supported!" });
if (!req.session.user) return res.status(401).json({ok: false}); if (!req.session.user) return res.status(401).json({ok: false});
const accessToken = await getAccessToken(); const accessToken = await getAccessToken();
if (!accessToken) if (!accessToken) return res.status(401).json({ok: false, reason: "Authorization failed!"});
return res.status(401).json({ ok: false, reason: "Authorization failed!" });
const trackingId = `${req.session.user.id}-${Date.now()}`; const trackingId = `${req.session.user.id}-${Date.now()}`;
@@ -40,11 +34,13 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
}, },
}; };
console.log(JSON.stringify({ console.log(
JSON.stringify({
url, url,
data, data,
headers, headers,
})); }),
);
try { try {
const request = await axios.put(url, data, headers); const request = await axios.put(url, data, headers);
@@ -54,8 +50,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
}); });
} catch (err) { } catch (err) {
console.error(url, err); console.error(url, err);
return res return res.status(500).json({ok: false, reason: "Failed to create tracking ID"});
.status(500)
.json({ ok: false, reason: "Failed to create tracking ID" });
} }
} }

View File

@@ -1,12 +1,11 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import {app} from "@/firebase"; import client from "@/lib/mongodb";
import {getFirestore, doc, setDoc, getDoc} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { getPermissionDoc } from "@/utils/permissions.be"; import { getPermissionDoc } from "@/utils/permissions.be";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -37,7 +36,12 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
const { users } = req.body; const { users } = req.body;
try { try {
await setDoc(doc(db, "permissions", id), {users}, {merge: true}); await db.collection("permissions").updateOne(
{ id: id },
{ $set: {...users, id: id} },
{ upsert: true }
);
return res.status(200).json({ ok: true }); return res.status(200).json({ ok: true });
} catch (err) { } catch (err) {
console.error(err); console.error(err);

View File

@@ -1,25 +1,10 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import { app } from "@/firebase";
import {
getFirestore,
collection,
getDocs,
query,
where,
doc,
setDoc,
addDoc,
getDoc,
deleteDoc,
} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {Permission} from "@/interfaces/permissions"; import {Permission} from "@/interfaces/permissions";
import {bootstrap} from "@/utils/permissions.be"; import {bootstrap} from "@/utils/permissions.be";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {

View File

@@ -1,25 +1,10 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import {
getFirestore,
collection,
getDocs,
query,
where,
doc,
setDoc,
addDoc,
getDoc,
deleteDoc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { Permission } from "@/interfaces/permissions"; import { Permission } from "@/interfaces/permissions";
import { getPermissions, getPermissionDocs } from "@/utils/permissions.be"; import { getPermissions, getPermissionDocs } from "@/utils/permissions.be";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {

View File

@@ -3,14 +3,14 @@ import {createUserWithEmailAndPassword, getAuth} from "firebase/auth";
import {app} from "@/firebase"; import {app} from "@/firebase";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {getFirestore, doc, setDoc, query, collection, where, getDocs} from "firebase/firestore"; import {Code, CorporateInformation, DemographicInformation, Group, Type} from "@/interfaces/user";
import {CorporateInformation, DemographicInformation, Group, Type} from "@/interfaces/user";
import {addUserToGroupOnCreation} from "@/utils/registration"; import {addUserToGroupOnCreation} from "@/utils/registration";
import moment from "moment"; import moment from "moment";
import {v4} from "uuid"; import {v4} from "uuid";
import client from "@/lib/mongodb";
const auth = getAuth(app); const auth = getAuth(app);
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(register, sessionOptions); export default withIronSessionApiRoute(register, sessionOptions);
@@ -45,24 +45,13 @@ async function registerIndividual(req: NextApiRequest, res: NextApiResponse) {
code?: string; code?: string;
}; };
const codeQuery = query(collection(db, "codes"), where("code", "==", code)); const codeDoc = await db.collection("codes").findOne<Code>({code});
const codeDocs = (await getDocs(codeQuery)).docs.filter((x) => !Object.keys(x.data()).includes("userId"));
if (code && code.length > 0 && codeDocs.length === 0) { if (code && code.length > 0 && !!codeDoc) {
res.status(400).json({error: "Invalid Code!"}); res.status(400).json({error: "Invalid Code!"});
return; return;
} }
const codeData =
codeDocs.length > 0
? (codeDocs[0].data() as {
code: string;
type: Type;
creator?: string;
expiryDate: Date | null;
})
: undefined;
createUserWithEmailAndPassword(auth, email.toLowerCase(), password) createUserWithEmailAndPassword(auth, email.toLowerCase(), password)
.then(async (userCredentials) => { .then(async (userCredentials) => {
const userId = userCredentials.user.uid; const userId = userCredentials.user.uid;
@@ -70,31 +59,32 @@ async function registerIndividual(req: NextApiRequest, res: NextApiResponse) {
const user = { const user = {
...req.body, ...req.body,
id: userId,
email: email.toLowerCase(), email: email.toLowerCase(),
desiredLevels: DEFAULT_DESIRED_LEVELS, desiredLevels: DEFAULT_DESIRED_LEVELS,
levels: DEFAULT_LEVELS, levels: DEFAULT_LEVELS,
bio: "", bio: "",
isFirstLogin: codeData ? codeData.type === "student" : true, isFirstLogin: codeDoc ? codeDoc.type === "student" : true,
profilePicture: "/defaultAvatar.png", profilePicture: "/defaultAvatar.png",
focus: "academic", focus: "academic",
type: email.endsWith("@ecrop.dev") ? "developer" : codeData ? codeData.type : "student", type: email.endsWith("@ecrop.dev") ? "developer" : codeDoc ? codeDoc.type : "student",
subscriptionExpirationDate: codeData ? codeData.expiryDate : moment().subtract(1, "days").toISOString(), subscriptionExpirationDate: codeDoc ? codeDoc.expiryDate : moment().subtract(1, "days").toISOString(),
...(passport_id ? {demographicInformation: {passport_id}} : {}), ...(passport_id ? {demographicInformation: {passport_id}} : {}),
registrationDate: new Date().toISOString(), registrationDate: new Date().toISOString(),
status: code ? "active" : "paymentDue", status: code ? "active" : "paymentDue",
}; };
await setDoc(doc(db, "users", userId), user); await db.collection("users").insertOne(user);
if (codeDocs.length > 0 && codeData) { if (!!codeDoc) {
await setDoc(codeDocs[0].ref, {userId: userId}, {merge: true}); await db.collection("codes").updateOne({code: codeDoc.code}, {$set: {userId}});
if (codeData.creator) await addUserToGroupOnCreation(userId, codeData.type, codeData.creator); if (codeDoc.creator) await addUserToGroupOnCreation(userId, codeDoc.type, codeDoc.creator);
} }
req.session.user = {...user, id: userId}; req.session.user = user;
await req.session.save(); await req.session.save();
res.status(200).json({user: {...user, id: userId}}); res.status(200).json({user});
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.log(error);
@@ -116,6 +106,7 @@ async function registerCorporate(req: NextApiRequest, res: NextApiResponse) {
const user = { const user = {
...req.body, ...req.body,
id: userId,
email: email.toLowerCase(), email: email.toLowerCase(),
desiredLevels: DEFAULT_DESIRED_LEVELS, desiredLevels: DEFAULT_DESIRED_LEVELS,
levels: DEFAULT_LEVELS, levels: DEFAULT_LEVELS,
@@ -152,15 +143,13 @@ async function registerCorporate(req: NextApiRequest, res: NextApiResponse) {
disableEditing: true, disableEditing: true,
}; };
await setDoc(doc(db, "users", userId), user); await db.collection("users").insertOne(user);
await setDoc(doc(db, "groups", defaultTeachersGroup.id), defaultTeachersGroup); await db.collection("groups").insertMany([defaultCorporateGroup, defaultStudentsGroup, defaultTeachersGroup]);
await setDoc(doc(db, "groups", defaultStudentsGroup.id), defaultStudentsGroup);
await setDoc(doc(db, "groups", defaultCorporateGroup.id), defaultCorporateGroup);
req.session.user = {...user, id: userId}; req.session.user = user;
await req.session.save(); await req.session.save();
res.status(200).json({user: {...user, id: userId}}); res.status(200).json({user});
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.log(error);

View File

@@ -1,12 +1,13 @@
import {NextApiRequest, NextApiResponse} from "next"; import {NextApiRequest, NextApiResponse} from "next";
import {getAuth} from "firebase-admin/auth"; import {getAuth} from "firebase-admin/auth";
import {adminApp, app} from "@/firebase"; import {adminApp} from "@/firebase";
import client from "@/lib/mongodb";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {doc, getFirestore, setDoc} from "firebase/firestore";
const db = client.db(process.env.MONGODB_DB);
const auth = getAuth(adminApp); const auth = getAuth(adminApp);
const db = getFirestore(app);
export default withIronSessionApiRoute(verify, sessionOptions); export default withIronSessionApiRoute(verify, sessionOptions);
@@ -19,8 +20,10 @@ async function verify(req: NextApiRequest, res: NextApiResponse) {
return; return;
} }
const userRef = doc(db, "users", user.uid); await db.collection("users").updateOne(
await setDoc(userRef, {isVerified: true}, {merge: true}); { id: user.uid},
{ $set: {isVerified: true} }
);
res.status(200).json({ok: true}); res.status(200).json({ok: true});
} }

View File

@@ -1,12 +1,11 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase"; import client from "@/lib/mongodb";
import {getFirestore, doc, getDoc, deleteDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {Session} from "@/hooks/useSessions"; import {Session} from "@/hooks/useSessions";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -23,14 +22,10 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query as {id: string}; const {id} = req.query as {id: string};
const docRef = doc(db, "sessions", id); const docSnap = await db.collection("sessions").findOne({ id: id });
const docSnap = await getDoc(docRef);
if (docSnap.exists()) { if (docSnap) {
res.status(200).json({ res.status(200).json(docSnap);
id: docSnap.id,
...docSnap.data(),
});
} else { } else {
res.status(404).json(undefined); res.status(404).json(undefined);
} }
@@ -44,11 +39,10 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query as {id: string}; const {id} = req.query as {id: string};
const docRef = doc(db, "sessions", id); const docSnap = await db.collection("sessions").findOne({ id: id });
const docSnap = await getDoc(docRef);
if (!docSnap.exists()) return res.status(404).json({ok: false}); if (!docSnap) return res.status(404).json({ok: false});
await db.collection("sessions").deleteOne({ id: id });
await deleteDoc(docRef);
return res.status(200).json({ok: true}); return res.status(200).json({ok: true});
} }

View File

@@ -1,13 +1,12 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase"; import client from "@/lib/mongodb";
import {getFirestore, collection, getDocs, query, where, doc, setDoc, addDoc, getDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {Session} from "@/hooks/useSessions"; import {Session} from "@/hooks/useSessions";
import moment from "moment"; import moment from "moment";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -24,12 +23,8 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
const {user} = req.query as {user?: string}; const {user} = req.query as {user?: string};
const q = user ? query(collection(db, "sessions"), where("user", "==", user)) : collection(db, "sessions"); const q = user ? {user: user} : {};
const snapshot = await getDocs(q); const sessions = await db.collection("sessions").find<Session>(q).toArray();
const sessions = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as Session[];
res.status(200).json( res.status(200).json(
sessions.filter((x) => { sessions.filter((x) => {
@@ -45,9 +40,13 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
res.status(401).json({ok: false}); res.status(401).json({ok: false});
return; return;
} }
const session = req.body; const session = req.body;
await setDoc(doc(db, "sessions", session.id), session, {merge: true});
await db.collection("sessions").updateOne(
{ id: session.id},
{ $set: session },
{ upsert: true }
);
res.status(200).json({ok: true}); res.status(200).json({ok: true});
} }

View File

@@ -1,15 +1,6 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { app, storage } from "@/firebase"; import { storage } from "@/firebase";
import { import client from "@/lib/mongodb";
getFirestore,
doc,
getDoc,
updateDoc,
getDocs,
query,
collection,
where,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import ReactPDF from "@react-pdf/renderer"; import ReactPDF from "@react-pdf/renderer";
@@ -35,7 +26,8 @@ import {
} from "@/utils/pdf"; } from "@/utils/pdf";
import moment from "moment-timezone"; import moment from "moment-timezone";
import { getCorporateNameForStudent } from "@/utils/groups.be"; import { getCorporateNameForStudent } from "@/utils/groups.be";
const db = getFirestore(app);
const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -281,19 +273,22 @@ async function getPdfUrl(pdfStream: any, docsSnap: any) {
// upload the pdf to storage // upload the pdf to storage
const pdfBuffer = await streamToBuffer(pdfStream); const pdfBuffer = await streamToBuffer(pdfStream);
const snapshot = await uploadBytes(fileRef, pdfBuffer, { await uploadBytes(fileRef, pdfBuffer, {
contentType: "application/pdf", contentType: "application/pdf",
}); });
// update the stats entries with the pdf url to prevent duplication // update the stats entries with the pdf url to prevent duplication
docsSnap.docs.forEach(async (doc: any) => { await db.collection("stats").updateOne(
await updateDoc(doc.ref, { { id: docsSnap.id },
{
$set: {
pdf: { pdf: {
path: refName, path: refName,
version: process.env.PDF_VERSION, version: process.env.PDF_VERSION,
}, }
}); }
}); }
);
return getDownloadURL(fileRef); return getDownloadURL(fileRef);
} }
@@ -302,16 +297,13 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
if (req.session.user) { if (req.session.user) {
const { id } = req.query as { id: string }; const { id } = req.query as { id: string };
// fetch stats entries for this particular user with the requested exam session // fetch stats entries for this particular user with the requested exam session
const docsSnap = await getDocs( const stats = await db.collection("stats").find<Stat>({ session: id }).toArray();
query(collection(db, "stats"), where("session", "==", id))
);
if (docsSnap.empty) { if (stats.length == 0) {
res.status(400).end(); res.status(400).end();
return; return;
} }
const stats = docsSnap.docs.map((d) => d.data()) as Stat[];
// verify if the stats already have a pdf generated // verify if the stats already have a pdf generated
const hasPDF = stats.find( const hasPDF = stats.find(
(s) => s.pdf?.path && s.pdf?.version === process.env.PDF_VERSION (s) => s.pdf?.path && s.pdf?.version === process.env.PDF_VERSION
@@ -336,23 +328,22 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
try { try {
// generate the pdf report // generate the pdf report
const docUser = await getDoc(doc(db, "users", userId)); const docUser = await db.collection("users").findOne<User>({ id: userId});
if (docUser.exists()) { if (docUser) {
// we'll need the user in order to get the user data (name, email, focus, etc); // we'll need the user in order to get the user data (name, email, focus, etc);
const [stat] = stats; const [stat] = stats;
if (stat.module === "level") { if (stat.module === "level") {
const user = docUser.data() as StudentUser; const user = docUser as StudentUser;
const uniqueExercises = stats.map((s) => ({ const uniqueExercises = stats.map((s) => ({
name: "Gramar & Vocabulary", name: "Gramar & Vocabulary",
result: `${s.score.correct}/${s.score.total}`, result: `${s.score.correct}/${s.score.total}`,
})); }));
const dates = stats.map((s) => moment(s.date)); const dates = stats.map((s) => moment(s.date));
const timeSpent = `${ const timeSpent = `${stats.reduce((accm, s: Stat) => accm + (s.timeSpent || 0), 0) / 60
stats.reduce((accm, s: Stat) => accm + (s.timeSpent || 0), 0) / 60
} minutes`; } minutes`;
const score = stats.reduce((accm, s) => accm + s.score.correct, 0); const score = stats.reduce((accm, s) => accm + s.score.correct, 0);
const corporateName = await getCorporateNameForStudent(userId); const corporateName = await getCorporateNameForStudent(userId);
@@ -373,11 +364,11 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
/> />
); );
const url = await getPdfUrl(pdfStream, docsSnap); const url = await getPdfUrl(pdfStream, docUser);
res.status(200).end(url); res.status(200).end(url);
return; return;
} }
const user = docUser.data() as User; const user = docUser as User;
try { try {
const pdfStream = await getDefaultPDFStream( const pdfStream = await getDefaultPDFStream(
@@ -386,7 +377,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
`${req.headers.origin || ""}${req.url}` `${req.headers.origin || ""}${req.url}`
); );
const url = await getPdfUrl(pdfStream, docsSnap); const url = await getPdfUrl(pdfStream, docUser);
res.status(200).end(url); res.status(200).end(url);
return; return;
} catch (err) { } catch (err) {
@@ -411,21 +402,17 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.query as { id: string }; const { id } = req.query as { id: string };
const docsSnap = await getDocs( const stats = await db.collection("stats").find<Stat>({ session: id }).toArray();
query(collection(db, "stats"), where("session", "==", id))
);
if (docsSnap.empty) { if (stats.length == 0) {
res.status(404).end(); res.status(404).end();
return; return;
} }
const stats = docsSnap.docs.map((d) => d.data());
const hasPDF = stats.find((s) => s.pdf?.path); const hasPDF = stats.find((s) => s.pdf?.path);
if (hasPDF) { if (hasPDF) {
const fileRef = ref(storage, hasPDF.pdf.path); const fileRef = ref(storage, hasPDF.pdf!.path);
const url = await getDownloadURL(fileRef); const url = await getDownloadURL(fileRef);
return res.redirect(url); return res.redirect(url);
} }

View File

@@ -1,12 +1,8 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase"; import client from "@/lib/mongodb";
import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc, deleteDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {uuidv4} from "@firebase/util";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return GET(req, res); if (req.method === "GET") return GET(req, res);
@@ -17,8 +13,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
async function GET(req: NextApiRequest, res: NextApiResponse) { async function GET(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query; const {id} = req.query;
const snapshot = await getDoc(doc(db, "stats", id as string)); const snapshot = await db.collection("stats").findOne({ id: id as string});
if (!snapshot.exists()) return res.status(404).json({id: snapshot.id}); if (!snapshot) return res.status(404).json({id: id as string});
res.status(200).json({...snapshot.data(), id: snapshot.id}); res.status(200).json({...snapshot.data(), id: snapshot.id});
} }

View File

@@ -1,14 +1,13 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import {app} from "@/firebase"; import client from "@/lib/mongodb";
import {getFirestore, collection, getDocs, query, where, doc, setDoc, addDoc, getDoc, deleteDoc} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { Stat } from "@/interfaces/user"; import { Stat } from "@/interfaces/user";
import { Assignment } from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
import { groupBy } from "lodash"; import { groupBy } from "lodash";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -22,17 +21,9 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
res.status(401).json({ ok: false }); res.status(401).json({ ok: false });
return; return;
} }
const snapshot = await db.collection("stats").find<Stat>({}).toArray();
const q = query(collection(db, "stats")); res.status(200).json(snapshot);
const snapshot = await getDocs(q);
res.status(200).json(
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})),
);
} }
async function post(req: NextApiRequest, res: NextApiResponse) { async function post(req: NextApiRequest, res: NextApiResponse) {
@@ -42,10 +33,13 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
} }
const stats = req.body as Stat[]; const stats = req.body as Stat[];
await stats.forEach(async (stat) => await setDoc(doc(db, "stats", stat.id), stat)); stats.forEach(async (stat) => await db.collection("stats").updateOne(
await stats.forEach(async (stat) => { { id: stat.id },
const sessionDoc = await getDoc(doc(db, "sessions", stat.session)); { $set: stat },
if (sessionDoc.exists()) await deleteDoc(sessionDoc.ref); { upsert: true }
));
stats.forEach(async (stat) => {
await db.collection("sessions").deleteOne({ id: stat.session })
}); });
const groupedStatsByAssignment = groupBy( const groupedStatsByAssignment = groupBy(
@@ -55,19 +49,20 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
if (Object.keys(groupedStatsByAssignment).length > 0) { if (Object.keys(groupedStatsByAssignment).length > 0) {
const assignments = Object.keys(groupedStatsByAssignment); const assignments = Object.keys(groupedStatsByAssignment);
await assignments.forEach(async (assignmentId) => { assignments.forEach(async (assignmentId) => {
const assignmentStats = groupedStatsByAssignment[assignmentId] as Stat[]; const assignmentStats = groupedStatsByAssignment[assignmentId] as Stat[];
const assignmentSnapshot = await getDoc(doc(db, "assignments", assignmentId)); const assignmentSnapshot = await db.collection("assignments").findOne<Assignment>({ id: assignmentId });
await setDoc( await db.collection("assignments").updateOne(
doc(db, "assignments", assignmentId), { id: assignmentId },
{ {
$set: {
results: [ results: [
...(assignmentSnapshot.data() as Assignment).results, ...assignmentSnapshot ? assignmentSnapshot.results : [],
{ user: req.session.user?.id, type: req.session.user?.focus, stats: assignmentStats }, { user: req.session.user?.id, type: req.session.user?.focus, stats: assignmentStats },
], ],
}, }
{merge: true}, }
); );
}); });
} }

View File

@@ -6,29 +6,25 @@ import {sessionOptions} from "@/lib/session";
import {calculateBandScore} from "@/utils/score"; import {calculateBandScore} from "@/utils/score";
import {groupByModule, groupBySession} from "@/utils/stats"; import {groupByModule, groupBySession} from "@/utils/stats";
import { MODULE_ARRAY } from "@/utils/moduleUtils"; import { MODULE_ARRAY } from "@/utils/moduleUtils";
import {getAuth} from "firebase/auth"; import client from "@/lib/mongodb";
import {collection, doc, getDoc, getDocs, getFirestore, query, updateDoc, where} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {groupBy} from "lodash"; import {groupBy} from "lodash";
import {NextApiRequest, NextApiResponse} from "next"; import {NextApiRequest, NextApiResponse} from "next";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(update, sessionOptions); export default withIronSessionApiRoute(update, sessionOptions);
async function update(req: NextApiRequest, res: NextApiResponse) { async function update(req: NextApiRequest, res: NextApiResponse) {
if (req.session.user) { if (req.session.user) {
const docUser = await getDoc(doc(db, "users", req.session.user.id)); const docUser = await db.collection("users").findOne({ id: req.session.user.id });
if (!docUser.exists()) {
if (!docUser) {
res.status(401).json(undefined); res.status(401).json(undefined);
return; return;
} }
const q = query(collection(db, "stats"), where("user", "==", req.session.user.id)); const stats = await db.collection("stats").find<Stat>({ user: req.session.user.id }).toArray();
const stats = (await getDocs(q)).docs.map((doc) => ({
...(doc.data() as Stat),
id: doc.id,
})) as Stat[];
const groupedStats = groupBySession(stats); const groupedStats = groupBySession(stats);
const sessionLevels: {[key in Module]: {correct: number; total: number}}[] = Object.keys(groupedStats).map((key) => { const sessionLevels: {[key in Module]: {correct: number; total: number}}[] = Object.keys(groupedStats).map((key) => {
@@ -102,8 +98,10 @@ async function update(req: NextApiRequest, res: NextApiResponse) {
level: calculateBandScore(levelLevel.correct, levelLevel.total, "level", req.session.user.focus), level: calculateBandScore(levelLevel.correct, levelLevel.total, "level", req.session.user.focus),
}; };
const userDoc = doc(db, "users", req.session.user.id); await db.collection("users").updateOne(
await updateDoc(userDoc, {levels}); { id: req.session.user.id},
{ $set: {levels} }
);
res.status(200).json({ok: true}); res.status(200).json({ok: true});
} else { } else {

View File

@@ -1,11 +1,10 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase"; import client from "@/lib/mongodb";
import {getFirestore, collection, getDocs, query, where, doc, setDoc, addDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -16,14 +15,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
} }
const {user} = req.query; const {user} = req.query;
const q = query(collection(db, "stats"), where("user", "==", user)); const snapshot = await db.collection("stats").find({ user: user }).toArray();
const snapshot = await getDocs(q); res.status(200).json(snapshot);
res.status(200).json(
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})),
);
} }

View File

@@ -1,7 +1,6 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase"; import {app} from "@/firebase";
import {getFirestore, doc, getDoc, deleteDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {Session} from "@/hooks/useSessions"; import {Session} from "@/hooks/useSessions";

View File

@@ -7,11 +7,7 @@ import formidable from "formidable-serverless";
import {getDownloadURL, ref, uploadBytes} from "firebase/storage"; import {getDownloadURL, ref, uploadBytes} from "firebase/storage";
import fs from "fs"; import fs from "fs";
import {app, storage} from "@/firebase"; import {app, storage} from "@/firebase";
import {doc, getDoc, getFirestore, setDoc} from "firebase/firestore";
import {Stat} from "@/interfaces/user";
import {speakingReverseMarking} from "@/utils/score";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {

View File

@@ -1,7 +1,6 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase"; import client from "@/lib/mongodb";
import {getFirestore, setDoc, doc, getDocs, query, collection, where} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {Type, User} from "@/interfaces/user"; import {Type, User} from "@/interfaces/user";
@@ -12,7 +11,8 @@ import * as Stripe from "stripe";
import ShortUniqueId from "short-unique-id"; import ShortUniqueId from "short-unique-id";
import moment from "moment"; import moment from "moment";
const db = getFirestore(app);
const db = client.db(process.env.MONGODB_DB);
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const {email, days, key, checkout} = req.body as {email: string; days: string; key: string; checkout: string}; const {email, days, key, checkout} = req.body as {email: string; days: string; key: string; checkout: string};
@@ -24,41 +24,44 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const uid = new ShortUniqueId(); const uid = new ShortUniqueId();
const code = uid.randomUUID(6); const code = uid.randomUUID(6);
const codeCheckerRef = await getDocs(query(collection(db, "codes"), where("checkout", "==", checkout))); const codeCheckerRef = await db.collection("codes").find({ checkout: checkout }).toArray();
if (codeCheckerRef.docs.length !== 0) { if (codeCheckerRef.length !== 0) {
res.status(401).json({ok: false}); res.status(401).json({ok: false});
return; return;
} }
const emailCheckerRef = await getDocs(query(collection(db, "users"), where("email", "==", email))); const emailCheckerRef = await db.collection("users").find({ email: email }).toArray();
if (emailCheckerRef.docs.length !== 0) { if (emailCheckerRef.length !== 0) {
const user = emailCheckerRef.docs[0]; const user = emailCheckerRef[0];
if (!user.data().subscriptionExpirationDate) { if (!user.subscriptionExpirationDate) {
res.status(200).json({ok: true}); res.status(200).json({ok: true});
return; return;
} }
const codeUserRef = await getDocs(query(collection(db, "codes"), where("userId", "==", user.id))); const codeUserRef = await db.collection("codes").find({ userId: user.id }).toArray();
const userCode = codeUserRef.docs[0]; const userCode = codeUserRef[0];
if (userCode.data().checkout && userCode.data().checkout === checkout) { if (userCode.checkout && userCode.checkout === checkout) {
res.status(401).json({ok: false}); res.status(401).json({ok: false});
return; return;
} }
await db.collection("users").updateOne(
await setDoc( { id: user.id },
user.ref, { $set: {subscriptionExpirationDate: moment(user.subscriptionExpirationDate).add(days, "days").toISOString()} }
{subscriptionExpirationDate: moment(user.data().subscriptionExpirationDate).add(days, "days").toISOString()}, );
{merge: true}, await db.collection("codes").updateOne(
{ id: userCode.id },
{ $set: {checkout} }
); );
await setDoc(userCode.ref, {checkout}, {merge: true});
res.status(200).json({ok: true}); res.status(200).json({ok: true});
return; return;
} }
const codeRef = doc(db, "codes", code); await db.collection("codes").updateOne(
await setDoc(codeRef, {type: "student", code, expiryDate: moment(new Date()).add(days, "days").toISOString(), checkout}); { id: code },
{ $set: {type: "student", code, expiryDate: moment(new Date()).add(days, "days").toISOString(), checkout} }
);
const transport = prepareMailer(); const transport = prepareMailer();
const mailOptions = prepareMailOptions( const mailOptions = prepareMailOptions(

View File

@@ -1,14 +1,13 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import {app} from "@/firebase"; import client from "@/lib/mongodb";
import {getFirestore, getDoc, doc, deleteDoc, setDoc} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { Ticket, TicketTypeLabel, TicketStatusLabel } from "@/interfaces/ticket"; import { Ticket, TicketTypeLabel, TicketStatusLabel } from "@/interfaces/ticket";
import moment from "moment"; import moment from "moment";
import { sendEmail } from "@/email"; import { sendEmail } from "@/email";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -28,10 +27,10 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.query as { id: string }; const { id } = req.query as { id: string };
const snapshot = await getDoc(doc(db, "tickets", id)); const snapshot = await db.collection("tickets").findOne({ id: id });
if (snapshot.exists()) { if (snapshot) {
res.status(200).json({...snapshot.data(), id: snapshot.id}); res.status(200).json({ ...snapshot });
} else { } else {
res.status(404).json(undefined); res.status(404).json(undefined);
} }
@@ -44,13 +43,10 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
} }
const { id } = req.query as { id: string }; const { id } = req.query as { id: string };
const snapshot = await getDoc(doc(db, "tickets", id));
const data = snapshot.data() as Ticket;
const user = req.session.user; const user = req.session.user;
if (user.type === "admin" || user.type === "developer") { if (user.type === "admin" || user.type === "developer") {
await deleteDoc(snapshot.ref); await db.collection("tickets").deleteOne({ id: id });
res.status(200).json({ ok: true }); res.status(200).json({ ok: true });
return; return;
} }
@@ -67,15 +63,17 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.query as { id: string }; const { id } = req.query as { id: string };
const body = req.body as Ticket; const body = req.body as Ticket;
const snapshot = await getDoc(doc(db, "tickets", id)); const data = await db.collection("tickets").updateOne(
{ id: id },
{ $set: body },
{ upsert: true }
);
const user = req.session.user; const user = req.session.user;
if (user.type === "admin" || user.type === "developer") { if (user.type === "admin" || user.type === "developer") {
const data = snapshot.data() as Ticket;
await setDoc(snapshot.ref, body, {merge: true});
try { try {
// send email if the status actually changed to completed // send email if the status actually changed to completed
if (data.status !== req.body.status && req.body.status === "completed") { if (body.status !== req.body.status && req.body.status === "completed") {
await sendEmail( await sendEmail(
"ticketStatusCompleted", "ticketStatusCompleted",
{ {
@@ -88,8 +86,8 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
description: body.description, description: body.description,
environment: process.env.ENVIRONMENT, environment: process.env.ENVIRONMENT,
}, },
[data.reporter.email], [body.reporter.email],
`Ticket ${id}: ${data.subject}`, `Ticket ${id}: ${body.subject}`,
); );
} }
} catch (err) { } catch (err) {

View File

@@ -1,16 +1,15 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { sendEmail } from "@/email"; import { sendEmail } from "@/email";
import {app} from "@/firebase";
import { Ticket, TicketTypeLabel, TicketWithCorporate } from "@/interfaces/ticket"; import { Ticket, TicketTypeLabel, TicketWithCorporate } from "@/interfaces/ticket";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import {collection, doc, getDocs, getFirestore, setDoc, where, query} from "firebase/firestore"; import client from "@/lib/mongodb";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import moment from "moment"; import moment from "moment";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import ShortUniqueId from "short-unique-id"; import ShortUniqueId from "short-unique-id";
import { Group, CorporateUser } from "@/interfaces/user"; import { Group, CorporateUser } from "@/interfaces/user";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -37,31 +36,24 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
} }
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
const snapshot = await getDocs(collection(db, "tickets")); const docs = await db.collection("tickets").find<Ticket>({}).toArray();
const docs = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as Ticket[];
// fetch all groups for these users // fetch all groups for these users
const reporters = [...new Set(docs.map((d) => d.reporter.id).filter((id) => id))]; const reporters = [...new Set(docs.map((d) => d.reporter.id).filter((id) => id))];
const groupsSnapshot = await getDocs(query(collection(db, "groups"), where("participants", "array-contains-any", reporters))); const groups = await db.collection("groups").find<Group>({
const groups = groupsSnapshot.docs.map((doc) => doc.data()) as Group[]; participants: { $in: reporters }
}).toArray();
// based on the admin of each group, verify if it exists and it's of type corporate // based on the admin of each group, verify if it exists and it's of type corporate
const groupsAdmins = [...new Set(groups.map((g) => g.admin).filter((id) => id))]; const groupsAdmins = [...new Set(groups.map((g) => g.admin).filter((id) => id))];
const adminsSnapshot = const admins = groupsAdmins.length > 0
groupsAdmins.length > 0 ? await db.collection("users").find<CorporateUser>({ id: { $in: groupsAdmins }, type: "corporate" }).toArray()
? await getDocs(query(collection(db, "users"), where("id", "in", groupsAdmins), where("type", "==", "corporate"))) : [];
: {docs: []};
const admins = adminsSnapshot.docs.map((doc) => doc.data());
const docsWithAdmins = docs.map((d) => { const docsWithAdmins = docs.map((d) => {
const group = groups.find((g) => g.participants.includes(d.reporter.id)); const group = groups.find((g) => g.participants.includes(d.reporter.id));
const admin = admins.find((a) => a.id === group?.admin) as CorporateUser; const admin = admins.find((a) => a.id === group?.admin);
if (admin) { if (admin) {
return { return {
@@ -81,7 +73,11 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const shortUID = new ShortUniqueId(); const shortUID = new ShortUniqueId();
const id = body.id || shortUID.randomUUID(8); const id = body.id || shortUID.randomUUID(8);
await setDoc(doc(db, "tickets", id), body); await db.collection("tickets").updateOne(
{ id: id },
{ $set: { ...body, id: id } }
);
res.status(200).json({ ok: true }); res.status(200).json({ ok: true });
try { try {

View File

@@ -1,10 +1,9 @@
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import {app} from "@/firebase"; import client from "@/lib/mongodb";
import { collection, doc, getDoc, getDocs, getFirestore, query } from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { NextApiRequest, NextApiResponse } from "next"; import { NextApiRequest, NextApiResponse } from "next";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -26,14 +25,9 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
return res.status(400).json({ message: 'Invalid ID' }); return res.status(400).json({ message: 'Invalid ID' });
} }
const docRef = doc(db, "training", id); const doc = await db.collection("training").findOne({ id: id });
const docSnap = await getDoc(docRef); if (doc) {
res.status(200).json(doc);
if (docSnap.exists()) {
res.status(200).json({
id: docSnap.id,
...docSnap.data(),
});
} else { } else {
res.status(404).json({ message: 'Document not found' }); res.status(404).json({ message: 'Document not found' });
} }

View File

@@ -1,11 +1,10 @@
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import axios from "axios"; import axios from "axios";
import { app } from "@/firebase"; import client from "@/lib/mongodb";
import { collection, doc, getDoc, getDocs, getFirestore, query } from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { NextApiRequest, NextApiResponse } from "next"; import { NextApiRequest, NextApiResponse } from "next";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -34,15 +33,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
try { try {
const q = query(collection(db, "training")); const snapshot = await db.collection("training").find({}).toArray();
const snapshot = await getDocs(q); res.status(200).json(snapshot);
res.status(200).json(
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
}))
);
} catch (error) { } catch (error) {
console.error('Error fetching data:', error); console.error('Error fetching data:', error);
res.status(500).json({ message: 'An unexpected error occurred' }); res.status(500).json({ message: 'An unexpected error occurred' });

View File

@@ -1,11 +1,10 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase"; import client from "@/lib/mongodb";
import {getFirestore, collection, getDocs, query, where, doc, setDoc, addDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -16,14 +15,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
} }
const {user} = req.query; const {user} = req.query;
const q = query(collection(db, "training"), where("user", "==", user)); const snapshot = await db.collection("training").find({ user: user }).toArray();
res.status(200).json(snapshot);
const snapshot = await getDocs(q);
res.status(200).json(
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})),
);
} }

View File

@@ -1,10 +1,9 @@
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { app } from "@/firebase"; import client from "@/lib/mongodb";
import { collection, doc, documentId, getDoc, getDocs, getFirestore, query, where } from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { NextApiRequest, NextApiResponse } from "next"; import { NextApiRequest, NextApiResponse } from "next";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -25,18 +24,11 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
return res.status(400).json({ message: 'Invalid or missing ids!' }); return res.status(400).json({ message: 'Invalid or missing ids!' });
} }
const walkthroughCollection = collection(db, 'walkthrough'); const docs = await db.collection('walkthrough').find({
id: { $in: ids }
}).toArray();
const q = query(walkthroughCollection, where(documentId(), 'in', ids)); res.status(200).json(docs);
const querySnapshot = await getDocs(q);
const documents = querySnapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}));
res.status(200).json(documents);
} catch (error) { } catch (error) {
console.error('Error fetching data:', error); console.error('Error fetching data:', error);
res.status(500).json({ message: 'An unexpected error occurred' }); res.status(500).json({ message: 'An unexpected error occurred' });

View File

@@ -2,14 +2,15 @@ import {PERMISSIONS} from "@/constants/userPermissions";
import {app, adminApp} from "@/firebase"; import {app, adminApp} from "@/firebase";
import {Group, User} from "@/interfaces/user"; import {Group, User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {collection, deleteDoc, doc, getDoc, getDocs, getFirestore, query, setDoc, where} from "firebase/firestore";
import {getAuth} from "firebase-admin/auth"; import {getAuth} from "firebase-admin/auth";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {NextApiRequest, NextApiResponse} from "next"; import {NextApiRequest, NextApiResponse} from "next";
import {getPermissions, getPermissionDocs} from "@/utils/permissions.be"; import {getPermissions, getPermissionDocs} from "@/utils/permissions.be";
import client from "@/lib/mongodb";
import {getGroupsForUser, getParticipantGroups} from "@/utils/groups.be";
const db = getFirestore(app);
const auth = getAuth(adminApp); const auth = getAuth(adminApp);
const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(user, sessionOptions); export default withIronSessionApiRoute(user, sessionOptions);
@@ -28,36 +29,26 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query as {id: string}; const {id} = req.query as {id: string};
const docUser = await getDoc(doc(db, "users", req.session.user.id)); const user = await db.collection("users").findOne<User>({id: req.session.user.id});
if (!docUser.exists()) { if (!user) {
res.status(401).json({ok: false}); res.status(401).json({ok: false});
return; return;
} }
const user = docUser.data() as User; const targetUser = await db.collection("users").findOne<User>({id});
if (!targetUser) {
const docTargetUser = await getDoc(doc(db, "users", id));
if (!docTargetUser.exists()) {
res.status(404).json({ok: false}); res.status(404).json({ok: false});
return; return;
} }
const targetUser = {...docTargetUser.data(), id: docTargetUser.id} as User;
if (user.type === "corporate" && (targetUser.type === "student" || targetUser.type === "teacher")) { if (user.type === "corporate" && (targetUser.type === "student" || targetUser.type === "teacher")) {
const userParticipantGroup = await getDocs(query(collection(db, "groups"), where("participants", "array-contains", id))); const groups = await getGroupsForUser(user.id, targetUser.id);
await Promise.all([ await Promise.all([
...userParticipantGroup.docs ...groups
.filter((x) => (x.data() as Group).admin === user.id) .filter((x) => x.admin === user.id)
.map( .map(
async (x) => async (x) =>
await setDoc( await db.collection("groups").updateOne({id: x.id}, {$set: {participants: x.participants.filter((y: string) => y !== id)}}),
x.ref,
{
participants: x.data().participants.filter((y: string) => y !== id),
},
{merge: true},
),
), ),
]); ]);
@@ -65,41 +56,30 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
} }
await auth.deleteUser(id); await auth.deleteUser(id);
await deleteDoc(doc(db, "users", id)); await db.collection("users").deleteOne({id: targetUser.id});
const userCodeDocs = await getDocs(query(collection(db, "codes"), where("userId", "==", id))); await db.collection("codes").deleteMany({userId: targetUser.id});
const userParticipantGroup = await getDocs(query(collection(db, "groups"), where("participants", "array-contains", id))); await db.collection("groups").deleteMany({admin: targetUser.id});
const userGroupAdminDocs = await getDocs(query(collection(db, "groups"), where("admin", "==", id))); await db.collection("stats").deleteMany({user: targetUser.id});
const userStatsDocs = await getDocs(query(collection(db, "stats"), where("user", "==", id)));
await Promise.all([ const groups = await getParticipantGroups(targetUser.id);
...userCodeDocs.docs.map(async (x) => await deleteDoc(x.ref)), await Promise.all(
...userGroupAdminDocs.docs.map(async (x) => await deleteDoc(x.ref)), groups.map(
...userStatsDocs.docs.map(async (x) => await deleteDoc(x.ref)), async (x) => await db.collection("groups").updateOne({id: x.id}, {$set: {participants: x.participants.filter((y: string) => y !== id)}}),
...userParticipantGroup.docs.map(
async (x) =>
await setDoc(
x.ref,
{
participants: x.data().participants.filter((y: string) => y !== id),
},
{merge: true},
), ),
), );
]);
res.json({ok: true}); res.json({ok: true});
} }
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
if (req.session.user) { if (req.session.user) {
const docUser = await getDoc(doc(db, "users", req.session.user.id)); const user = await db.collection("users").findOne<User>({id: req.session.user.id});
if (!docUser.exists()) { if (!user) {
res.status(401).json(undefined); res.status(401).json(undefined);
return; return;
} }
const user = docUser.data() as User; await db.collection("users").updateOne({id: user.id}, {$set: {lastLogin: new Date().toISOString()}});
await setDoc(docUser.ref, {lastLogin: new Date().toISOString()}, {merge: true});
req.session.user = { req.session.user = {
...user, ...user,

View File

@@ -1,17 +1,10 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase"; import client from "@/lib/mongodb";
import {
getFirestore,
collection,
getDocs,
getDoc,
doc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
const db = getFirestore(app); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -22,8 +15,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
} }
const { id } = req.query as { id: string }; const { id } = req.query as { id: string };
const snapshot = await getDoc(doc(db, "users", id)); const snapshot = await db.collection("users").findOne({ id: id });
if (!snapshot.exists()) return res.status(404).json({ ok: false });
res.status(200).json({ ...snapshot.data(), id: snapshot.id }); if (!snapshot) return res.status(404).json({ ok: false });
res.status(200).json(snapshot);
} }

Some files were not shown because too many files have changed in this diff Show More