Compare commits

...

16 commits

Author SHA1 Message Date
supalerk-ar66
e02da48f7c feat: Implement the course discovery and catalog page, including filtering, search, and course detail view.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 16s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 4s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
2026-03-06 17:34:27 +07:00
Missez
ae32cfebe4 feat: add utils/date.ts and stores api/user/me
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 56s
Build and Deploy Frontend Management to Dev Server / Deploy E-learning Frontend Management to Dev Server (push) Successful in 4s
Build and Deploy Frontend Management to Dev Server / Notify Deployment Status (push) Successful in 1s
2026-03-06 17:33:01 +07:00
Missez
ea442d7815 del readme 2026-03-06 15:58:58 +07:00
Missez
ac768a3df4 add readme testresult
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 53s
Build and Deploy Frontend Management to Dev Server / Deploy E-learning Frontend Management to Dev Server (push) Successful in 4s
Build and Deploy Frontend Management to Dev Server / Notify Deployment Status (push) Successful in 1s
2026-03-06 15:53:13 +07:00
Missez
9e4fcbf04e Add tests result 2026-03-06 15:47:43 +07:00
supalerk-ar66
853c141910 feat: Add i18n support with English and Thai locales and introduce new browse pages.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 51s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 4s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
2026-03-06 13:33:58 +07:00
supalerk-ar66
b0b665f588 feat: Implement E2E tests for authentication, student account, discovery, and classroom features, alongside new browse pages and a useAuth composable.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 48s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 4s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
2026-03-06 12:43:49 +07:00
Missez
0205aab461 feat: Introduce core authentication service, several new admin management pages, and instructor feature tests.
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 52s
Build and Deploy Frontend Management to Dev Server / Deploy E-learning Frontend Management to Dev Server (push) Successful in 4s
Build and Deploy Frontend Management to Dev Server / Notify Deployment Status (push) Successful in 1s
2026-03-06 11:24:10 +07:00
supalerk-ar66
000f9eea5c feat: Add Dockerfile for containerization and package-lock.json for dependency locking.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 1m31s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 4s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
2026-03-06 09:23:15 +07:00
JakkrapartXD
522a0eec8a refactor: update user identification to pass userId directly to services instead of JWT tokens.
Some checks failed
Build and Deploy Backend / Build Backend Docker Image (push) Successful in 48s
Build and Deploy Backend / Deploy E-learning Backend to Dev Server (push) Successful in 9s
Build and Deploy Backend / Notify Deployment Status (push) Successful in 2s
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Failing after 33s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Has been skipped
Build and Deploy Frontend Learner / Notify Deployment Status (push) Failing after 1s
2026-03-04 17:19:58 +07:00
supalerk-ar66
b6c1aebe30 feat: Implement Playwright E2E tests for authentication, quiz, student account, and discovery, and add a new quiz page.
Some checks failed
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Failing after 25s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Has been skipped
Build and Deploy Frontend Learner / Notify Deployment Status (push) Failing after 1s
2026-03-04 15:07:02 +07:00
supalerk-ar66
1c63e79db1 feat: add homepage 2026-03-04 11:48:10 +07:00
JakkrapartXD
9bb941b45e feat: Add unit tests for backend validators and configure Jest. 2026-03-04 10:58:37 +07:00
supalerk-ar66
ebcae0b3e7 feat: implement dashboard page displaying course progress, recommendations, and quick category navigation. 2026-03-04 10:41:53 +07:00
supalerk-ar66
a3b2e55443 feat: Add Playwright end-to-end testing setup and initial test suites for various application flows. 2026-03-02 16:26:22 +07:00
Missez
9bc24fbe8a feat: Establish Playwright testing infrastructure with initial tests for authentication, admin, and instructor modules, and fix instructor video and quiz lesson management pages.
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 1m17s
Build and Deploy Frontend Management to Dev Server / Deploy E-learning Frontend Management to Dev Server (push) Successful in 8s
Build and Deploy Frontend Management to Dev Server / Notify Deployment Status (push) Successful in 2s
2026-03-02 15:48:47 +07:00
232 changed files with 12552 additions and 5091 deletions

22
Backend/jest.config.js Normal file
View file

@ -0,0 +1,22 @@
/** @type {import('jest').Config} */
const config = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/tests'],
testMatch: ['**/*.test.ts'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
},
clearMocks: true,
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/generated/**',
'!src/server.ts'
],
transform: {
'^.+\\.tsx?$': ['ts-jest', { tsconfig: './tsconfig.test.json' }]
}
};
module.exports = config;

File diff suppressed because it is too large Load diff

View file

@ -44,6 +44,7 @@
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.7", "@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^1.4.12", "@types/multer": "^1.4.12",
"@types/node": "^22.10.5", "@types/node": "^22.10.5",

171
Backend/pnpm-lock.yaml generated
View file

@ -78,6 +78,9 @@ importers:
'@types/express': '@types/express':
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.0.6 version: 5.0.6
'@types/jest':
specifier: ^30.0.0
version: 30.0.0
'@types/jsonwebtoken': '@types/jsonwebtoken':
specifier: ^9.0.7 specifier: ^9.0.7
version: 9.0.10 version: 9.0.10
@ -119,7 +122,7 @@ importers:
version: 7.2.2 version: 7.2.2
ts-jest: ts-jest:
specifier: ^29.2.5 specifier: ^29.2.5
version: 29.4.6(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.19.5))(typescript@5.9.3) version: 29.4.6(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@30.2.0)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.5))(typescript@5.9.3)
tsconfig-paths: tsconfig-paths:
specifier: ^4.2.0 specifier: ^4.2.0
version: 4.2.0 version: 4.2.0
@ -615,6 +618,10 @@ packages:
node-notifier: node-notifier:
optional: true optional: true
'@jest/diff-sequences@30.0.1':
resolution: {integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
'@jest/environment@29.7.0': '@jest/environment@29.7.0':
resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -623,6 +630,10 @@ packages:
resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
'@jest/expect-utils@30.2.0':
resolution: {integrity: sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
'@jest/expect@29.7.0': '@jest/expect@29.7.0':
resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -631,10 +642,18 @@ packages:
resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
'@jest/get-type@30.1.0':
resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
'@jest/globals@29.7.0': '@jest/globals@29.7.0':
resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
'@jest/pattern@30.0.1':
resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
'@jest/reporters@29.7.0': '@jest/reporters@29.7.0':
resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -648,6 +667,10 @@ packages:
resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
'@jest/schemas@30.0.5':
resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
'@jest/source-map@29.6.3': '@jest/source-map@29.6.3':
resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -668,6 +691,10 @@ packages:
resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
'@jest/types@30.2.0':
resolution: {integrity: sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@ -862,6 +889,9 @@ packages:
'@sinclair/typebox@0.27.8': '@sinclair/typebox@0.27.8':
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
'@sinclair/typebox@0.34.48':
resolution: {integrity: sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==}
'@sinonjs/commons@3.0.1': '@sinonjs/commons@3.0.1':
resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==}
@ -1115,6 +1145,9 @@ packages:
'@types/istanbul-reports@3.0.4': '@types/istanbul-reports@3.0.4':
resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==}
'@types/jest@30.0.0':
resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==}
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@ -1433,6 +1466,10 @@ packages:
resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
ci-info@4.4.0:
resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==}
engines: {node: '>=8'}
cjs-module-lexer@1.4.3: cjs-module-lexer@1.4.3:
resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==}
@ -1729,6 +1766,10 @@ packages:
resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
expect@30.2.0:
resolution: {integrity: sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
express-rate-limit@7.5.1: express-rate-limit@7.5.1:
resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==} resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==}
engines: {node: '>= 16'} engines: {node: '>= 16'}
@ -2118,6 +2159,10 @@ packages:
resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
jest-diff@30.2.0:
resolution: {integrity: sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
jest-docblock@29.7.0: jest-docblock@29.7.0:
resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -2146,14 +2191,26 @@ packages:
resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
jest-matcher-utils@30.2.0:
resolution: {integrity: sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
jest-message-util@29.7.0: jest-message-util@29.7.0:
resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
jest-message-util@30.2.0:
resolution: {integrity: sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
jest-mock@29.7.0: jest-mock@29.7.0:
resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
jest-mock@30.2.0:
resolution: {integrity: sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
jest-pnp-resolver@1.2.3: jest-pnp-resolver@1.2.3:
resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -2167,6 +2224,10 @@ packages:
resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
jest-regex-util@30.0.1:
resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
jest-resolve-dependencies@29.7.0: jest-resolve-dependencies@29.7.0:
resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -2191,6 +2252,10 @@ packages:
resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
jest-util@30.2.0:
resolution: {integrity: sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
jest-validate@29.7.0: jest-validate@29.7.0:
resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -2584,6 +2649,10 @@ packages:
resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
pretty-format@30.2.0:
resolution: {integrity: sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
prisma@5.22.0: prisma@5.22.0:
resolution: {integrity: sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==} resolution: {integrity: sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==}
engines: {node: '>=16.13'} engines: {node: '>=16.13'}
@ -4027,6 +4096,8 @@ snapshots:
- supports-color - supports-color
- ts-node - ts-node
'@jest/diff-sequences@30.0.1': {}
'@jest/environment@29.7.0': '@jest/environment@29.7.0':
dependencies: dependencies:
'@jest/fake-timers': 29.7.0 '@jest/fake-timers': 29.7.0
@ -4038,6 +4109,10 @@ snapshots:
dependencies: dependencies:
jest-get-type: 29.6.3 jest-get-type: 29.6.3
'@jest/expect-utils@30.2.0':
dependencies:
'@jest/get-type': 30.1.0
'@jest/expect@29.7.0': '@jest/expect@29.7.0':
dependencies: dependencies:
expect: 29.7.0 expect: 29.7.0
@ -4054,6 +4129,8 @@ snapshots:
jest-mock: 29.7.0 jest-mock: 29.7.0
jest-util: 29.7.0 jest-util: 29.7.0
'@jest/get-type@30.1.0': {}
'@jest/globals@29.7.0': '@jest/globals@29.7.0':
dependencies: dependencies:
'@jest/environment': 29.7.0 '@jest/environment': 29.7.0
@ -4063,6 +4140,11 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@jest/pattern@30.0.1':
dependencies:
'@types/node': 22.19.5
jest-regex-util: 30.0.1
'@jest/reporters@29.7.0': '@jest/reporters@29.7.0':
dependencies: dependencies:
'@bcoe/v8-coverage': 0.2.3 '@bcoe/v8-coverage': 0.2.3
@ -4096,6 +4178,10 @@ snapshots:
dependencies: dependencies:
'@sinclair/typebox': 0.27.8 '@sinclair/typebox': 0.27.8
'@jest/schemas@30.0.5':
dependencies:
'@sinclair/typebox': 0.34.48
'@jest/source-map@29.6.3': '@jest/source-map@29.6.3':
dependencies: dependencies:
'@jridgewell/trace-mapping': 0.3.31 '@jridgewell/trace-mapping': 0.3.31
@ -4145,6 +4231,16 @@ snapshots:
'@types/yargs': 17.0.35 '@types/yargs': 17.0.35
chalk: 4.1.2 chalk: 4.1.2
'@jest/types@30.2.0':
dependencies:
'@jest/pattern': 30.0.1
'@jest/schemas': 30.0.5
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
'@types/node': 22.19.5
'@types/yargs': 17.0.35
chalk: 4.1.2
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@ -4316,6 +4412,8 @@ snapshots:
'@sinclair/typebox@0.27.8': {} '@sinclair/typebox@0.27.8': {}
'@sinclair/typebox@0.34.48': {}
'@sinonjs/commons@3.0.1': '@sinonjs/commons@3.0.1':
dependencies: dependencies:
type-detect: 4.0.8 type-detect: 4.0.8
@ -4721,6 +4819,11 @@ snapshots:
dependencies: dependencies:
'@types/istanbul-lib-report': 3.0.3 '@types/istanbul-lib-report': 3.0.3
'@types/jest@30.0.0':
dependencies:
expect: 30.2.0
pretty-format: 30.2.0
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/jsonwebtoken@9.0.10': '@types/jsonwebtoken@9.0.10':
@ -5116,6 +5219,8 @@ snapshots:
ci-info@3.9.0: {} ci-info@3.9.0: {}
ci-info@4.4.0: {}
cjs-module-lexer@1.4.3: {} cjs-module-lexer@1.4.3: {}
cliui@8.0.1: cliui@8.0.1:
@ -5398,6 +5503,15 @@ snapshots:
jest-message-util: 29.7.0 jest-message-util: 29.7.0
jest-util: 29.7.0 jest-util: 29.7.0
expect@30.2.0:
dependencies:
'@jest/expect-utils': 30.2.0
'@jest/get-type': 30.1.0
jest-matcher-utils: 30.2.0
jest-message-util: 30.2.0
jest-mock: 30.2.0
jest-util: 30.2.0
express-rate-limit@7.5.1(express@4.22.1): express-rate-limit@7.5.1(express@4.22.1):
dependencies: dependencies:
express: 4.22.1 express: 4.22.1
@ -5872,6 +5986,13 @@ snapshots:
jest-get-type: 29.6.3 jest-get-type: 29.6.3
pretty-format: 29.7.0 pretty-format: 29.7.0
jest-diff@30.2.0:
dependencies:
'@jest/diff-sequences': 30.0.1
'@jest/get-type': 30.1.0
chalk: 4.1.2
pretty-format: 30.2.0
jest-docblock@29.7.0: jest-docblock@29.7.0:
dependencies: dependencies:
detect-newline: 3.1.0 detect-newline: 3.1.0
@ -5923,6 +6044,13 @@ snapshots:
jest-get-type: 29.6.3 jest-get-type: 29.6.3
pretty-format: 29.7.0 pretty-format: 29.7.0
jest-matcher-utils@30.2.0:
dependencies:
'@jest/get-type': 30.1.0
chalk: 4.1.2
jest-diff: 30.2.0
pretty-format: 30.2.0
jest-message-util@29.7.0: jest-message-util@29.7.0:
dependencies: dependencies:
'@babel/code-frame': 7.27.1 '@babel/code-frame': 7.27.1
@ -5935,18 +6063,38 @@ snapshots:
slash: 3.0.0 slash: 3.0.0
stack-utils: 2.0.6 stack-utils: 2.0.6
jest-message-util@30.2.0:
dependencies:
'@babel/code-frame': 7.27.1
'@jest/types': 30.2.0
'@types/stack-utils': 2.0.3
chalk: 4.1.2
graceful-fs: 4.2.11
micromatch: 4.0.8
pretty-format: 30.2.0
slash: 3.0.0
stack-utils: 2.0.6
jest-mock@29.7.0: jest-mock@29.7.0:
dependencies: dependencies:
'@jest/types': 29.6.3 '@jest/types': 29.6.3
'@types/node': 22.19.5 '@types/node': 22.19.5
jest-util: 29.7.0 jest-util: 29.7.0
jest-mock@30.2.0:
dependencies:
'@jest/types': 30.2.0
'@types/node': 22.19.5
jest-util: 30.2.0
jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): jest-pnp-resolver@1.2.3(jest-resolve@29.7.0):
optionalDependencies: optionalDependencies:
jest-resolve: 29.7.0 jest-resolve: 29.7.0
jest-regex-util@29.6.3: {} jest-regex-util@29.6.3: {}
jest-regex-util@30.0.1: {}
jest-resolve-dependencies@29.7.0: jest-resolve-dependencies@29.7.0:
dependencies: dependencies:
jest-regex-util: 29.6.3 jest-regex-util: 29.6.3
@ -6053,6 +6201,15 @@ snapshots:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
picomatch: 2.3.1 picomatch: 2.3.1
jest-util@30.2.0:
dependencies:
'@jest/types': 30.2.0
'@types/node': 22.19.5
chalk: 4.1.2
ci-info: 4.4.0
graceful-fs: 4.2.11
picomatch: 4.0.3
jest-validate@29.7.0: jest-validate@29.7.0:
dependencies: dependencies:
'@jest/types': 29.6.3 '@jest/types': 29.6.3
@ -6444,6 +6601,12 @@ snapshots:
ansi-styles: 5.2.0 ansi-styles: 5.2.0
react-is: 18.3.1 react-is: 18.3.1
pretty-format@30.2.0:
dependencies:
'@jest/schemas': 30.0.5
ansi-styles: 5.2.0
react-is: 18.3.1
prisma@5.22.0: prisma@5.22.0:
dependencies: dependencies:
'@prisma/engines': 5.22.0 '@prisma/engines': 5.22.0
@ -6797,7 +6960,7 @@ snapshots:
ts-deepmerge@7.0.3: {} ts-deepmerge@7.0.3: {}
ts-jest@29.4.6(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.19.5))(typescript@5.9.3): ts-jest@29.4.6(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@30.2.0)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.5))(typescript@5.9.3):
dependencies: dependencies:
bs-logger: 0.2.6 bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0 fast-json-stable-stringify: 2.1.0
@ -6813,9 +6976,9 @@ snapshots:
optionalDependencies: optionalDependencies:
'@babel/core': 7.28.5 '@babel/core': 7.28.5
'@jest/transform': 29.7.0 '@jest/transform': 29.7.0
'@jest/types': 29.6.3 '@jest/types': 30.2.0
babel-jest: 29.7.0(@babel/core@7.28.5) babel-jest: 29.7.0(@babel/core@7.28.5)
jest-util: 29.7.0 jest-util: 30.2.0
tsconfig-paths@4.2.0: tsconfig-paths@4.2.0:
dependencies: dependencies:

View file

@ -24,9 +24,7 @@ export class AdminCourseApprovalController {
@Response('401', 'Unauthorized') @Response('401', 'Unauthorized')
@Response('403', 'Forbidden - Admin only') @Response('403', 'Forbidden - Admin only')
public async listPendingCourses(@Request() request: any): Promise<ListPendingCoursesResponse> { public async listPendingCourses(@Request() request: any): Promise<ListPendingCoursesResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await AdminCourseApprovalService.listPendingCourses(request.user.id);
if (!token) throw new ValidationError('No token provided');
return await AdminCourseApprovalService.listPendingCourses(token);
} }
/** /**
@ -41,9 +39,7 @@ export class AdminCourseApprovalController {
@Response('403', 'Forbidden - Admin only') @Response('403', 'Forbidden - Admin only')
@Response('404', 'Course not found') @Response('404', 'Course not found')
public async getCourseDetail(@Request() request: any, @Path() courseId: number): Promise<GetCourseDetailForAdminResponse> { public async getCourseDetail(@Request() request: any, @Path() courseId: number): Promise<GetCourseDetailForAdminResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await AdminCourseApprovalService.getCourseDetail(request.user.id, courseId);
if (!token) throw new ValidationError('No token provided');
return await AdminCourseApprovalService.getCourseDetail(token, courseId);
} }
/** /**
@ -62,10 +58,7 @@ export class AdminCourseApprovalController {
@Request() request: any, @Request() request: any,
@Path() courseId: number @Path() courseId: number
): Promise<ApproveCourseResponse> { ): Promise<ApproveCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await AdminCourseApprovalService.approveCourse(request.user.id, courseId, undefined);
if (!token) throw new ValidationError('No token provided');
return await AdminCourseApprovalService.approveCourse(token, courseId, undefined);
} }
/** /**
@ -85,13 +78,10 @@ export class AdminCourseApprovalController {
@Path() courseId: number, @Path() courseId: number,
@Body() body: RejectCourseBody @Body() body: RejectCourseBody
): Promise<RejectCourseResponse> { ): Promise<RejectCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
// Validate body // Validate body
const { error } = RejectCourseValidator.validate(body); const { error } = RejectCourseValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await AdminCourseApprovalService.rejectCourse(token, courseId, body.comment); return await AdminCourseApprovalService.rejectCourse(request.user.id, courseId, body.comment);
} }
} }

View file

@ -40,11 +40,6 @@ export class AuditController {
@Query() page?: number, @Query() page?: number,
@Query() limit?: number @Query() limit?: number
): Promise<ListAuditLogsResponse> { ): Promise<ListAuditLogsResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await auditService.getLogs({ return await auditService.getLogs({
userId, userId,
action, action,
@ -72,11 +67,6 @@ export class AuditController {
@Request() request: any, @Request() request: any,
@Path() logId: number @Path() logId: number
): Promise<AuditLogResponse> { ): Promise<AuditLogResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
const log = await auditService.getLogById(logId); const log = await auditService.getLogById(logId);
if (!log) { if (!log) {
throw new ValidationError('Audit log not found'); throw new ValidationError('Audit log not found');
@ -94,11 +84,6 @@ export class AuditController {
@Response('401', 'Unauthorized') @Response('401', 'Unauthorized')
@Response('403', 'Forbidden - Admin only') @Response('403', 'Forbidden - Admin only')
public async getAuditStats(@Request() request: any): Promise<AuditLogStats> { public async getAuditStats(@Request() request: any): Promise<AuditLogStats> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await auditService.getStats(); return await auditService.getStats();
} }
@ -118,11 +103,6 @@ export class AuditController {
@Path() entityType: string, @Path() entityType: string,
@Path() entityId: number @Path() entityId: number
): Promise<AuditLogResponse[]> { ): Promise<AuditLogResponse[]> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await auditService.getEntityHistory(entityType, entityId); return await auditService.getEntityHistory(entityType, entityId);
} }
@ -142,11 +122,6 @@ export class AuditController {
@Path() userId: number, @Path() userId: number,
@Query() limit?: number @Query() limit?: number
): Promise<AuditLogResponse[]> { ): Promise<AuditLogResponse[]> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await auditService.getUserActivity(userId, limit || 50); return await auditService.getUserActivity(userId, limit || 50);
} }
@ -164,11 +139,6 @@ export class AuditController {
@Request() request: any, @Request() request: any,
@Query() days: number = 90 @Query() days: number = 90
): Promise<{ deleted: number; message: string }> { ): Promise<{ deleted: number; message: string }> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
if (days < 6) { if (days < 6) {
throw new ValidationError('Cannot delete logs newer than 6 days'); throw new ValidationError('Cannot delete logs newer than 6 days');
} }

View file

@ -33,32 +33,6 @@ export class AuthController {
data: { data: {
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
user: {
id: 1,
username: 'admin',
email: 'admin@elearning.local',
email_verified_at: new Date('2024-01-01T00:00:00Z'),
updated_at: new Date('2024-01-01T00:00:00Z'),
created_at: new Date('2024-01-01T00:00:00Z'),
role: {
code: 'ADMIN',
name: {
th: 'ผู้ดูแลระบบ',
en: 'Administrator'
}
},
profile: {
prefix: {
th: 'นาย',
en: 'Mr.'
},
first_name: 'Admin',
last_name: 'User',
phone: null,
avatar_url: null,
birth_date: null
}
}
} }
}) })
public async login(@Body() body: LoginRequest): Promise<LoginResponse> { public async login(@Body() body: LoginRequest): Promise<LoginResponse> {

View file

@ -27,13 +27,11 @@ export class CategoriesAdminController {
@SuccessResponse('200', 'Category created successfully') @SuccessResponse('200', 'Category created successfully')
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
public async createCategory(@Request() request: any, @Body() body: createCategory): Promise<createCategoryResponse> { public async createCategory(@Request() request: any, @Body() body: createCategory): Promise<createCategoryResponse> {
const token = request.headers.authorization?.replace('Bearer ', '') || '';
// Validate body // Validate body
const { error } = CreateCategoryValidator.validate(body); const { error } = CreateCategoryValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await this.categoryService.createCategory(token, body); return await this.categoryService.createCategory(request.user.id, body);
} }
@Put('{id}') @Put('{id}')
@ -41,13 +39,11 @@ export class CategoriesAdminController {
@SuccessResponse('200', 'Category updated successfully') @SuccessResponse('200', 'Category updated successfully')
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
public async updateCategory(@Request() request: any, @Body() body: updateCategory): Promise<updateCategoryResponse> { public async updateCategory(@Request() request: any, @Body() body: updateCategory): Promise<updateCategoryResponse> {
const token = request.headers.authorization?.replace('Bearer ', '') || '';
// Validate body // Validate body
const { error } = UpdateCategoryValidator.validate(body); const { error } = UpdateCategoryValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await this.categoryService.updateCategory(token, body.id, body); return await this.categoryService.updateCategory(request.user.id, body.id, body);
} }
@Delete('{id}') @Delete('{id}')
@ -55,7 +51,6 @@ export class CategoriesAdminController {
@SuccessResponse('200', 'Category deleted successfully') @SuccessResponse('200', 'Category deleted successfully')
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
public async deleteCategory(@Request() request: any, @Path() id: number): Promise<deleteCategoryResponse> { public async deleteCategory(@Request() request: any, @Path() id: number): Promise<deleteCategoryResponse> {
const token = request.headers.authorization?.replace('Bearer ', '') || ''; return await this.categoryService.deleteCategory(request.user.id, id);
return await this.categoryService.deleteCategory(token, id);
} }
} }

View file

@ -1,5 +1,4 @@
import { Get, Post, Route, Tags, SuccessResponse, Response, Security, Path, Request } from 'tsoa'; import { Get, Post, Route, Tags, SuccessResponse, Response, Security, Path, Request } from 'tsoa';
import { ValidationError } from '../middleware/errorHandler';
import { CertificateService } from '../services/certificate.service'; import { CertificateService } from '../services/certificate.service';
import { import {
GenerateCertificateResponse, GenerateCertificateResponse,
@ -21,9 +20,7 @@ export class CertificateController {
@SuccessResponse('200', 'Certificates retrieved successfully') @SuccessResponse('200', 'Certificates retrieved successfully')
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
public async listMyCertificates(@Request() request: any): Promise<ListMyCertificatesResponse> { public async listMyCertificates(@Request() request: any): Promise<ListMyCertificatesResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await this.certificateService.listMyCertificates({ userId: request.user.id });
if (!token) throw new ValidationError('No token provided');
return await this.certificateService.listMyCertificates({ token });
} }
/** /**
@ -37,9 +34,7 @@ export class CertificateController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
@Response('404', 'Certificate not found') @Response('404', 'Certificate not found')
public async getCertificate(@Request() request: any, @Path() courseId: number): Promise<GetCertificateResponse> { public async getCertificate(@Request() request: any, @Path() courseId: number): Promise<GetCertificateResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await this.certificateService.getCertificate({ userId: request.user.id, course_id: courseId });
if (!token) throw new ValidationError('No token provided');
return await this.certificateService.getCertificate({ token, course_id: courseId });
} }
/** /**
@ -54,8 +49,6 @@ export class CertificateController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
@Response('404', 'Enrollment not found') @Response('404', 'Enrollment not found')
public async generateCertificate(@Request() request: any, @Path() courseId: number): Promise<GenerateCertificateResponse> { public async generateCertificate(@Request() request: any, @Path() courseId: number): Promise<GenerateCertificateResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await this.certificateService.generateCertificate({ userId: request.user.id, course_id: courseId });
if (!token) throw new ValidationError('No token provided');
return await this.certificateService.generateCertificate({ token, course_id: courseId });
} }
} }

View file

@ -65,14 +65,11 @@ export class ChaptersLessonInstructorController {
@Path() courseId: number, @Path() courseId: number,
@Body() body: CreateChapterBody @Body() body: CreateChapterBody
): Promise<CreateChapterResponse> { ): Promise<CreateChapterResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = CreateChapterValidator.validate(body); const { error } = CreateChapterValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.createChapter({ return await chaptersLessonService.createChapter({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
title: body.title, title: body.title,
description: body.description, description: body.description,
@ -96,14 +93,11 @@ export class ChaptersLessonInstructorController {
@Path() chapterId: number, @Path() chapterId: number,
@Body() body: UpdateChapterBody @Body() body: UpdateChapterBody
): Promise<UpdateChapterResponse> { ): Promise<UpdateChapterResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = UpdateChapterValidator.validate(body); const { error } = UpdateChapterValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.updateChapter({ return await chaptersLessonService.updateChapter({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
chapter_id: chapterId, chapter_id: chapterId,
...body, ...body,
@ -125,9 +119,7 @@ export class ChaptersLessonInstructorController {
@Path() courseId: number, @Path() courseId: number,
@Path() chapterId: number @Path() chapterId: number
): Promise<DeleteChapterResponse> { ): Promise<DeleteChapterResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await chaptersLessonService.deleteChapter({ userId: request.user.id, course_id: courseId, chapter_id: chapterId });
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.deleteChapter({ token, course_id: courseId, chapter_id: chapterId });
} }
/** /**
@ -143,14 +135,11 @@ export class ChaptersLessonInstructorController {
@Path() chapterId: number, @Path() chapterId: number,
@Body() body: ReorderChapterBody @Body() body: ReorderChapterBody
): Promise<ReorderChapterResponse> { ): Promise<ReorderChapterResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = ReorderChapterValidator.validate(body); const { error } = ReorderChapterValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.reorderChapter({ return await chaptersLessonService.reorderChapter({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
chapter_id: chapterId, chapter_id: chapterId,
sort_order: body.sort_order, sort_order: body.sort_order,
@ -174,9 +163,7 @@ export class ChaptersLessonInstructorController {
@Path() chapterId: number, @Path() chapterId: number,
@Path() lessonId: number @Path() lessonId: number
): Promise<GetLessonResponse> { ): Promise<GetLessonResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await chaptersLessonService.getLesson({ userId: request.user.id, course_id: courseId, chapter_id: chapterId, lesson_id: lessonId });
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.getLesson({ token, course_id: courseId, chapter_id: chapterId, lesson_id: lessonId });
} }
/** /**
@ -192,14 +179,11 @@ export class ChaptersLessonInstructorController {
@Path() chapterId: number, @Path() chapterId: number,
@Body() body: CreateLessonBody @Body() body: CreateLessonBody
): Promise<CreateLessonResponse> { ): Promise<CreateLessonResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = CreateLessonValidator.validate(body); const { error } = CreateLessonValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.createLesson({ return await chaptersLessonService.createLesson({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
chapter_id: chapterId, chapter_id: chapterId,
title: body.title, title: body.title,
@ -223,14 +207,11 @@ export class ChaptersLessonInstructorController {
@Path() lessonId: number, @Path() lessonId: number,
@Body() body: UpdateLessonBody @Body() body: UpdateLessonBody
): Promise<UpdateLessonResponse> { ): Promise<UpdateLessonResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = UpdateLessonValidator.validate(body); const { error } = UpdateLessonValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.updateLesson({ return await chaptersLessonService.updateLesson({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
chapter_id: chapterId, chapter_id: chapterId,
lesson_id: lessonId, lesson_id: lessonId,
@ -258,9 +239,7 @@ export class ChaptersLessonInstructorController {
@Path() chapterId: number, @Path() chapterId: number,
@Path() lessonId: number @Path() lessonId: number
): Promise<DeleteLessonResponse> { ): Promise<DeleteLessonResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await chaptersLessonService.deleteLesson({ userId: request.user.id, course_id: courseId, chapter_id: chapterId, lesson_id: lessonId });
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.deleteLesson({ token, course_id: courseId, chapter_id: chapterId, lesson_id: lessonId });
} }
/** /**
@ -276,14 +255,11 @@ export class ChaptersLessonInstructorController {
@Path() chapterId: number, @Path() chapterId: number,
@Body() body: ReorderLessonsBody @Body() body: ReorderLessonsBody
): Promise<ReorderLessonsResponse> { ): Promise<ReorderLessonsResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = ReorderLessonsValidator.validate(body); const { error } = ReorderLessonsValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.reorderLessons({ return await chaptersLessonService.reorderLessons({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
chapter_id: chapterId, chapter_id: chapterId,
lesson_id: body.lesson_id, lesson_id: body.lesson_id,
@ -309,14 +285,11 @@ export class ChaptersLessonInstructorController {
@Path() lessonId: number, @Path() lessonId: number,
@Body() body: AddQuestionBody @Body() body: AddQuestionBody
): Promise<AddQuestionResponse> { ): Promise<AddQuestionResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = AddQuestionValidator.validate(body); const { error } = AddQuestionValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.addQuestion({ return await chaptersLessonService.addQuestion({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
lesson_id: lessonId, lesson_id: lessonId,
...body, ...body,
@ -338,14 +311,11 @@ export class ChaptersLessonInstructorController {
@Path() questionId: number, @Path() questionId: number,
@Body() body: UpdateQuestionBody @Body() body: UpdateQuestionBody
): Promise<UpdateQuestionResponse> { ): Promise<UpdateQuestionResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = UpdateQuestionValidator.validate(body); const { error } = UpdateQuestionValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.updateQuestion({ return await chaptersLessonService.updateQuestion({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
lesson_id: lessonId, lesson_id: lessonId,
question_id: questionId, question_id: questionId,
@ -364,14 +334,11 @@ export class ChaptersLessonInstructorController {
@Path() questionId: number, @Path() questionId: number,
@Body() body: ReorderQuestionBody @Body() body: ReorderQuestionBody
): Promise<ReorderQuestionResponse> { ): Promise<ReorderQuestionResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = ReorderQuestionValidator.validate(body); const { error } = ReorderQuestionValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.reorderQuestion({ return await chaptersLessonService.reorderQuestion({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
lesson_id: lessonId, lesson_id: lessonId,
question_id: questionId, question_id: questionId,
@ -393,10 +360,8 @@ export class ChaptersLessonInstructorController {
@Path() lessonId: number, @Path() lessonId: number,
@Path() questionId: number @Path() questionId: number
): Promise<DeleteQuestionResponse> { ): Promise<DeleteQuestionResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.deleteQuestion({ return await chaptersLessonService.deleteQuestion({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
lesson_id: lessonId, lesson_id: lessonId,
question_id: questionId, question_id: questionId,
@ -417,14 +382,11 @@ export class ChaptersLessonInstructorController {
@Path() lessonId: number, @Path() lessonId: number,
@Body() body: UpdateQuizBody @Body() body: UpdateQuizBody
): Promise<UpdateQuizResponse> { ): Promise<UpdateQuizResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = UpdateQuizValidator.validate(body); const { error } = UpdateQuizValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.updateQuiz({ return await chaptersLessonService.updateQuiz({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
lesson_id: lessonId, lesson_id: lessonId,
...body, ...body,

View file

@ -22,12 +22,10 @@ import {
GetCourseApprovalHistoryResponse, GetCourseApprovalHistoryResponse,
setCourseDraftResponse, setCourseDraftResponse,
CloneCourseResponse, CloneCourseResponse,
GetAllMyStudentsResponse,
} from '../types/CoursesInstructor.types'; } from '../types/CoursesInstructor.types';
import { CreateCourseValidator, UpdateCourseValidator, CloneCourseValidator } from "../validators/CoursesInstructor.validator"; import { CreateCourseValidator, UpdateCourseValidator, CloneCourseValidator } from "../validators/CoursesInstructor.validator";
import jwt from 'jsonwebtoken';
import { config } from '../config';
@Route('api/instructors/courses') @Route('api/instructors/courses')
@Tags('CoursesInstructor') @Tags('CoursesInstructor')
export class CoursesInstructorController { export class CoursesInstructorController {
@ -45,11 +43,7 @@ export class CoursesInstructorController {
@Request() request: any, @Request() request: any,
@Query() status?: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'ARCHIVED' @Query() status?: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'ARCHIVED'
): Promise<ListMyCourseResponse> { ): Promise<ListMyCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await CoursesInstructorService.listMyCourses({ userId: request.user.id, status });
if (!token) {
throw new ValidationError('No token provided');
}
return await CoursesInstructorService.listMyCourses({ token, status });
} }
/** /**
@ -67,9 +61,23 @@ export class CoursesInstructorController {
@Path() courseId: number, @Path() courseId: number,
@Query() query: string @Query() query: string
): Promise<SearchInstructorResponse> { ): Promise<SearchInstructorResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await CoursesInstructorService.searchInstructors({ userId: request.user.id, query, course_id: courseId });
if (!token) throw new ValidationError('No token provided'); }
return await CoursesInstructorService.searchInstructors({ token, query, course_id: courseId });
/**
* instructor
* Get all students enrolled in all of instructor's courses
*
* @returns total_enrolled total_completed
*/
@Get('my-students')
@Security('jwt', ['instructor'])
@SuccessResponse('200', 'Students retrieved successfully')
@Response('401', 'Unauthorized')
public async getMyAllStudents(
@Request() request: any
): Promise<GetAllMyStudentsResponse> {
return await CoursesInstructorService.getMyAllStudents(request.user.id);
} }
/** /**
@ -83,11 +91,7 @@ export class CoursesInstructorController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
@Response('404', 'Course not found') @Response('404', 'Course not found')
public async getMyCourse(@Request() request: any, @Path() courseId: number): Promise<GetMyCourseResponse> { public async getMyCourse(@Request() request: any, @Path() courseId: number): Promise<GetMyCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await CoursesInstructorService.getmyCourse({ userId: request.user.id, course_id: courseId });
if (!token) {
throw new ValidationError('No token provided');
}
return await CoursesInstructorService.getmyCourse({ token, course_id: courseId });
} }
/** /**
@ -101,13 +105,10 @@ export class CoursesInstructorController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
@Response('404', 'Course not found') @Response('404', 'Course not found')
public async updateCourse(@Request() request: any, @Path() courseId: number, @Body() body: UpdateMyCourse): Promise<UpdateMyCourseResponse> { public async updateCourse(@Request() request: any, @Path() courseId: number, @Body() body: UpdateMyCourse): Promise<UpdateMyCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = UpdateCourseValidator.validate(body.data); const { error } = UpdateCourseValidator.validate(body.data);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await CoursesInstructorService.updateCourse(token, courseId, body.data); return await CoursesInstructorService.updateCourse(request.user.id, courseId, body.data);
} }
/** /**
@ -126,10 +127,6 @@ export class CoursesInstructorController {
@FormField() data: string, @FormField() data: string,
@UploadedFile() thumbnail?: Express.Multer.File @UploadedFile() thumbnail?: Express.Multer.File
): Promise<createCourseResponse> { ): Promise<createCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
const parsed = JSON.parse(data); const parsed = JSON.parse(data);
const { error, value } = CreateCourseValidator.validate(parsed); const { error, value } = CreateCourseValidator.validate(parsed);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
@ -137,7 +134,7 @@ export class CoursesInstructorController {
// Validate thumbnail file type if provided // Validate thumbnail file type if provided
if (thumbnail && !thumbnail.mimetype?.startsWith('image/')) throw new ValidationError('Only image files are allowed for thumbnail'); if (thumbnail && !thumbnail.mimetype?.startsWith('image/')) throw new ValidationError('Only image files are allowed for thumbnail');
return await CoursesInstructorService.createCourse(value, decoded.id, thumbnail); return await CoursesInstructorService.createCourse(value, request.user.id, thumbnail);
} }
/** /**
@ -156,11 +153,9 @@ export class CoursesInstructorController {
@Path() courseId: number, @Path() courseId: number,
@UploadedFile() file: Express.Multer.File @UploadedFile() file: Express.Multer.File
): Promise<{ code: number; message: string; data: { course_id: number; thumbnail_url: string } }> { ): Promise<{ code: number; message: string; data: { course_id: number; thumbnail_url: string } }> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
if (!file.mimetype?.startsWith('image/')) throw new ValidationError('Only image files are allowed'); if (!file.mimetype?.startsWith('image/')) throw new ValidationError('Only image files are allowed');
return await CoursesInstructorService.uploadThumbnail(token, courseId, file); return await CoursesInstructorService.uploadThumbnail(request.user.id, courseId, file);
} }
/** /**
@ -174,9 +169,7 @@ export class CoursesInstructorController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
@Response('404', 'Course not found') @Response('404', 'Course not found')
public async deleteCourse(@Request() request: any, @Path() courseId: number): Promise<DeleteMyCourseResponse> { public async deleteCourse(@Request() request: any, @Path() courseId: number): Promise<DeleteMyCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await CoursesInstructorService.deleteCourse(request.user.id, courseId);
if (!token) throw new ValidationError('No token provided')
return await CoursesInstructorService.deleteCourse(token, courseId);
} }
/** /**
@ -196,14 +189,11 @@ export class CoursesInstructorController {
@Path() courseId: number, @Path() courseId: number,
@Body() body: { title: { th: string; en: string } } @Body() body: { title: { th: string; en: string } }
): Promise<CloneCourseResponse> { ): Promise<CloneCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = CloneCourseValidator.validate(body); const { error } = CloneCourseValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
const result = await CoursesInstructorService.cloneCourse({ const result = await CoursesInstructorService.cloneCourse({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
title: body.title title: body.title
}); });
@ -220,9 +210,7 @@ export class CoursesInstructorController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
@Response('404', 'Course not found') @Response('404', 'Course not found')
public async submitCourse(@Request() request: any, @Path() courseId: number): Promise<submitCourseResponse> { public async submitCourse(@Request() request: any, @Path() courseId: number): Promise<submitCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await CoursesInstructorService.sendCourseForReview({ userId: request.user.id, course_id: courseId });
if (!token) throw new ValidationError('No token provided')
return await CoursesInstructorService.sendCourseForReview({ token, course_id: courseId });
} }
/** /**
@ -236,9 +224,7 @@ export class CoursesInstructorController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
@Response('404', 'Course not found') @Response('404', 'Course not found')
public async setCourseDraft(@Request() request: any, @Path() courseId: number): Promise<setCourseDraftResponse> { public async setCourseDraft(@Request() request: any, @Path() courseId: number): Promise<setCourseDraftResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await CoursesInstructorService.setCourseDraft({ userId: request.user.id, course_id: courseId });
if (!token) throw new ValidationError('No token provided')
return await CoursesInstructorService.setCourseDraft({ token, course_id: courseId });
} }
/** /**
@ -253,9 +239,7 @@ export class CoursesInstructorController {
@Response('403', 'You are not an instructor of this course') @Response('403', 'You are not an instructor of this course')
@Response('404', 'Course not found') @Response('404', 'Course not found')
public async getCourseApprovals(@Request() request: any, @Path() courseId: number): Promise<GetCourseApprovalsResponse> { public async getCourseApprovals(@Request() request: any, @Path() courseId: number): Promise<GetCourseApprovalsResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await CoursesInstructorService.getCourseApprovals(request.user.id, courseId);
if (!token) throw new ValidationError('No token provided')
return await CoursesInstructorService.getCourseApprovals(token, courseId);
} }
/** /**
@ -269,9 +253,7 @@ export class CoursesInstructorController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
@Response('404', 'Instructors not found') @Response('404', 'Instructors not found')
public async listInstructorCourses(@Request() request: any, @Path() courseId: number): Promise<listinstructorCourseResponse> { public async listInstructorCourses(@Request() request: any, @Path() courseId: number): Promise<listinstructorCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await CoursesInstructorService.listInstructorsOfCourse({ userId: request.user.id, course_id: courseId });
if (!token) throw new ValidationError('No token provided')
return await CoursesInstructorService.listInstructorsOfCourse({ token, course_id: courseId });
} }
/** /**
@ -286,9 +268,7 @@ export class CoursesInstructorController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
@Response('404', 'Instructor not found') @Response('404', 'Instructor not found')
public async addInstructor(@Request() request: any, @Path() courseId: number, @Path() emailOrUsername: string): Promise<addinstructorCourseResponse> { public async addInstructor(@Request() request: any, @Path() courseId: number, @Path() emailOrUsername: string): Promise<addinstructorCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await CoursesInstructorService.addInstructorToCourse({ userId: request.user.id, course_id: courseId, email_or_username: emailOrUsername });
if (!token) throw new ValidationError('No token provided')
return await CoursesInstructorService.addInstructorToCourse({ token, course_id: courseId, email_or_username: emailOrUsername });
} }
/** /**
@ -303,9 +283,7 @@ export class CoursesInstructorController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
@Response('404', 'Instructor not found') @Response('404', 'Instructor not found')
public async removeInstructor(@Request() request: any, @Path() courseId: number, @Path() userId: number): Promise<removeinstructorCourseResponse> { public async removeInstructor(@Request() request: any, @Path() courseId: number, @Path() userId: number): Promise<removeinstructorCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await CoursesInstructorService.removeInstructorFromCourse({ userId: request.user.id, course_id: courseId, user_id: userId });
if (!token) throw new ValidationError('No token provided')
return await CoursesInstructorService.removeInstructorFromCourse({ token, course_id: courseId, user_id: userId });
} }
/** /**
@ -320,9 +298,7 @@ export class CoursesInstructorController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
@Response('404', 'Primary instructor not found') @Response('404', 'Primary instructor not found')
public async setPrimaryInstructor(@Request() request: any, @Path() courseId: number, @Path() userId: number): Promise<setprimaryCourseInstructorResponse> { public async setPrimaryInstructor(@Request() request: any, @Path() courseId: number, @Path() userId: number): Promise<setprimaryCourseInstructorResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await CoursesInstructorService.setPrimaryInstructor({ userId: request.user.id, course_id: courseId, user_id: userId });
if (!token) throw new ValidationError('No token provided')
return await CoursesInstructorService.setPrimaryInstructor({ token, course_id: courseId, user_id: userId });
} }
/** /**
@ -347,10 +323,8 @@ export class CoursesInstructorController {
@Query() search?: string, @Query() search?: string,
@Query() status?: 'ENROLLED' | 'COMPLETED' @Query() status?: 'ENROLLED' | 'COMPLETED'
): Promise<GetEnrolledStudentsResponse> { ): Promise<GetEnrolledStudentsResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await CoursesInstructorService.getEnrolledStudents({ return await CoursesInstructorService.getEnrolledStudents({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
page, page,
limit, limit,
@ -376,10 +350,8 @@ export class CoursesInstructorController {
@Path() courseId: number, @Path() courseId: number,
@Path() studentId: number @Path() studentId: number
): Promise<GetEnrolledStudentDetailResponse> { ): Promise<GetEnrolledStudentDetailResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await CoursesInstructorService.getEnrolledStudentDetail({ return await CoursesInstructorService.getEnrolledStudentDetail({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
student_id: studentId, student_id: studentId,
}); });
@ -410,10 +382,8 @@ export class CoursesInstructorController {
@Query() search?: string, @Query() search?: string,
@Query() isPassed?: boolean @Query() isPassed?: boolean
): Promise<GetQuizScoresResponse> { ): Promise<GetQuizScoresResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await CoursesInstructorService.getQuizScores({ return await CoursesInstructorService.getQuizScores({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
lesson_id: lessonId, lesson_id: lessonId,
page, page,
@ -442,10 +412,8 @@ export class CoursesInstructorController {
@Path() lessonId: number, @Path() lessonId: number,
@Path() studentId: number @Path() studentId: number
): Promise<GetQuizAttemptDetailResponse> { ): Promise<GetQuizAttemptDetailResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await CoursesInstructorService.getQuizAttemptDetail({ return await CoursesInstructorService.getQuizAttemptDetail({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
lesson_id: lessonId, lesson_id: lessonId,
student_id: studentId, student_id: studentId,
@ -467,8 +435,6 @@ export class CoursesInstructorController {
@Request() request: any, @Request() request: any,
@Path() courseId: number @Path() courseId: number
): Promise<GetCourseApprovalHistoryResponse> { ): Promise<GetCourseApprovalHistoryResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await CoursesInstructorService.getCourseApprovalHistory(request.user.id, courseId);
if (!token) throw new ValidationError('No token provided');
return await CoursesInstructorService.getCourseApprovalHistory(token, courseId);
} }
} }

View file

@ -36,11 +36,7 @@ export class CoursesStudentController {
@Response('404', 'Course not found') @Response('404', 'Course not found')
@Response('409', 'Already enrolled in this course') @Response('409', 'Already enrolled in this course')
public async enrollCourse(@Request() request: any, @Path() courseId: number): Promise<EnrollCourseResponse> { public async enrollCourse(@Request() request: any, @Path() courseId: number): Promise<EnrollCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await this.service.enrollCourse({ userId: request.user.id, course_id: courseId });
if (!token) {
throw new ValidationError('No token provided');
}
return await this.service.enrollCourse({ token, course_id: courseId });
} }
/** /**
@ -60,11 +56,7 @@ export class CoursesStudentController {
@Query() limit?: number, @Query() limit?: number,
@Query() status?: EnrollmentStatus @Query() status?: EnrollmentStatus
): Promise<ListEnrolledCoursesResponse> { ): Promise<ListEnrolledCoursesResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await this.service.GetEnrolledCourses({ userId: request.user.id, page, limit, status });
if (!token) {
throw new ValidationError('No token provided');
}
return await this.service.GetEnrolledCourses({ token, page, limit, status });
} }
/** /**
@ -79,11 +71,7 @@ export class CoursesStudentController {
@Response('403', 'Not enrolled in this course') @Response('403', 'Not enrolled in this course')
@Response('404', 'Course not found') @Response('404', 'Course not found')
public async getCourseLearning(@Request() request: any, @Path() courseId: number): Promise<GetCourseLearningResponse> { public async getCourseLearning(@Request() request: any, @Path() courseId: number): Promise<GetCourseLearningResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await this.service.getCourseLearning({ userId: request.user.id, course_id: courseId });
if (!token) {
throw new ValidationError('No token provided');
}
return await this.service.getCourseLearning({ token, course_id: courseId });
} }
/** /**
@ -103,11 +91,7 @@ export class CoursesStudentController {
@Path() courseId: number, @Path() courseId: number,
@Path() lessonId: number @Path() lessonId: number
): Promise<GetLessonContentResponse> { ): Promise<GetLessonContentResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await this.service.getlessonContent({ userId: request.user.id, course_id: courseId, lesson_id: lessonId });
if (!token) {
throw new ValidationError('No token provided');
}
return await this.service.getlessonContent({ token, course_id: courseId, lesson_id: lessonId });
} }
/** /**
@ -126,11 +110,7 @@ export class CoursesStudentController {
@Path() courseId: number, @Path() courseId: number,
@Path() lessonId: number @Path() lessonId: number
): Promise<CheckLessonAccessResponse> { ): Promise<CheckLessonAccessResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await this.service.checkAccessLesson({ userId: request.user.id, course_id: courseId, lesson_id: lessonId });
if (!token) {
throw new ValidationError('No token provided');
}
return await this.service.checkAccessLesson({ token, course_id: courseId, lesson_id: lessonId });
} }
/** /**
@ -149,14 +129,12 @@ export class CoursesStudentController {
@Path() lessonId: number, @Path() lessonId: number,
@Body() body: SaveVideoProgressBody @Body() body: SaveVideoProgressBody
): Promise<SaveVideoProgressResponse> { ): Promise<SaveVideoProgressResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = SaveVideoProgressValidator.validate(body); const { error } = SaveVideoProgressValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await this.service.saveVideoProgress({ return await this.service.saveVideoProgress({
token, userId: request.user.id,
lesson_id: lessonId, lesson_id: lessonId,
video_progress_seconds: body.video_progress_seconds, video_progress_seconds: body.video_progress_seconds,
video_duration_seconds: body.video_duration_seconds, video_duration_seconds: body.video_duration_seconds,
@ -178,11 +156,7 @@ export class CoursesStudentController {
@Request() request: any, @Request() request: any,
@Path() lessonId: number @Path() lessonId: number
): Promise<GetVideoProgressResponse> { ): Promise<GetVideoProgressResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await this.service.getVideoProgress({ userId: request.user.id, lesson_id: lessonId });
if (!token) {
throw new ValidationError('No token provided');
}
return await this.service.getVideoProgress({ token, lesson_id: lessonId });
} }
/** /**
@ -202,11 +176,7 @@ export class CoursesStudentController {
@Path() courseId: number, @Path() courseId: number,
@Path() lessonId: number @Path() lessonId: number
): Promise<CompleteLessonResponse> { ): Promise<CompleteLessonResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await this.service.completeLesson({ userId: request.user.id, lesson_id: lessonId });
if (!token) {
throw new ValidationError('No token provided');
}
return await this.service.completeLesson({ token, lesson_id: lessonId });
} }
/** /**
@ -227,14 +197,12 @@ export class CoursesStudentController {
@Path() lessonId: number, @Path() lessonId: number,
@Body() body: SubmitQuizBody @Body() body: SubmitQuizBody
): Promise<SubmitQuizResponse> { ): Promise<SubmitQuizResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = SubmitQuizValidator.validate(body); const { error } = SubmitQuizValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await this.service.submitQuiz({ return await this.service.submitQuiz({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
lesson_id: lessonId, lesson_id: lessonId,
answers: body.answers, answers: body.answers,
@ -258,12 +226,8 @@ export class CoursesStudentController {
@Path() courseId: number, @Path() courseId: number,
@Path() lessonId: number @Path() lessonId: number
): Promise<GetQuizAttemptsResponse> { ): Promise<GetQuizAttemptsResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await this.service.getQuizAttempts({ return await this.service.getQuizAttempts({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
lesson_id: lessonId, lesson_id: lessonId,
}); });

View file

@ -42,8 +42,6 @@ export class LessonsController {
@Path() lessonId: number, @Path() lessonId: number,
@UploadedFile() video: Express.Multer.File @UploadedFile() video: Express.Multer.File
): Promise<VideoOperationResponse> { ): Promise<VideoOperationResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
if (!video) { if (!video) {
throw new ValidationError('Video file is required'); throw new ValidationError('Video file is required');
@ -57,7 +55,7 @@ export class LessonsController {
}; };
return await chaptersLessonService.uploadVideo({ return await chaptersLessonService.uploadVideo({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
lesson_id: lessonId, lesson_id: lessonId,
video: videoInfo, video: videoInfo,
@ -87,8 +85,6 @@ export class LessonsController {
@Path() lessonId: number, @Path() lessonId: number,
@UploadedFile() video: Express.Multer.File @UploadedFile() video: Express.Multer.File
): Promise<VideoOperationResponse> { ): Promise<VideoOperationResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
if (!video) { if (!video) {
throw new ValidationError('Video file is required'); throw new ValidationError('Video file is required');
@ -102,7 +98,7 @@ export class LessonsController {
}; };
return await chaptersLessonService.updateVideo({ return await chaptersLessonService.updateVideo({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
lesson_id: lessonId, lesson_id: lessonId,
video: videoInfo, video: videoInfo,
@ -132,8 +128,6 @@ export class LessonsController {
@Path() lessonId: number, @Path() lessonId: number,
@UploadedFile() attachment: Express.Multer.File @UploadedFile() attachment: Express.Multer.File
): Promise<AttachmentOperationResponse> { ): Promise<AttachmentOperationResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
if (!attachment) { if (!attachment) {
throw new ValidationError('Attachment file is required'); throw new ValidationError('Attachment file is required');
@ -147,7 +141,7 @@ export class LessonsController {
}; };
return await chaptersLessonService.uploadAttachment({ return await chaptersLessonService.uploadAttachment({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
lesson_id: lessonId, lesson_id: lessonId,
attachment: attachmentInfo, attachment: attachmentInfo,
@ -177,11 +171,9 @@ export class LessonsController {
@Path() lessonId: number, @Path() lessonId: number,
@Path() attachmentId: number @Path() attachmentId: number
): Promise<DeleteAttachmentResponse> { ): Promise<DeleteAttachmentResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.deleteAttachment({ return await chaptersLessonService.deleteAttachment({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
lesson_id: lessonId, lesson_id: lessonId,
attachment_id: attachmentId, attachment_id: attachmentId,
@ -211,14 +203,12 @@ export class LessonsController {
@Path() lessonId: number, @Path() lessonId: number,
@Body() body: SetYouTubeVideoBody @Body() body: SetYouTubeVideoBody
): Promise<YouTubeVideoResponse> { ): Promise<YouTubeVideoResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = SetYouTubeVideoValidator.validate(body); const { error } = SetYouTubeVideoValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.setYouTubeVideo({ return await chaptersLessonService.setYouTubeVideo({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
lesson_id: lessonId, lesson_id: lessonId,
youtube_video_id: body.youtube_video_id, youtube_video_id: body.youtube_video_id,

View file

@ -1,5 +1,4 @@
import { Get, Path, Put, Query, Request, Response, Route, Security, SuccessResponse, Tags } from 'tsoa'; import { Get, Path, Put, Query, Request, Response, Route, Security, SuccessResponse, Tags } from 'tsoa';
import { ValidationError } from '../middleware/errorHandler';
import { RecommendedCoursesService } from '../services/RecommendedCourses.service'; import { RecommendedCoursesService } from '../services/RecommendedCourses.service';
import { import {
ListApprovedCoursesResponse, ListApprovedCoursesResponse,
@ -25,9 +24,7 @@ export class RecommendedCoursesController {
@Query() search?: string, @Query() search?: string,
@Query() categoryId?: number @Query() categoryId?: number
): Promise<ListApprovedCoursesResponse> { ): Promise<ListApprovedCoursesResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await RecommendedCoursesService.listApprovedCourses(request.user.id, { search, categoryId });
if (!token) throw new ValidationError('No token provided');
return await RecommendedCoursesService.listApprovedCourses(token, { search, categoryId });
} }
/** /**
@ -43,9 +40,7 @@ export class RecommendedCoursesController {
@Response('403', 'Forbidden - Admin only') @Response('403', 'Forbidden - Admin only')
@Response('404', 'Course not found') @Response('404', 'Course not found')
public async getCourseById(@Request() request: any, @Path() courseId: number): Promise<GetCourseByIdResponse> { public async getCourseById(@Request() request: any, @Path() courseId: number): Promise<GetCourseByIdResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await RecommendedCoursesService.getCourseById(request.user.id, courseId);
if (!token) throw new ValidationError('No token provided');
return await RecommendedCoursesService.getCourseById(token, courseId);
} }
/** /**
@ -65,8 +60,6 @@ export class RecommendedCoursesController {
@Path() courseId: number, @Path() courseId: number,
@Query() is_recommended: boolean @Query() is_recommended: boolean
): Promise<ToggleRecommendedResponse> { ): Promise<ToggleRecommendedResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await RecommendedCoursesService.toggleRecommended(request.user.id, courseId, is_recommended);
if (!token) throw new ValidationError('No token provided');
return await RecommendedCoursesService.toggleRecommended(token, courseId, is_recommended);
} }
} }

View file

@ -1,12 +1,10 @@
import { Get, Body, Post, Route, Tags, SuccessResponse, Response, Example, Controller, Security, Request, Put, UploadedFile } from 'tsoa'; import { Get, Body, Post, Route, Tags, SuccessResponse, Response, Security, Request, Put, UploadedFile } from 'tsoa';
import { ValidationError } from '../middleware/errorHandler'; import { ValidationError } from '../middleware/errorHandler';
import { UserService } from '../services/user.service'; import { UserService } from '../services/user.service';
import { import {
UserResponse, UserResponse,
ProfileResponse,
ProfileUpdate, ProfileUpdate,
ProfileUpdateResponse, ProfileUpdateResponse,
ChangePasswordRequest,
ChangePasswordResponse, ChangePasswordResponse,
updateAvatarResponse, updateAvatarResponse,
SendVerifyEmailResponse, SendVerifyEmailResponse,
@ -23,8 +21,6 @@ export class UserController {
/** /**
* Get current user profile * Get current user profile
* @summary Retrieve authenticated user's profile information
* @param request Express request object with JWT token in Authorization header
*/ */
@Get('me') @Get('me')
@SuccessResponse('200', 'User found') @SuccessResponse('200', 'User found')
@ -32,12 +28,7 @@ export class UserController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
@Security('jwt') @Security('jwt')
public async getMe(@Request() request: any): Promise<UserResponse> { public async getMe(@Request() request: any): Promise<UserResponse> {
// Extract token from Authorization header return await this.userService.getUserProfile(request.user.id);
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await this.userService.getUserProfile(token);
} }
@Put('me') @Put('me')
@ -47,34 +38,20 @@ export class UserController {
@Response('400', 'Validation error') @Response('400', 'Validation error')
public async updateProfile(@Request() request: any, @Body() body: ProfileUpdate): Promise<ProfileUpdateResponse> { public async updateProfile(@Request() request: any, @Body() body: ProfileUpdate): Promise<ProfileUpdateResponse> {
const { error } = profileUpdateSchema.validate(body); const { error } = profileUpdateSchema.validate(body);
if (error) { if (error) throw new ValidationError(error.details[0].message);
throw new ValidationError(error.details[0].message); return await this.userService.updateProfile(request.user.id, body);
}
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await this.userService.updateProfile(token, body);
} }
@Get('roles') @Get('roles')
@Security('jwt') @Security('jwt')
@SuccessResponse('200', 'Roles retrieved successfully') @SuccessResponse('200', 'Roles retrieved successfully')
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
public async getRoles(@Request() request: any): Promise<rolesResponse> { public async getRoles(): Promise<rolesResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await this.userService.getRoles();
if (!token) {
throw new ValidationError('No token provided');
}
return await this.userService.getRoles(token);
} }
/** /**
* Change password * Change password
* @summary Change user password using old password
* @param request Express request object with JWT token in Authorization header
* @param body Old password and new password
* @returns Success message
*/ */
@Post('change-password') @Post('change-password')
@Security('jwt') @Security('jwt')
@ -83,22 +60,12 @@ export class UserController {
@Response('400', 'Validation error') @Response('400', 'Validation error')
public async changePassword(@Request() request: any, @Body() body: ChangePassword): Promise<ChangePasswordResponse> { public async changePassword(@Request() request: any, @Body() body: ChangePassword): Promise<ChangePasswordResponse> {
const { error } = changePasswordSchema.validate(body); const { error } = changePasswordSchema.validate(body);
if (error) { if (error) throw new ValidationError(error.details[0].message);
throw new ValidationError(error.details[0].message); return await this.userService.changePassword(request.user.id, body.oldPassword, body.newPassword);
}
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await this.userService.changePassword(token, body.oldPassword, body.newPassword);
} }
/** /**
* Upload user avatar picture * Upload user avatar picture
* @param request Express request object with JWT token in Authorization header
* @param file Avatar image file
*/ */
@Post('upload-avatar') @Post('upload-avatar')
@Security('jwt') @Security('jwt')
@ -109,9 +76,6 @@ export class UserController {
@Request() request: any, @Request() request: any,
@UploadedFile() file: Express.Multer.File @UploadedFile() file: Express.Multer.File
): Promise<updateAvatarResponse> { ): Promise<updateAvatarResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
// Validate file type (images only) // Validate file type (images only)
if (!file.mimetype?.startsWith('image/')) throw new ValidationError('Only image files are allowed'); if (!file.mimetype?.startsWith('image/')) throw new ValidationError('Only image files are allowed');
@ -119,13 +83,11 @@ export class UserController {
const maxSize = 5 * 1024 * 1024; // 5MB const maxSize = 5 * 1024 * 1024; // 5MB
if (file.size > maxSize) throw new ValidationError('File size must be less than 5MB'); if (file.size > maxSize) throw new ValidationError('File size must be less than 5MB');
return await this.userService.uploadAvatarPicture(token, file); return await this.userService.uploadAvatarPicture(request.user.id, file);
} }
/** /**
* Send verification email to user * Send verification email to user
* @summary Send email verification link to authenticated user's email
* @param request Express request object with JWT token in Authorization header
*/ */
@Post('send-verify-email') @Post('send-verify-email')
@Security('jwt') @Security('jwt')
@ -133,9 +95,7 @@ export class UserController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
@Response('400', 'Email already verified') @Response('400', 'Email already verified')
public async sendVerifyEmail(@Request() request: any): Promise<SendVerifyEmailResponse> { public async sendVerifyEmail(@Request() request: any): Promise<SendVerifyEmailResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await this.userService.sendVerifyEmail(request.user.id);
if (!token) throw new ValidationError('No token provided');
return await this.userService.sendVerifyEmail(token);
} }
/** /**

View file

@ -37,10 +37,8 @@ export class AnnouncementsController {
@Query() page?: number, @Query() page?: number,
@Query() limit?: number @Query() limit?: number
): Promise<ListAnnouncementResponse> { ): Promise<ListAnnouncementResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await announcementsService.listAnnouncement({ return await announcementsService.listAnnouncement({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
page, page,
limit, limit,
@ -63,9 +61,6 @@ export class AnnouncementsController {
@FormField() data: string, @FormField() data: string,
@UploadedFiles() files?: Express.Multer.File[] @UploadedFiles() files?: Express.Multer.File[]
): Promise<CreateAnnouncementResponse> { ): Promise<CreateAnnouncementResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
// Parse JSON data field // Parse JSON data field
const parsed = JSON.parse(data) as CreateAnnouncementBody; const parsed = JSON.parse(data) as CreateAnnouncementBody;
@ -74,7 +69,7 @@ export class AnnouncementsController {
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await announcementsService.createAnnouncement({ return await announcementsService.createAnnouncement({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
title: parsed.title, title: parsed.title,
content: parsed.content, content: parsed.content,
@ -103,15 +98,12 @@ export class AnnouncementsController {
@Path() announcementId: number, @Path() announcementId: number,
@Body() body: UpdateAnnouncementBody @Body() body: UpdateAnnouncementBody
): Promise<UpdateAnnouncementResponse> { ): Promise<UpdateAnnouncementResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
// Validate body // Validate body
const { error } = UpdateAnnouncementValidator.validate(body); const { error } = UpdateAnnouncementValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await announcementsService.updateAnnouncement({ return await announcementsService.updateAnnouncement({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
announcement_id: announcementId, announcement_id: announcementId,
title: body.title, title: body.title,
@ -139,10 +131,8 @@ export class AnnouncementsController {
@Path() courseId: number, @Path() courseId: number,
@Path() announcementId: number @Path() announcementId: number
): Promise<DeleteAnnouncementResponse> { ): Promise<DeleteAnnouncementResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await announcementsService.deleteAnnouncement({ return await announcementsService.deleteAnnouncement({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
announcement_id: announcementId, announcement_id: announcementId,
}); });
@ -166,10 +156,8 @@ export class AnnouncementsController {
@Path() announcementId: number, @Path() announcementId: number,
@UploadedFile() file: Express.Multer.File @UploadedFile() file: Express.Multer.File
): Promise<UploadAnnouncementAttachmentResponse> { ): Promise<UploadAnnouncementAttachmentResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await announcementsService.uploadAttachment({ return await announcementsService.uploadAttachment({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
announcement_id: announcementId, announcement_id: announcementId,
file: file as any, file: file as any,
@ -195,10 +183,8 @@ export class AnnouncementsController {
@Path() announcementId: number, @Path() announcementId: number,
@Path() attachmentId: number @Path() attachmentId: number
): Promise<DeleteAnnouncementAttachmentResponse> { ): Promise<DeleteAnnouncementAttachmentResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await announcementsService.deleteAttachment({ return await announcementsService.deleteAttachment({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
announcement_id: announcementId, announcement_id: announcementId,
attachment_id: attachmentId, attachment_id: attachmentId,
@ -228,10 +214,8 @@ export class AnnouncementsStudentController {
@Query() page?: number, @Query() page?: number,
@Query() limit?: number @Query() limit?: number
): Promise<ListAnnouncementResponse> { ): Promise<ListAnnouncementResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await announcementsService.listAnnouncement({ return await announcementsService.listAnnouncement({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
page, page,
limit, limit,

View file

@ -1,8 +1,6 @@
import { prisma } from '../config/database'; import { prisma } from '../config/database';
import { config } from '../config';
import { logger } from '../config/logger'; import { logger } from '../config/logger';
import { UnauthorizedError, ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler'; import { ValidationError, NotFoundError } from '../middleware/errorHandler';
import jwt from 'jsonwebtoken';
import { getPresignedUrl } from '../config/minio'; import { getPresignedUrl } from '../config/minio';
import { import {
ListPendingCoursesResponse, ListPendingCoursesResponse,
@ -18,7 +16,7 @@ export class AdminCourseApprovalService {
/** /**
* Get all pending courses for admin review * Get all pending courses for admin review
*/ */
static async listPendingCourses(token: string): Promise<ListPendingCoursesResponse> { static async listPendingCourses(userId: number): Promise<ListPendingCoursesResponse> {
try { try {
const courses = await prisma.course.findMany({ const courses = await prisma.course.findMany({
where: { status: 'PENDING' }, where: { status: 'PENDING' },
@ -96,9 +94,8 @@ export class AdminCourseApprovalService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to list pending courses', { error }); logger.error('Failed to list pending courses', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: 0, entityId: 0,
@ -113,7 +110,7 @@ export class AdminCourseApprovalService {
/** /**
* Get course details for admin review * Get course details for admin review
*/ */
static async getCourseDetail(token: string, courseId: number): Promise<GetCourseDetailForAdminResponse> { static async getCourseDetail(userId: number, courseId: number): Promise<GetCourseDetailForAdminResponse> {
try { try {
const course = await prisma.course.findUnique({ const course = await prisma.course.findUnique({
where: { id: courseId }, where: { id: courseId },
@ -228,9 +225,8 @@ export class AdminCourseApprovalService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to get course detail', { error }); logger.error('Failed to get course detail', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: courseId, entityId: courseId,
@ -245,9 +241,8 @@ export class AdminCourseApprovalService {
/** /**
* Approve a course * Approve a course
*/ */
static async approveCourse(token: string, courseId: number, comment?: string): Promise<ApproveCourseResponse> { static async approveCourse(userId: number, courseId: number, comment?: string): Promise<ApproveCourseResponse> {
try { try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
const course = await prisma.course.findUnique({ where: { id: courseId } }); const course = await prisma.course.findUnique({ where: { id: courseId } });
if (!course) { if (!course) {
@ -264,7 +259,7 @@ export class AdminCourseApprovalService {
where: { id: courseId }, where: { id: courseId },
data: { data: {
status: 'APPROVED', status: 'APPROVED',
approved_by: decoded.id, approved_by: userId,
approved_at: new Date() approved_at: new Date()
} }
}), }),
@ -273,7 +268,7 @@ export class AdminCourseApprovalService {
data: { data: {
course_id: courseId, course_id: courseId,
submitted_by: course.created_by, submitted_by: course.created_by,
reviewed_by: decoded.id, reviewed_by: userId,
action: 'APPROVED', action: 'APPROVED',
previous_status: course.status, previous_status: course.status,
new_status: 'APPROVED', new_status: 'APPROVED',
@ -284,7 +279,7 @@ export class AdminCourseApprovalService {
// Audit log - APPROVE_COURSE // Audit log - APPROVE_COURSE
await auditService.logSync({ await auditService.logSync({
userId: decoded.id, userId,
action: AuditAction.APPROVE_COURSE, action: AuditAction.APPROVE_COURSE,
entityType: 'Course', entityType: 'Course',
entityId: courseId, entityId: courseId,
@ -299,9 +294,8 @@ export class AdminCourseApprovalService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to approve course', { error }); logger.error('Failed to approve course', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: courseId, entityId: courseId,
@ -317,9 +311,8 @@ export class AdminCourseApprovalService {
/** /**
* Reject a course * Reject a course
*/ */
static async rejectCourse(token: string, courseId: number, comment: string): Promise<RejectCourseResponse> { static async rejectCourse(userId: number, courseId: number, comment: string): Promise<RejectCourseResponse> {
try { try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
const course = await prisma.course.findUnique({ where: { id: courseId } }); const course = await prisma.course.findUnique({ where: { id: courseId } });
if (!course) { if (!course) {
@ -350,7 +343,7 @@ export class AdminCourseApprovalService {
data: { data: {
course_id: courseId, course_id: courseId,
submitted_by: course.created_by, submitted_by: course.created_by,
reviewed_by: decoded.id, reviewed_by: userId,
action: 'REJECTED', action: 'REJECTED',
previous_status: course.status, previous_status: course.status,
new_status: 'REJECTED', new_status: 'REJECTED',
@ -361,7 +354,7 @@ export class AdminCourseApprovalService {
// Audit log - REJECT_COURSE // Audit log - REJECT_COURSE
await auditService.logSync({ await auditService.logSync({
userId: decoded.id, userId,
action: AuditAction.REJECT_COURSE, action: AuditAction.REJECT_COURSE,
entityType: 'Course', entityType: 'Course',
entityId: courseId, entityId: courseId,
@ -376,9 +369,8 @@ export class AdminCourseApprovalService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to reject course', { error }); logger.error('Failed to reject course', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: courseId, entityId: courseId,

View file

@ -59,14 +59,11 @@ import { AuditAction } from '@prisma/client';
* Course ( Instructor Student) * Course ( Instructor Student)
* Returns: { hasAccess: boolean, role: 'INSTRUCTOR' | 'STUDENT' | null, userId: number } * Returns: { hasAccess: boolean, role: 'INSTRUCTOR' | 'STUDENT' | null, userId: number }
*/ */
async function validateCourseAccess(token: string, course_id: number): Promise<{ async function validateCourseAccess(userId: number, course_id: number): Promise<{
hasAccess: boolean; hasAccess: boolean;
role: 'INSTRUCTOR' | 'STUDENT' | null; role: 'INSTRUCTOR' | 'STUDENT' | null;
userId: number; userId: number;
}> { }> {
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
const userId = decodedToken.id;
const user = await prisma.user.findUnique({ where: { id: userId } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) { if (!user) {
throw new UnauthorizedError('Invalid token'); throw new UnauthorizedError('Invalid token');
@ -98,9 +95,8 @@ async function validateCourseAccess(token: string, course_id: number): Promise<{
export class ChaptersLessonService { export class ChaptersLessonService {
async listChapters(request: ChaptersRequest): Promise<ListChaptersResponse> { async listChapters(request: ChaptersRequest): Promise<ListChaptersResponse> {
try { try {
const { token, course_id } = request; const { userId, course_id } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number }; const user = await prisma.user.findUnique({ where: { id: userId } });
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
if (!user) { if (!user) {
throw new UnauthorizedError('Invalid token'); throw new UnauthorizedError('Invalid token');
} }
@ -117,14 +113,13 @@ export class ChaptersLessonService {
async createChapter(request: CreateChapterInput): Promise<CreateChapterResponse> { async createChapter(request: CreateChapterInput): Promise<CreateChapterResponse> {
try { try {
const { token, course_id, title, description, sort_order } = request; const { userId, course_id, title, description, sort_order } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) { if (!user) {
throw new UnauthorizedError('Invalid token'); throw new UnauthorizedError('Invalid token');
} }
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) { if (!courseInstructor) {
throw new ForbiddenError('You are not permitted to create chapter'); throw new ForbiddenError('You are not permitted to create chapter');
} }
@ -132,7 +127,7 @@ export class ChaptersLessonService {
// Audit log - CREATE Chapter // Audit log - CREATE Chapter
auditService.log({ auditService.log({
userId: decodedToken.id, userId: userId,
action: AuditAction.CREATE, action: AuditAction.CREATE,
entityType: 'Chapter', entityType: 'Chapter',
entityId: chapter.id, entityId: chapter.id,
@ -142,9 +137,8 @@ export class ChaptersLessonService {
return { code: 200, message: 'Chapter created successfully', data: chapter as ChapterData }; return { code: 200, message: 'Chapter created successfully', data: chapter as ChapterData };
} catch (error) { } catch (error) {
logger.error(`Error creating chapter: ${error}`); logger.error(`Error creating chapter: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Chapter', entityType: 'Chapter',
entityId: 0, entityId: 0,
@ -159,14 +153,13 @@ export class ChaptersLessonService {
async updateChapter(request: UpdateChapterInput): Promise<UpdateChapterResponse> { async updateChapter(request: UpdateChapterInput): Promise<UpdateChapterResponse> {
try { try {
const { token, course_id, chapter_id, title, description, sort_order } = request; const { userId, course_id, chapter_id, title, description, sort_order } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) { if (!user) {
throw new UnauthorizedError('Invalid token'); throw new UnauthorizedError('Invalid token');
} }
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) { if (!courseInstructor) {
throw new ForbiddenError('You are not permitted to update chapter'); throw new ForbiddenError('You are not permitted to update chapter');
} }
@ -174,9 +167,8 @@ export class ChaptersLessonService {
return { code: 200, message: 'Chapter updated successfully', data: chapter as ChapterData }; return { code: 200, message: 'Chapter updated successfully', data: chapter as ChapterData };
} catch (error) { } catch (error) {
logger.error(`Error updating chapter: ${error}`); logger.error(`Error updating chapter: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Chapter', entityType: 'Chapter',
entityId: request.chapter_id, entityId: request.chapter_id,
@ -191,14 +183,13 @@ export class ChaptersLessonService {
async deleteChapter(request: DeleteChapterRequest): Promise<DeleteChapterResponse> { async deleteChapter(request: DeleteChapterRequest): Promise<DeleteChapterResponse> {
try { try {
const { token, course_id, chapter_id } = request; const { userId, course_id, chapter_id } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) { if (!user) {
throw new UnauthorizedError('Invalid token'); throw new UnauthorizedError('Invalid token');
} }
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) { if (!courseInstructor) {
throw new ForbiddenError('You are not permitted to delete chapter'); throw new ForbiddenError('You are not permitted to delete chapter');
} }
@ -206,7 +197,7 @@ export class ChaptersLessonService {
// Audit log - DELETE Chapter // Audit log - DELETE Chapter
auditService.log({ auditService.log({
userId: decodedToken.id, userId: userId,
action: AuditAction.DELETE, action: AuditAction.DELETE,
entityType: 'Chapter', entityType: 'Chapter',
entityId: chapter_id, entityId: chapter_id,
@ -219,9 +210,8 @@ export class ChaptersLessonService {
return { code: 200, message: 'Chapter deleted successfully' }; return { code: 200, message: 'Chapter deleted successfully' };
} catch (error) { } catch (error) {
logger.error(`Error deleting chapter: ${error}`); logger.error(`Error deleting chapter: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Chapter', entityType: 'Chapter',
entityId: request.chapter_id, entityId: request.chapter_id,
@ -236,14 +226,13 @@ export class ChaptersLessonService {
async reorderChapter(request: ReorderChapterRequest): Promise<ReorderChapterResponse> { async reorderChapter(request: ReorderChapterRequest): Promise<ReorderChapterResponse> {
try { try {
const { token, course_id, chapter_id, sort_order: newSortOrder } = request; const { userId, course_id, chapter_id, sort_order: newSortOrder } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) { if (!user) {
throw new UnauthorizedError('Invalid token'); throw new UnauthorizedError('Invalid token');
} }
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) { if (!courseInstructor) {
throw new ForbiddenError('You are not permitted to reorder chapter'); throw new ForbiddenError('You are not permitted to reorder chapter');
} }
@ -313,9 +302,8 @@ export class ChaptersLessonService {
return { code: 200, message: 'Chapter reordered successfully', data: chapters as ChapterData[] }; return { code: 200, message: 'Chapter reordered successfully', data: chapters as ChapterData[] };
} catch (error) { } catch (error) {
logger.error(`Error reordering chapter: ${error}`); logger.error(`Error reordering chapter: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Chapter', entityType: 'Chapter',
entityId: request.chapter_id, entityId: request.chapter_id,
@ -335,14 +323,13 @@ export class ChaptersLessonService {
*/ */
async createLesson(request: CreateLessonInput): Promise<CreateLessonResponse> { async createLesson(request: CreateLessonInput): Promise<CreateLessonResponse> {
try { try {
const { token, course_id, chapter_id, title, content, type, sort_order } = request; const { userId, course_id, chapter_id, title, content, type, sort_order } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) { if (!user) {
throw new UnauthorizedError('Invalid token'); throw new UnauthorizedError('Invalid token');
} }
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) { if (!courseInstructor) {
throw new ForbiddenError('You are not permitted to create lesson'); throw new ForbiddenError('You are not permitted to create lesson');
} }
@ -354,7 +341,6 @@ export class ChaptersLessonService {
// If QUIZ type, create empty Quiz shell // If QUIZ type, create empty Quiz shell
if (type === 'QUIZ') { if (type === 'QUIZ') {
const userId = decodedToken.id;
await prisma.quiz.create({ await prisma.quiz.create({
data: { data: {
@ -376,7 +362,7 @@ export class ChaptersLessonService {
// Audit log - CREATE Lesson (QUIZ) // Audit log - CREATE Lesson (QUIZ)
auditService.log({ auditService.log({
userId: decodedToken.id, userId: userId,
action: AuditAction.CREATE, action: AuditAction.CREATE,
entityType: 'Lesson', entityType: 'Lesson',
entityId: lesson.id, entityId: lesson.id,
@ -388,7 +374,7 @@ export class ChaptersLessonService {
// Audit log - CREATE Lesson // Audit log - CREATE Lesson
auditService.log({ auditService.log({
userId: decodedToken.id, userId: userId,
action: AuditAction.CREATE, action: AuditAction.CREATE,
entityType: 'Lesson', entityType: 'Lesson',
entityId: lesson.id, entityId: lesson.id,
@ -398,9 +384,8 @@ export class ChaptersLessonService {
return { code: 200, message: 'Lesson created successfully', data: lesson as LessonData }; return { code: 200, message: 'Lesson created successfully', data: lesson as LessonData };
} catch (error) { } catch (error) {
logger.error(`Error creating lesson: ${error}`); logger.error(`Error creating lesson: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Lesson', entityType: 'Lesson',
entityId: 0, entityId: 0,
@ -419,10 +404,10 @@ export class ChaptersLessonService {
*/ */
async getLesson(request: GetLessonRequest): Promise<GetLessonResponse> { async getLesson(request: GetLessonRequest): Promise<GetLessonResponse> {
try { try {
const { token, course_id, lesson_id } = request; const { userId, course_id, lesson_id } = request;
// Check access for both instructor and enrolled student // Check access for both instructor and enrolled student
const access = await validateCourseAccess(token, course_id); const access = await validateCourseAccess(userId, course_id);
if (!access.hasAccess) { if (!access.hasAccess) {
throw new ForbiddenError('You do not have access to this course'); throw new ForbiddenError('You do not have access to this course');
} }
@ -549,9 +534,8 @@ export class ChaptersLessonService {
return { code: 200, message: 'Lesson fetched successfully', data: lessonData as LessonData }; return { code: 200, message: 'Lesson fetched successfully', data: lessonData as LessonData };
} catch (error) { } catch (error) {
logger.error(`Error fetching lesson: ${error}`); logger.error(`Error fetching lesson: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Lesson', entityType: 'Lesson',
entityId: request.lesson_id, entityId: request.lesson_id,
@ -566,14 +550,13 @@ export class ChaptersLessonService {
async updateLesson(request: UpdateLessonRequest): Promise<UpdateLessonResponse> { async updateLesson(request: UpdateLessonRequest): Promise<UpdateLessonResponse> {
try { try {
const { token, course_id, lesson_id, data } = request; const { userId, course_id, lesson_id, data } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) { if (!user) {
throw new UnauthorizedError('Invalid token'); throw new UnauthorizedError('Invalid token');
} }
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) { if (!courseInstructor) {
throw new ForbiddenError('You are not permitted to update lesson'); throw new ForbiddenError('You are not permitted to update lesson');
} }
@ -581,9 +564,8 @@ export class ChaptersLessonService {
return { code: 200, message: 'Lesson updated successfully', data: lesson as LessonData }; return { code: 200, message: 'Lesson updated successfully', data: lesson as LessonData };
} catch (error) { } catch (error) {
logger.error(`Error updating lesson: ${error}`); logger.error(`Error updating lesson: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Lesson', entityType: 'Lesson',
entityId: request.lesson_id, entityId: request.lesson_id,
@ -602,14 +584,13 @@ export class ChaptersLessonService {
*/ */
async reorderLessons(request: ReorderLessonsRequest): Promise<ReorderLessonsResponse> { async reorderLessons(request: ReorderLessonsRequest): Promise<ReorderLessonsResponse> {
try { try {
const { token, course_id, chapter_id, lesson_id, sort_order: newSortOrder } = request; const { userId, course_id, chapter_id, lesson_id, sort_order: newSortOrder } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedError('Invalid token'); if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to reorder lessons'); if (!courseInstructor) throw new ForbiddenError('You are not permitted to reorder lessons');
// Verify chapter exists and belongs to the course // Verify chapter exists and belongs to the course
@ -682,9 +663,8 @@ export class ChaptersLessonService {
return { code: 200, message: 'Lessons reordered successfully', data: lessons as LessonData[] }; return { code: 200, message: 'Lessons reordered successfully', data: lessons as LessonData[] };
} catch (error) { } catch (error) {
logger.error(`Error reordering lessons: ${error}`); logger.error(`Error reordering lessons: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Lesson', entityType: 'Lesson',
entityId: request.lesson_id, entityId: request.lesson_id,
@ -704,14 +684,13 @@ export class ChaptersLessonService {
*/ */
async deleteLesson(request: DeleteLessonRequest): Promise<DeleteLessonResponse> { async deleteLesson(request: DeleteLessonRequest): Promise<DeleteLessonResponse> {
try { try {
const { token, course_id, lesson_id } = request; const { userId, course_id, lesson_id } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedError('Invalid token'); if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to delete this lesson'); if (!courseInstructor) throw new ForbiddenError('You are not permitted to delete this lesson');
// Fetch lesson with all related data // Fetch lesson with all related data
@ -751,7 +730,7 @@ export class ChaptersLessonService {
// Audit log - DELETE Lesson // Audit log - DELETE Lesson
auditService.log({ auditService.log({
userId: decodedToken.id, userId: userId,
action: AuditAction.DELETE, action: AuditAction.DELETE,
entityType: 'Lesson', entityType: 'Lesson',
entityId: lesson_id, entityId: lesson_id,
@ -764,9 +743,8 @@ export class ChaptersLessonService {
return { code: 200, message: 'Lesson deleted successfully' }; return { code: 200, message: 'Lesson deleted successfully' };
} catch (error) { } catch (error) {
logger.error(`Error deleting lesson: ${error}`); logger.error(`Error deleting lesson: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Lesson', entityType: 'Lesson',
entityId: request.lesson_id, entityId: request.lesson_id,
@ -789,14 +767,13 @@ export class ChaptersLessonService {
*/ */
async uploadVideo(request: UploadVideoInput): Promise<VideoOperationResponse> { async uploadVideo(request: UploadVideoInput): Promise<VideoOperationResponse> {
try { try {
const { token, course_id, lesson_id, video } = request; const { userId, course_id, lesson_id, video } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedError('Invalid token'); if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists and is VIDEO type // Verify lesson exists and is VIDEO type
@ -833,7 +810,7 @@ export class ChaptersLessonService {
// Audit log - UPLOAD_FILE (Video) // Audit log - UPLOAD_FILE (Video)
auditService.log({ auditService.log({
userId: decodedToken.id, userId: userId,
action: AuditAction.UPLOAD_FILE, action: AuditAction.UPLOAD_FILE,
entityType: 'Lesson', entityType: 'Lesson',
entityId: lesson_id, entityId: lesson_id,
@ -853,9 +830,8 @@ export class ChaptersLessonService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error uploading video: ${error}`); logger.error(`Error uploading video: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Lesson', entityType: 'Lesson',
entityId: request.lesson_id, entityId: request.lesson_id,
@ -874,14 +850,13 @@ export class ChaptersLessonService {
*/ */
async updateVideo(request: UpdateVideoInput): Promise<VideoOperationResponse> { async updateVideo(request: UpdateVideoInput): Promise<VideoOperationResponse> {
try { try {
const { token, course_id, lesson_id, video } = request; const { userId, course_id, lesson_id, video } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedError('Invalid token'); if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists and is VIDEO type // Verify lesson exists and is VIDEO type
@ -946,9 +921,8 @@ export class ChaptersLessonService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error updating video: ${error}`); logger.error(`Error updating video: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Lesson', entityType: 'Lesson',
entityId: request.lesson_id, entityId: request.lesson_id,
@ -967,14 +941,13 @@ export class ChaptersLessonService {
*/ */
async setYouTubeVideo(request: SetYouTubeVideoInput): Promise<YouTubeVideoResponse> { async setYouTubeVideo(request: SetYouTubeVideoInput): Promise<YouTubeVideoResponse> {
try { try {
const { token, course_id, lesson_id, youtube_video_id, video_title } = request; const { userId, course_id, lesson_id, youtube_video_id, video_title } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedError('Invalid token'); if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists and is VIDEO type // Verify lesson exists and is VIDEO type
@ -1038,9 +1011,8 @@ export class ChaptersLessonService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error setting YouTube video: ${error}`); logger.error(`Error setting YouTube video: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Lesson', entityType: 'Lesson',
entityId: request.lesson_id, entityId: request.lesson_id,
@ -1059,14 +1031,13 @@ export class ChaptersLessonService {
*/ */
async uploadAttachment(request: UploadAttachmentInput): Promise<AttachmentOperationResponse> { async uploadAttachment(request: UploadAttachmentInput): Promise<AttachmentOperationResponse> {
try { try {
const { token, course_id, lesson_id, attachment } = request; const { userId, course_id, lesson_id, attachment } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedError('Invalid token'); if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists // Verify lesson exists
@ -1101,7 +1072,7 @@ export class ChaptersLessonService {
// Audit log - UPLOAD_FILE (Attachment) // Audit log - UPLOAD_FILE (Attachment)
auditService.log({ auditService.log({
userId: decodedToken.id, userId: userId,
action: AuditAction.UPLOAD_FILE, action: AuditAction.UPLOAD_FILE,
entityType: 'LessonAttachment', entityType: 'LessonAttachment',
entityId: newAttachment.id, entityId: newAttachment.id,
@ -1125,9 +1096,8 @@ export class ChaptersLessonService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error uploading attachment: ${error}`); logger.error(`Error uploading attachment: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'LessonAttachment', entityType: 'LessonAttachment',
entityId: request.lesson_id, entityId: request.lesson_id,
@ -1146,14 +1116,13 @@ export class ChaptersLessonService {
*/ */
async deleteAttachment(request: DeleteAttachmentInput): Promise<DeleteAttachmentResponse> { async deleteAttachment(request: DeleteAttachmentInput): Promise<DeleteAttachmentResponse> {
try { try {
const { token, course_id, lesson_id, attachment_id } = request; const { userId, course_id, lesson_id, attachment_id } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedError('Invalid token'); if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists // Verify lesson exists
@ -1184,7 +1153,7 @@ export class ChaptersLessonService {
// Audit log - DELETE_FILE (Attachment) // Audit log - DELETE_FILE (Attachment)
auditService.log({ auditService.log({
userId: decodedToken.id, userId: userId,
action: AuditAction.DELETE_FILE, action: AuditAction.DELETE_FILE,
entityType: 'LessonAttachment', entityType: 'LessonAttachment',
entityId: attachment_id, entityId: attachment_id,
@ -1194,9 +1163,8 @@ export class ChaptersLessonService {
return { code: 200, message: 'Attachment deleted successfully' }; return { code: 200, message: 'Attachment deleted successfully' };
} catch (error) { } catch (error) {
logger.error(`Error deleting attachment: ${error}`); logger.error(`Error deleting attachment: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'LessonAttachment', entityType: 'LessonAttachment',
entityId: request.attachment_id, entityId: request.attachment_id,
@ -1216,14 +1184,13 @@ export class ChaptersLessonService {
*/ */
async addQuestion(request: AddQuestionInput): Promise<AddQuestionResponse> { async addQuestion(request: AddQuestionInput): Promise<AddQuestionResponse> {
try { try {
const { token, course_id, lesson_id, question, explanation, question_type, sort_order, choices } = request; const { userId, course_id, lesson_id, question, explanation, question_type, sort_order, choices } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedError('Invalid token'); if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists and is QUIZ type // Verify lesson exists and is QUIZ type
@ -1281,9 +1248,8 @@ export class ChaptersLessonService {
return { code: 200, message: 'Question added successfully', data: completeQuestion as QuizQuestionData }; return { code: 200, message: 'Question added successfully', data: completeQuestion as QuizQuestionData };
} catch (error) { } catch (error) {
logger.error(`Error adding question: ${error}`); logger.error(`Error adding question: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Question', entityType: 'Question',
entityId: 0, entityId: 0,
@ -1303,14 +1269,13 @@ export class ChaptersLessonService {
*/ */
async updateQuestion(request: UpdateQuestionInput): Promise<UpdateQuestionResponse> { async updateQuestion(request: UpdateQuestionInput): Promise<UpdateQuestionResponse> {
try { try {
const { token, course_id, lesson_id, question_id, question, explanation, question_type, sort_order, choices } = request; const { userId, course_id, lesson_id, question_id, question, explanation, question_type, sort_order, choices } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedError('Invalid token'); if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists and is QUIZ type // Verify lesson exists and is QUIZ type
@ -1367,9 +1332,8 @@ export class ChaptersLessonService {
return { code: 200, message: 'Question updated successfully', data: completeQuestion as QuizQuestionData }; return { code: 200, message: 'Question updated successfully', data: completeQuestion as QuizQuestionData };
} catch (error) { } catch (error) {
logger.error(`Error updating question: ${error}`); logger.error(`Error updating question: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Question', entityType: 'Question',
entityId: request.question_id, entityId: request.question_id,
@ -1384,14 +1348,13 @@ export class ChaptersLessonService {
async reorderQuestion(request: ReorderQuestionInput): Promise<ReorderQuestionResponse> { async reorderQuestion(request: ReorderQuestionInput): Promise<ReorderQuestionResponse> {
try { try {
const { token, course_id, lesson_id, question_id, sort_order } = request; const { userId, course_id, lesson_id, question_id, sort_order } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedError('Invalid token'); if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists and is QUIZ type // Verify lesson exists and is QUIZ type
@ -1471,9 +1434,8 @@ export class ChaptersLessonService {
return { code: 200, message: 'Question reordered successfully', data: questions as QuizQuestionData[] }; return { code: 200, message: 'Question reordered successfully', data: questions as QuizQuestionData[] };
} catch (error) { } catch (error) {
logger.error(`Error reordering question: ${error}`); logger.error(`Error reordering question: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Question', entityType: 'Question',
entityId: request.question_id, entityId: request.question_id,
@ -1493,14 +1455,13 @@ export class ChaptersLessonService {
*/ */
async deleteQuestion(request: DeleteQuestionInput): Promise<DeleteQuestionResponse> { async deleteQuestion(request: DeleteQuestionInput): Promise<DeleteQuestionResponse> {
try { try {
const { token, course_id, lesson_id, question_id } = request; const { userId, course_id, lesson_id, question_id } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedError('Invalid token'); if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists and is QUIZ type // Verify lesson exists and is QUIZ type
@ -1530,9 +1491,8 @@ export class ChaptersLessonService {
return { code: 200, message: 'Question deleted successfully' }; return { code: 200, message: 'Question deleted successfully' };
} catch (error) { } catch (error) {
logger.error(`Error deleting question: ${error}`); logger.error(`Error deleting question: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Question', entityType: 'Question',
entityId: request.question_id, entityId: request.question_id,
@ -1680,14 +1640,13 @@ export class ChaptersLessonService {
*/ */
async updateQuiz(request: UpdateQuizInput): Promise<UpdateQuizResponse> { async updateQuiz(request: UpdateQuizInput): Promise<UpdateQuizResponse> {
try { try {
const { token, course_id, lesson_id, title, description, passing_score, time_limit, shuffle_questions, shuffle_choices, show_answers_after_completion, is_skippable, allow_multiple_attempts } = request; const { userId, course_id, lesson_id, title, description, passing_score, time_limit, shuffle_questions, shuffle_choices, show_answers_after_completion, is_skippable, allow_multiple_attempts } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedError('Invalid token'); if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists and is QUIZ type // Verify lesson exists and is QUIZ type

View file

@ -1,9 +1,7 @@
import { prisma } from '../config/database'; import { prisma } from '../config/database';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { config } from '../config';
import { logger } from '../config/logger'; import { logger } from '../config/logger';
import { UnauthorizedError, ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler'; import { ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler';
import jwt from 'jsonwebtoken';
import { uploadFile, deleteFile, getPresignedUrl } from '../config/minio'; import { uploadFile, deleteFile, getPresignedUrl } from '../config/minio';
import { import {
CreateCourseInput, CreateCourseInput,
@ -27,6 +25,7 @@ import {
SearchInstructorResponse, SearchInstructorResponse,
GetEnrolledStudentsInput, GetEnrolledStudentsInput,
GetEnrolledStudentsResponse, GetEnrolledStudentsResponse,
EnrolledStudentData,
GetQuizScoresInput, GetQuizScoresInput,
GetQuizScoresResponse, GetQuizScoresResponse,
GetQuizAttemptDetailInput, GetQuizAttemptDetailInput,
@ -38,6 +37,7 @@ import {
CloneCourseResponse, CloneCourseResponse,
setCourseDraft, setCourseDraft,
setCourseDraftResponse, setCourseDraftResponse,
GetAllMyStudentsResponse,
} from "../types/CoursesInstructor.types"; } from "../types/CoursesInstructor.types";
import { auditService } from './audit.service'; import { auditService } from './audit.service';
import { AuditAction } from '@prisma/client'; import { AuditAction } from '@prisma/client';
@ -121,10 +121,9 @@ export class CoursesInstructorService {
static async listMyCourses(input: ListMyCoursesInput): Promise<ListMyCourseResponse> { static async listMyCourses(input: ListMyCoursesInput): Promise<ListMyCourseResponse> {
try { try {
const decoded = jwt.verify(input.token, config.jwt.secret) as { id: number; type: string };
const courseInstructors = await prisma.courseInstructor.findMany({ const courseInstructors = await prisma.courseInstructor.findMany({
where: { where: {
user_id: decoded.id, user_id: input.userId,
course: input.status ? { status: input.status } : undefined course: input.status ? { status: input.status } : undefined
}, },
include: { include: {
@ -157,9 +156,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to retrieve courses', { error }); logger.error('Failed to retrieve courses', { error });
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: 0, entityId: 0,
@ -174,12 +172,10 @@ export class CoursesInstructorService {
static async getmyCourse(getmyCourse: getmyCourse): Promise<GetMyCourseResponse> { static async getmyCourse(getmyCourse: getmyCourse): Promise<GetMyCourseResponse> {
try { try {
const decoded = jwt.verify(getmyCourse.token, config.jwt.secret) as { id: number; type: string };
// Check if user is instructor of this course // Check if user is instructor of this course
const courseInstructor = await prisma.courseInstructor.findFirst({ const courseInstructor = await prisma.courseInstructor.findFirst({
where: { where: {
user_id: decoded.id, user_id: getmyCourse.userId,
course_id: getmyCourse.course_id course_id: getmyCourse.course_id
}, },
include: { include: {
@ -225,9 +221,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to retrieve course', { error }); logger.error('Failed to retrieve course', { error });
const decoded = jwt.decode(getmyCourse.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId: getmyCourse.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: getmyCourse.course_id, entityId: getmyCourse.course_id,
@ -240,9 +235,9 @@ export class CoursesInstructorService {
} }
} }
static async updateCourse(token: string, courseId: number, courseData: UpdateCourseInput): Promise<createCourseResponse> { static async updateCourse(userId: number, courseId: number, courseData: UpdateCourseInput): Promise<createCourseResponse> {
try { try {
await this.validateCourseInstructor(token, courseId); await this.validateCourseInstructor(userId, courseId);
const course = await prisma.course.update({ const course = await prisma.course.update({
where: { where: {
@ -258,9 +253,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to update course', { error }); logger.error('Failed to update course', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: courseId, entityId: courseId,
@ -273,9 +267,9 @@ export class CoursesInstructorService {
} }
} }
static async uploadThumbnail(token: string, courseId: number, file: Express.Multer.File): Promise<{ code: number; message: string; data: { course_id: number; thumbnail_url: string } }> { static async uploadThumbnail(userId: number, courseId: number, file: Express.Multer.File): Promise<{ code: number; message: string; data: { course_id: number; thumbnail_url: string } }> {
try { try {
await this.validateCourseInstructor(token, courseId); await this.validateCourseInstructor(userId, courseId);
// Get current course to check for existing thumbnail // Get current course to check for existing thumbnail
const currentCourse = await prisma.course.findUnique({ const currentCourse = await prisma.course.findUnique({
@ -322,9 +316,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to upload thumbnail', { error }); logger.error('Failed to upload thumbnail', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: courseId, entityId: courseId,
@ -337,9 +330,9 @@ export class CoursesInstructorService {
} }
} }
static async deleteCourse(token: string, courseId: number): Promise<createCourseResponse> { static async deleteCourse(userId: number, courseId: number): Promise<createCourseResponse> {
try { try {
const courseInstructorId = await this.validateCourseInstructor(token, courseId); const courseInstructorId = await this.validateCourseInstructor(userId, courseId);
if (!courseInstructorId.is_primary) { if (!courseInstructorId.is_primary) {
throw new ForbiddenError('You have no permission to delete this course'); throw new ForbiddenError('You have no permission to delete this course');
} }
@ -365,9 +358,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to delete course', { error }); logger.error('Failed to delete course', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: courseId, entityId: courseId,
@ -382,11 +374,10 @@ export class CoursesInstructorService {
static async sendCourseForReview(sendCourseForReview: sendCourseForReview): Promise<submitCourseResponse> { static async sendCourseForReview(sendCourseForReview: sendCourseForReview): Promise<submitCourseResponse> {
try { try {
const decoded = jwt.verify(sendCourseForReview.token, config.jwt.secret) as { id: number; type: string };
await prisma.courseApproval.create({ await prisma.courseApproval.create({
data: { data: {
course_id: sendCourseForReview.course_id, course_id: sendCourseForReview.course_id,
submitted_by: decoded.id, submitted_by: sendCourseForReview.userId,
} }
}); });
await prisma.course.update({ await prisma.course.update({
@ -398,7 +389,7 @@ export class CoursesInstructorService {
} }
}); });
await auditService.logSync({ await auditService.logSync({
userId: decoded.id, userId: sendCourseForReview.userId,
action: AuditAction.UPDATE, action: AuditAction.UPDATE,
entityType: 'Course', entityType: 'Course',
entityId: sendCourseForReview.course_id, entityId: sendCourseForReview.course_id,
@ -412,9 +403,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to send course for review', { error }); logger.error('Failed to send course for review', { error });
const decoded = jwt.decode(sendCourseForReview.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId: sendCourseForReview.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: sendCourseForReview.course_id, entityId: sendCourseForReview.course_id,
@ -429,7 +419,7 @@ export class CoursesInstructorService {
static async setCourseDraft(setCourseDraft: setCourseDraft): Promise<setCourseDraftResponse> { static async setCourseDraft(setCourseDraft: setCourseDraft): Promise<setCourseDraftResponse> {
try { try {
await this.validateCourseInstructor(setCourseDraft.token, setCourseDraft.course_id); await this.validateCourseInstructor(setCourseDraft.userId, setCourseDraft.course_id);
await prisma.course.update({ await prisma.course.update({
where: { where: {
id: setCourseDraft.course_id, id: setCourseDraft.course_id,
@ -445,9 +435,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to set course to draft', { error }); logger.error('Failed to set course to draft', { error });
const decoded = jwt.decode(setCourseDraft.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId: setCourseDraft.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: setCourseDraft.course_id, entityId: setCourseDraft.course_id,
@ -460,7 +449,7 @@ export class CoursesInstructorService {
} }
} }
static async getCourseApprovals(token: string, courseId: number): Promise<{ static async getCourseApprovals(userId: number, courseId: number): Promise<{
code: number; code: number;
message: string; message: string;
data: any[]; data: any[];
@ -468,7 +457,7 @@ export class CoursesInstructorService {
}> { }> {
try { try {
// Validate instructor access // Validate instructor access
await this.validateCourseInstructor(token, courseId); await this.validateCourseInstructor(userId, courseId);
const approvals = await prisma.courseApproval.findMany({ const approvals = await prisma.courseApproval.findMany({
where: { course_id: courseId }, where: { course_id: courseId },
@ -491,9 +480,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to retrieve course approvals', { error }); logger.error('Failed to retrieve course approvals', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: courseId, entityId: courseId,
@ -510,8 +498,6 @@ export class CoursesInstructorService {
static async searchInstructors(input: SearchInstructorInput): Promise<SearchInstructorResponse> { static async searchInstructors(input: SearchInstructorInput): Promise<SearchInstructorResponse> {
try { try {
const decoded = jwt.verify(input.token, config.jwt.secret) as { id: number };
// Get existing instructors in the course // Get existing instructors in the course
const existingInstructors = await prisma.courseInstructor.findMany({ const existingInstructors = await prisma.courseInstructor.findMany({
where: { course_id: input.course_id }, where: { course_id: input.course_id },
@ -528,7 +514,7 @@ export class CoursesInstructorService {
], ],
role: { code: 'INSTRUCTOR' }, role: { code: 'INSTRUCTOR' },
id: { id: {
notIn: [decoded.id, ...existingInstructorIds], notIn: [input.userId, ...existingInstructorIds],
}, },
}, },
include: { include: {
@ -563,9 +549,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to search instructors', { error }); logger.error('Failed to search instructors', { error });
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: input.course_id, entityId: input.course_id,
@ -581,7 +566,7 @@ export class CoursesInstructorService {
static async addInstructorToCourse(addinstructorCourse: addinstructorCourse): Promise<addinstructorCourseResponse> { static async addInstructorToCourse(addinstructorCourse: addinstructorCourse): Promise<addinstructorCourseResponse> {
try { try {
// Validate user is instructor of this course // Validate user is instructor of this course
await this.validateCourseInstructor(addinstructorCourse.token, addinstructorCourse.course_id); await this.validateCourseInstructor(addinstructorCourse.userId, addinstructorCourse.course_id);
// Find user by email or username // Find user by email or username
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({
@ -619,9 +604,8 @@ export class CoursesInstructorService {
} }
}); });
const decoded = jwt.decode(addinstructorCourse.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: addinstructorCourse.userId,
action: AuditAction.CREATE, action: AuditAction.CREATE,
entityType: 'Course', entityType: 'Course',
entityId: addinstructorCourse.course_id, entityId: addinstructorCourse.course_id,
@ -637,9 +621,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to add instructor to course', { error }); logger.error('Failed to add instructor to course', { error });
const decoded = jwt.decode(addinstructorCourse.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId: addinstructorCourse.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: addinstructorCourse.course_id, entityId: addinstructorCourse.course_id,
@ -654,7 +637,6 @@ export class CoursesInstructorService {
static async removeInstructorFromCourse(removeinstructorCourse: removeinstructorCourse): Promise<removeinstructorCourseResponse> { static async removeInstructorFromCourse(removeinstructorCourse: removeinstructorCourse): Promise<removeinstructorCourseResponse> {
try { try {
const decoded = jwt.verify(removeinstructorCourse.token, config.jwt.secret) as { id: number; type: string };
await prisma.courseInstructor.delete({ await prisma.courseInstructor.delete({
where: { where: {
course_id_user_id: { course_id_user_id: {
@ -665,7 +647,7 @@ export class CoursesInstructorService {
}); });
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: removeinstructorCourse.userId,
action: AuditAction.DELETE, action: AuditAction.DELETE,
entityType: 'Course', entityType: 'Course',
entityId: removeinstructorCourse.course_id, entityId: removeinstructorCourse.course_id,
@ -682,9 +664,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to remove instructor from course', { error }); logger.error('Failed to remove instructor from course', { error });
const decoded = jwt.decode(removeinstructorCourse.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId: removeinstructorCourse.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: removeinstructorCourse.course_id, entityId: removeinstructorCourse.course_id,
@ -699,7 +680,6 @@ export class CoursesInstructorService {
static async listInstructorsOfCourse(listinstructorCourse: listinstructorCourse): Promise<listinstructorCourseResponse> { static async listInstructorsOfCourse(listinstructorCourse: listinstructorCourse): Promise<listinstructorCourseResponse> {
try { try {
const decoded = jwt.verify(listinstructorCourse.token, config.jwt.secret) as { id: number; type: string };
const courseInstructors = await prisma.courseInstructor.findMany({ const courseInstructors = await prisma.courseInstructor.findMany({
where: { where: {
course_id: listinstructorCourse.course_id, course_id: listinstructorCourse.course_id,
@ -743,9 +723,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to retrieve instructors of course', { error }); logger.error('Failed to retrieve instructors of course', { error });
const decoded = jwt.decode(listinstructorCourse.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId: listinstructorCourse.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: listinstructorCourse.course_id, entityId: listinstructorCourse.course_id,
@ -760,7 +739,6 @@ export class CoursesInstructorService {
static async setPrimaryInstructor(setprimaryCourseInstructor: setprimaryCourseInstructor): Promise<setprimaryCourseInstructorResponse> { static async setPrimaryInstructor(setprimaryCourseInstructor: setprimaryCourseInstructor): Promise<setprimaryCourseInstructorResponse> {
try { try {
const decoded = jwt.verify(setprimaryCourseInstructor.token, config.jwt.secret) as { id: number; type: string };
await prisma.courseInstructor.update({ await prisma.courseInstructor.update({
where: { where: {
course_id_user_id: { course_id_user_id: {
@ -774,7 +752,7 @@ export class CoursesInstructorService {
}); });
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: setprimaryCourseInstructor.userId,
action: AuditAction.UPDATE, action: AuditAction.UPDATE,
entityType: 'Course', entityType: 'Course',
entityId: setprimaryCourseInstructor.course_id, entityId: setprimaryCourseInstructor.course_id,
@ -791,9 +769,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to set primary instructor', { error }); logger.error('Failed to set primary instructor', { error });
const decoded = jwt.decode(setprimaryCourseInstructor.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId: setprimaryCourseInstructor.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: setprimaryCourseInstructor.course_id, entityId: setprimaryCourseInstructor.course_id,
@ -806,11 +783,10 @@ export class CoursesInstructorService {
} }
} }
static async validateCourseInstructor(token: string, courseId: number): Promise<{ user_id: number; is_primary: boolean }> { static async validateCourseInstructor(userId: number, courseId: number): Promise<{ user_id: number; is_primary: boolean }> {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
const courseInstructor = await prisma.courseInstructor.findFirst({ const courseInstructor = await prisma.courseInstructor.findFirst({
where: { where: {
user_id: decoded.id, user_id: userId,
course_id: courseId course_id: courseId
} }
}); });
@ -839,10 +815,10 @@ export class CoursesInstructorService {
*/ */
static async getEnrolledStudents(input: GetEnrolledStudentsInput): Promise<GetEnrolledStudentsResponse> { static async getEnrolledStudents(input: GetEnrolledStudentsInput): Promise<GetEnrolledStudentsResponse> {
try { try {
const { token, course_id, page = 1, limit = 20, search, status } = input; const { userId, course_id, page = 1, limit = 20, search, status } = input;
// Validate instructor // Validate instructor
await this.validateCourseInstructor(token, course_id); await this.validateCourseInstructor(userId, course_id);
// Build where clause // Build where clause
const whereClause: any = { course_id }; const whereClause: any = { course_id };
@ -917,9 +893,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error getting enrolled students: ${error}`); logger.error(`Error getting enrolled students: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: input.course_id, entityId: input.course_id,
@ -938,11 +913,10 @@ export class CoursesInstructorService {
*/ */
static async getQuizScores(input: GetQuizScoresInput): Promise<GetQuizScoresResponse> { static async getQuizScores(input: GetQuizScoresInput): Promise<GetQuizScoresResponse> {
try { try {
const { token, course_id, lesson_id, page = 1, limit = 20, search, is_passed } = input; const { userId, course_id, lesson_id, page = 1, limit = 20, search, is_passed } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor // Validate instructor
await this.validateCourseInstructor(token, course_id); await this.validateCourseInstructor(userId, course_id);
// Get lesson and verify it's a QUIZ type // Get lesson and verify it's a QUIZ type
const lesson = await prisma.lesson.findUnique({ const lesson = await prisma.lesson.findUnique({
@ -1095,9 +1069,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error getting quiz scores: ${error}`); logger.error(`Error getting quiz scores: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: input.course_id, entityId: input.course_id,
@ -1116,10 +1089,10 @@ export class CoursesInstructorService {
*/ */
static async getQuizAttemptDetail(input: GetQuizAttemptDetailInput): Promise<GetQuizAttemptDetailResponse> { static async getQuizAttemptDetail(input: GetQuizAttemptDetailInput): Promise<GetQuizAttemptDetailResponse> {
try { try {
const { token, course_id, lesson_id, student_id } = input; const { userId, course_id, lesson_id, student_id } = input;
// Validate instructor // Validate instructor
await this.validateCourseInstructor(token, course_id); await this.validateCourseInstructor(userId, course_id);
// Get lesson and verify it's a QUIZ type // Get lesson and verify it's a QUIZ type
const lesson = await prisma.lesson.findUnique({ const lesson = await prisma.lesson.findUnique({
@ -1219,9 +1192,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error getting quiz attempt detail: ${error}`); logger.error(`Error getting quiz attempt detail: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: input.course_id, entityId: input.course_id,
@ -1240,10 +1212,10 @@ export class CoursesInstructorService {
*/ */
static async getEnrolledStudentDetail(input: GetEnrolledStudentDetailInput): Promise<GetEnrolledStudentDetailResponse> { static async getEnrolledStudentDetail(input: GetEnrolledStudentDetailInput): Promise<GetEnrolledStudentDetailResponse> {
try { try {
const { token, course_id, student_id } = input; const { userId, course_id, student_id } = input;
// Validate instructor // Validate instructor
await this.validateCourseInstructor(token, course_id); await this.validateCourseInstructor(userId, course_id);
// Get student info // Get student info
const student = await prisma.user.findUnique({ const student = await prisma.user.findUnique({
@ -1367,9 +1339,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error getting enrolled student detail: ${error}`); logger.error(`Error getting enrolled student detail: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: input.course_id, entityId: input.course_id,
@ -1386,12 +1357,10 @@ export class CoursesInstructorService {
* *
* Get course approval history for instructor to see rejection reasons * Get course approval history for instructor to see rejection reasons
*/ */
static async getCourseApprovalHistory(token: string, courseId: number): Promise<GetCourseApprovalHistoryResponse> { static async getCourseApprovalHistory(userId: number, courseId: number): Promise<GetCourseApprovalHistoryResponse> {
try { try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor access // Validate instructor access
await this.validateCourseInstructor(token, courseId); await this.validateCourseInstructor(userId, courseId);
// Get course with approval history // Get course with approval history
const course = await prisma.course.findUnique({ const course = await prisma.course.findUnique({
@ -1434,9 +1403,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error getting course approval history: ${error}`); logger.error(`Error getting course approval history: ${error}`);
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: courseId, entityId: courseId,
@ -1454,11 +1422,10 @@ export class CoursesInstructorService {
*/ */
static async cloneCourse(input: CloneCourseInput): Promise<CloneCourseResponse> { static async cloneCourse(input: CloneCourseInput): Promise<CloneCourseResponse> {
try { try {
const { token, course_id, title } = input; const { userId, course_id, title } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor // Validate instructor
const courseInstructor = await this.validateCourseInstructor(token, course_id); const courseInstructor = await this.validateCourseInstructor(userId, course_id);
if (!courseInstructor) { if (!courseInstructor) {
throw new ForbiddenError('You are not an instructor of this course'); throw new ForbiddenError('You are not an instructor of this course');
} }
@ -1508,7 +1475,7 @@ export class CoursesInstructorService {
is_free: originalCourse.is_free, is_free: originalCourse.is_free,
have_certificate: originalCourse.have_certificate, have_certificate: originalCourse.have_certificate,
status: 'DRAFT', // Reset status status: 'DRAFT', // Reset status
created_by: decoded.id created_by: userId
} }
}); });
@ -1516,7 +1483,7 @@ export class CoursesInstructorService {
await tx.courseInstructor.create({ await tx.courseInstructor.create({
data: { data: {
course_id: createdCourse.id, course_id: createdCourse.id,
user_id: decoded.id, user_id: userId,
is_primary: true is_primary: true
} }
}); });
@ -1589,7 +1556,7 @@ export class CoursesInstructorService {
shuffle_questions: lesson.quiz.shuffle_questions, shuffle_questions: lesson.quiz.shuffle_questions,
shuffle_choices: lesson.quiz.shuffle_choices, shuffle_choices: lesson.quiz.shuffle_choices,
show_answers_after_completion: lesson.quiz.show_answers_after_completion, show_answers_after_completion: lesson.quiz.show_answers_after_completion,
created_by: decoded.id created_by: userId
} }
}); });
@ -1636,7 +1603,7 @@ export class CoursesInstructorService {
}); });
await auditService.logSync({ await auditService.logSync({
userId: decoded.id, userId: input.userId,
action: AuditAction.CREATE, action: AuditAction.CREATE,
entityType: 'Course', entityType: 'Course',
entityId: newCourse.id, entityId: newCourse.id,
@ -1658,9 +1625,8 @@ export class CoursesInstructorService {
} catch (error) { } catch (error) {
logger.error(`Error cloning course: ${error}`); logger.error(`Error cloning course: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: input.course_id, entityId: input.course_id,
@ -1672,4 +1638,45 @@ export class CoursesInstructorService {
throw error; throw error;
} }
} }
/**
* instructor
* Get all enrolled students across all courses the instructor owns/teaches
*/
static async getMyAllStudents(userId: number): Promise<GetAllMyStudentsResponse> {
try {
// หา course IDs ทั้งหมดที่ instructor สอน
const instructorCourses = await prisma.courseInstructor.findMany({
where: { user_id: userId },
select: { course_id: true }
});
const courseIds = instructorCourses.map(ci => ci.course_id);
if (courseIds.length === 0) {
return { code: 200, message: 'Students retrieved successfully', total_students: 0, total_completed: 0 };
}
// unique students ทั้งหมด
const uniqueStudents = await prisma.enrollment.groupBy({
by: ['user_id'],
where: { course_id: { in: courseIds } },
});
// จำนวน enrollment ที่ COMPLETED
const totalCompleted = await prisma.enrollment.count({
where: { course_id: { in: courseIds }, status: 'COMPLETED' }
});
return {
code: 200,
message: 'Students retrieved successfully',
total_students: uniqueStudents.length,
total_completed: totalCompleted,
};
} catch (error) {
logger.error(`Error getting all students: ${error}`);
throw error;
}
}
} }

View file

@ -133,7 +133,7 @@ export class CoursesStudentService {
async enrollCourse(input: EnrollCourseInput): Promise<EnrollCourseResponse> { async enrollCourse(input: EnrollCourseInput): Promise<EnrollCourseResponse> {
try { try {
const { course_id } = input; const { course_id } = input;
const decoded = jwt.verify(input.token, config.jwt.secret) as { id: number; type: string }; const userId = input.userId;
const course = await prisma.course.findUnique({ const course = await prisma.course.findUnique({
where: { id: course_id }, where: { id: course_id },
@ -146,7 +146,7 @@ export class CoursesStudentService {
const existingEnrollment = await prisma.enrollment.findUnique({ const existingEnrollment = await prisma.enrollment.findUnique({
where: { where: {
unique_enrollment: { unique_enrollment: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
}, },
}, },
@ -159,7 +159,7 @@ export class CoursesStudentService {
const enrollment = await prisma.enrollment.create({ const enrollment = await prisma.enrollment.create({
data: { data: {
course_id, course_id,
user_id: decoded.id, user_id: userId,
status: 'ENROLLED', status: 'ENROLLED',
enrolled_at: new Date(), enrolled_at: new Date(),
}, },
@ -167,11 +167,11 @@ export class CoursesStudentService {
// Audit log - ENROLL // Audit log - ENROLL
auditService.log({ auditService.log({
userId: decoded.id, userId: userId,
action: AuditAction.ENROLL, action: AuditAction.ENROLL,
entityType: 'Enrollment', entityType: 'Enrollment',
entityId: enrollment.id, entityId: enrollment.id,
newValue: { course_id, user_id: decoded.id, status: 'ENROLLED' } newValue: { course_id, user_id: userId, status: 'ENROLLED' }
}); });
return { return {
@ -187,9 +187,9 @@ export class CoursesStudentService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error enrolling in course: ${error}`); logger.error(`Error enrolling in course: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null; // userId from middleware
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Enrollment', entityType: 'Enrollment',
entityId: 0, entityId: 0,
@ -206,13 +206,13 @@ export class CoursesStudentService {
async GetEnrolledCourses(input: ListEnrolledCoursesInput): Promise<ListEnrolledCoursesResponse> { async GetEnrolledCourses(input: ListEnrolledCoursesInput): Promise<ListEnrolledCoursesResponse> {
try { try {
const { token } = input; // destructure input
const page = input.page ?? 1; const page = input.page ?? 1;
const limit = input.limit ?? 20; const limit = input.limit ?? 20;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; const userId = input.userId;
const enrollments = await prisma.enrollment.findMany({ const enrollments = await prisma.enrollment.findMany({
where: { where: {
user_id: decoded.id, user_id: userId,
}, },
include: { include: {
course: { course: {
@ -230,7 +230,7 @@ export class CoursesStudentService {
}); });
const total = await prisma.enrollment.count({ const total = await prisma.enrollment.count({
where: { where: {
user_id: decoded.id, user_id: userId,
}, },
}); });
@ -274,9 +274,9 @@ export class CoursesStudentService {
}; };
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
const decoded = jwt.decode(input.token) as { id: number } | null; // userId from middleware
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Enrollment', entityType: 'Enrollment',
entityId: 0, entityId: 0,
@ -290,8 +290,8 @@ export class CoursesStudentService {
} }
async getCourseLearning(input: GetCourseLearningInput): Promise<GetCourseLearningResponse> { async getCourseLearning(input: GetCourseLearningInput): Promise<GetCourseLearningResponse> {
try { try {
const { token, course_id } = input; const { course_id } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; const userId = input.userId;
// Get course with chapters and lessons (basic info only) // Get course with chapters and lessons (basic info only)
const course = await prisma.course.findUnique({ const course = await prisma.course.findUnique({
@ -330,7 +330,7 @@ export class CoursesStudentService {
const enrollment = await prisma.enrollment.findUnique({ const enrollment = await prisma.enrollment.findUnique({
where: { where: {
unique_enrollment: { unique_enrollment: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
}, },
}, },
@ -345,7 +345,7 @@ export class CoursesStudentService {
prisma.enrollment.update({ prisma.enrollment.update({
where: { where: {
unique_enrollment: { unique_enrollment: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
}, },
}, },
@ -357,7 +357,7 @@ export class CoursesStudentService {
const lessonIds = course.chapters.flatMap(ch => ch.lessons.map(l => l.id)); const lessonIds = course.chapters.flatMap(ch => ch.lessons.map(l => l.id));
const lessonProgress = await prisma.lessonProgress.findMany({ const lessonProgress = await prisma.lessonProgress.findMany({
where: { where: {
user_id: decoded.id, user_id: userId,
lesson_id: { in: lessonIds }, lesson_id: { in: lessonIds },
}, },
}); });
@ -453,9 +453,9 @@ export class CoursesStudentService {
}; };
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
const decoded = jwt.decode(input.token) as { id: number } | null; // userId from middleware
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Enrollment', entityType: 'Enrollment',
entityId: 0, entityId: 0,
@ -470,8 +470,8 @@ export class CoursesStudentService {
async getlessonContent(input: GetLessonContentInput): Promise<GetLessonContentResponse> { async getlessonContent(input: GetLessonContentInput): Promise<GetLessonContentResponse> {
try { try {
const { token, course_id, lesson_id } = input; const { course_id, lesson_id } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; const userId = input.userId;
// Import MinIO functions // Import MinIO functions
@ -479,7 +479,7 @@ export class CoursesStudentService {
const enrollment = await prisma.enrollment.findUnique({ const enrollment = await prisma.enrollment.findUnique({
where: { where: {
unique_enrollment: { unique_enrollment: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
}, },
}, },
@ -528,7 +528,7 @@ export class CoursesStudentService {
const lessonProgress = await prisma.lessonProgress.findUnique({ const lessonProgress = await prisma.lessonProgress.findUnique({
where: { where: {
user_id_lesson_id: { user_id_lesson_id: {
user_id: decoded.id, user_id: userId,
lesson_id, lesson_id,
}, },
}, },
@ -639,7 +639,7 @@ export class CoursesStudentService {
// Get latest quiz attempt for this user // Get latest quiz attempt for this user
latestQuizAttempt = await prisma.quizAttempt.findFirst({ latestQuizAttempt = await prisma.quizAttempt.findFirst({
where: { where: {
user_id: decoded.id, user_id: userId,
quiz_id: lesson.quiz.id, quiz_id: lesson.quiz.id,
}, },
orderBy: { orderBy: {
@ -726,9 +726,9 @@ export class CoursesStudentService {
}; };
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
const decoded = jwt.decode(input.token) as { id: number } | null; // userId from middleware
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Enrollment', entityType: 'Enrollment',
entityId: 0, entityId: 0,
@ -744,14 +744,14 @@ export class CoursesStudentService {
async checkAccessLesson(input: CheckLessonAccessInput): Promise<CheckLessonAccessResponse> { async checkAccessLesson(input: CheckLessonAccessInput): Promise<CheckLessonAccessResponse> {
try { try {
const { token, course_id, lesson_id } = input; const { course_id, lesson_id } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; const userId = input.userId;
// Check enrollment // Check enrollment
const enrollment = await prisma.enrollment.findUnique({ const enrollment = await prisma.enrollment.findUnique({
where: { where: {
unique_enrollment: { unique_enrollment: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
}, },
}, },
@ -845,7 +845,7 @@ export class CoursesStudentService {
// Get user's progress for prerequisite lessons // Get user's progress for prerequisite lessons
const prerequisiteProgress = await prisma.lessonProgress.findMany({ const prerequisiteProgress = await prisma.lessonProgress.findMany({
where: { where: {
user_id: decoded.id, user_id: userId,
lesson_id: { in: prerequisiteIds }, lesson_id: { in: prerequisiteIds },
}, },
}); });
@ -879,7 +879,7 @@ export class CoursesStudentService {
// Check if user passed the quiz // Check if user passed the quiz
const quizAttempt = await prisma.quizAttempt.findFirst({ const quizAttempt = await prisma.quizAttempt.findFirst({
where: { where: {
user_id: decoded.id, user_id: userId,
quiz_id: prereqLesson.quiz.id, quiz_id: prereqLesson.quiz.id,
is_passed: true, is_passed: true,
}, },
@ -925,9 +925,9 @@ export class CoursesStudentService {
}; };
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
const decoded = jwt.decode(input.token) as { id: number } | null; // userId from middleware
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Enrollment', entityType: 'Enrollment',
entityId: 0, entityId: 0,
@ -942,8 +942,8 @@ export class CoursesStudentService {
async getVideoProgress(input: GetVideoProgressInput): Promise<GetVideoProgressResponse> { async getVideoProgress(input: GetVideoProgressInput): Promise<GetVideoProgressResponse> {
try { try {
const { token, lesson_id } = input; const { lesson_id } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; const userId = input.userId;
// Get lesson to find course_id // Get lesson to find course_id
const lesson = await prisma.lesson.findUnique({ const lesson = await prisma.lesson.findUnique({
@ -966,7 +966,7 @@ export class CoursesStudentService {
const enrollment = await prisma.enrollment.findUnique({ const enrollment = await prisma.enrollment.findUnique({
where: { where: {
unique_enrollment: { unique_enrollment: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
}, },
}, },
@ -980,7 +980,7 @@ export class CoursesStudentService {
const progress = await prisma.lessonProgress.findUnique({ const progress = await prisma.lessonProgress.findUnique({
where: { where: {
user_id_lesson_id: { user_id_lesson_id: {
user_id: decoded.id, user_id: userId,
lesson_id, lesson_id,
}, },
}, },
@ -1010,9 +1010,9 @@ export class CoursesStudentService {
}; };
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
const decoded = jwt.decode(input.token) as { id: number } | null; // userId from middleware
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Enrollment', entityType: 'Enrollment',
entityId: 0, entityId: 0,
@ -1027,8 +1027,8 @@ export class CoursesStudentService {
async saveVideoProgress(input: SaveVideoProgressInput): Promise<SaveVideoProgressResponse> { async saveVideoProgress(input: SaveVideoProgressInput): Promise<SaveVideoProgressResponse> {
try { try {
const { token, lesson_id, video_progress_seconds, video_duration_seconds } = input; const { lesson_id, video_progress_seconds, video_duration_seconds } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; const userId = input.userId;
// Get lesson to find course_id // Get lesson to find course_id
const lesson = await prisma.lesson.findUnique({ const lesson = await prisma.lesson.findUnique({
@ -1051,7 +1051,7 @@ export class CoursesStudentService {
const enrollment = await prisma.enrollment.findUnique({ const enrollment = await prisma.enrollment.findUnique({
where: { where: {
unique_enrollment: { unique_enrollment: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
}, },
}, },
@ -1074,12 +1074,12 @@ export class CoursesStudentService {
const progress = await prisma.lessonProgress.upsert({ const progress = await prisma.lessonProgress.upsert({
where: { where: {
user_id_lesson_id: { user_id_lesson_id: {
user_id: decoded.id, user_id: userId,
lesson_id, lesson_id,
}, },
}, },
create: { create: {
user_id: decoded.id, user_id: userId,
lesson_id, lesson_id,
video_progress_seconds, video_progress_seconds,
video_duration_seconds: video_duration_seconds ?? null, video_duration_seconds: video_duration_seconds ?? null,
@ -1098,7 +1098,7 @@ export class CoursesStudentService {
// If video completed, mark lesson as complete and update enrollment progress // If video completed, mark lesson as complete and update enrollment progress
let enrollmentProgress: { progress_percentage: number; is_course_completed: boolean } | undefined; let enrollmentProgress: { progress_percentage: number; is_course_completed: boolean } | undefined;
if (isCompleted) { if (isCompleted) {
const result = await this.markLessonComplete(decoded.id, lesson_id, course_id); const result = await this.markLessonComplete(userId, lesson_id, course_id);
enrollmentProgress = result.enrollmentProgress; enrollmentProgress = result.enrollmentProgress;
} }
@ -1118,9 +1118,9 @@ export class CoursesStudentService {
}; };
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
const decoded = jwt.decode(input.token) as { id: number } | null; // userId from middleware
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Enrollment', entityType: 'Enrollment',
entityId: 0, entityId: 0,
@ -1135,8 +1135,8 @@ export class CoursesStudentService {
async completeLesson(input: CompleteLessonInput): Promise<CompleteLessonResponse> { async completeLesson(input: CompleteLessonInput): Promise<CompleteLessonResponse> {
try { try {
const { token, lesson_id } = input; const { lesson_id } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; const userId = input.userId;
// Get lesson with chapter and course info // Get lesson with chapter and course info
const lesson = await prisma.lesson.findUnique({ const lesson = await prisma.lesson.findUnique({
@ -1185,7 +1185,7 @@ export class CoursesStudentService {
const enrollment = await prisma.enrollment.findUnique({ const enrollment = await prisma.enrollment.findUnique({
where: { where: {
unique_enrollment: { unique_enrollment: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
}, },
}, },
@ -1196,7 +1196,7 @@ export class CoursesStudentService {
} }
// Mark lesson as complete and update enrollment progress // Mark lesson as complete and update enrollment progress
const { lessonProgress, enrollmentProgress } = await this.markLessonComplete(decoded.id, lesson_id, course_id); const { lessonProgress, enrollmentProgress } = await this.markLessonComplete(userId, lesson_id, course_id);
const { progress_percentage: course_progress_percentage, is_course_completed } = enrollmentProgress; const { progress_percentage: course_progress_percentage, is_course_completed } = enrollmentProgress;
// Find next lesson // Find next lesson
@ -1225,7 +1225,7 @@ export class CoursesStudentService {
// Check if certificate already exists // Check if certificate already exists
const existingCertificate = await prisma.certificate.findFirst({ const existingCertificate = await prisma.certificate.findFirst({
where: { where: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
}, },
}); });
@ -1233,10 +1233,10 @@ export class CoursesStudentService {
if (!existingCertificate) { if (!existingCertificate) {
await prisma.certificate.create({ await prisma.certificate.create({
data: { data: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
enrollment_id: enrollment.id, enrollment_id: enrollment.id,
file_path: `certificates/${course_id}/${decoded.id}/${Date.now()}.pdf`, file_path: `certificates/${course_id}/${userId}/${Date.now()}.pdf`,
issued_at: new Date(), issued_at: new Date(),
}, },
}); });
@ -1261,9 +1261,9 @@ export class CoursesStudentService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error completing lesson: ${error}`); logger.error(`Error completing lesson: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null; // userId from middleware
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'LessonProgress', entityType: 'LessonProgress',
entityId: input.lesson_id, entityId: input.lesson_id,
@ -1283,14 +1283,14 @@ export class CoursesStudentService {
*/ */
async submitQuiz(input: SubmitQuizInput): Promise<SubmitQuizResponse> { async submitQuiz(input: SubmitQuizInput): Promise<SubmitQuizResponse> {
try { try {
const { token, course_id, lesson_id, answers } = input; const { course_id, lesson_id, answers } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; const userId = input.userId;
// Check enrollment // Check enrollment
const enrollment = await prisma.enrollment.findUnique({ const enrollment = await prisma.enrollment.findUnique({
where: { where: {
unique_enrollment: { unique_enrollment: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
}, },
}, },
@ -1331,7 +1331,7 @@ export class CoursesStudentService {
// Get previous attempt count // Get previous attempt count
const previousAttempts = await prisma.quizAttempt.count({ const previousAttempts = await prisma.quizAttempt.count({
where: { where: {
user_id: decoded.id, user_id: userId,
quiz_id: quiz.id, quiz_id: quiz.id,
}, },
}); });
@ -1384,7 +1384,7 @@ export class CoursesStudentService {
const now = new Date(); const now = new Date();
const quizAttempt = await prisma.quizAttempt.create({ const quizAttempt = await prisma.quizAttempt.create({
data: { data: {
user_id: decoded.id, user_id: userId,
quiz_id: quiz.id, quiz_id: quiz.id,
score: earnedScore, score: earnedScore,
total_questions: quiz.questions.length, total_questions: quiz.questions.length,
@ -1400,7 +1400,7 @@ export class CoursesStudentService {
// If passed, mark lesson as complete and update enrollment progress // If passed, mark lesson as complete and update enrollment progress
let enrollmentProgress: { progress_percentage: number; is_course_completed: boolean } | undefined; let enrollmentProgress: { progress_percentage: number; is_course_completed: boolean } | undefined;
if (isPassed) { if (isPassed) {
const result = await this.markLessonComplete(decoded.id, lesson_id, course_id); const result = await this.markLessonComplete(userId, lesson_id, course_id);
enrollmentProgress = result.enrollmentProgress; enrollmentProgress = result.enrollmentProgress;
} }
@ -1429,9 +1429,9 @@ export class CoursesStudentService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error submitting quiz: ${error}`); logger.error(`Error submitting quiz: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null; // userId from middleware
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'QuizAttempt', entityType: 'QuizAttempt',
entityId: 0, entityId: 0,
@ -1452,14 +1452,14 @@ export class CoursesStudentService {
*/ */
async getQuizAttempts(input: GetQuizAttemptsInput): Promise<GetQuizAttemptsResponse> { async getQuizAttempts(input: GetQuizAttemptsInput): Promise<GetQuizAttemptsResponse> {
try { try {
const { token, course_id, lesson_id } = input; const { course_id, lesson_id } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; const userId = input.userId;
// Check enrollment // Check enrollment
const enrollment = await prisma.enrollment.findUnique({ const enrollment = await prisma.enrollment.findUnique({
where: { where: {
unique_enrollment: { unique_enrollment: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
}, },
}, },
@ -1494,7 +1494,7 @@ export class CoursesStudentService {
// Get all quiz attempts for this user // Get all quiz attempts for this user
const attempts = await prisma.quizAttempt.findMany({ const attempts = await prisma.quizAttempt.findMany({
where: { where: {
user_id: decoded.id, user_id: userId,
quiz_id: lesson.quiz.id, quiz_id: lesson.quiz.id,
}, },
orderBy: { attempt_number: 'desc' }, orderBy: { attempt_number: 'desc' },
@ -1539,10 +1539,9 @@ export class CoursesStudentService {
}; };
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
const decoded = jwt.decode(input.token) as { id: number } | null; // userId from middleware
if (decoded?.id) {
await auditService.logSync({ await auditService.logSync({
userId: decoded.id, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'QuizAttempt', entityType: 'QuizAttempt',
entityId: 0, entityId: 0,
@ -1553,7 +1552,6 @@ export class CoursesStudentService {
error: error instanceof Error ? error.message : String(error) error: error instanceof Error ? error.message : String(error)
} }
}); });
}
throw error; throw error;
} }
} }

View file

@ -1,8 +1,6 @@
import { prisma } from '../config/database'; import { prisma } from '../config/database';
import { config } from '../config';
import { logger } from '../config/logger'; import { logger } from '../config/logger';
import { NotFoundError, ValidationError } from '../middleware/errorHandler'; import { NotFoundError, ValidationError } from '../middleware/errorHandler';
import jwt from 'jsonwebtoken';
import { getPresignedUrl } from '../config/minio'; import { getPresignedUrl } from '../config/minio';
import { import {
ListApprovedCoursesResponse, ListApprovedCoursesResponse,
@ -20,7 +18,7 @@ export class RecommendedCoursesService {
* List all approved courses (for admin to manage recommendations) * List all approved courses (for admin to manage recommendations)
*/ */
static async listApprovedCourses( static async listApprovedCourses(
token: string, userId: number,
filters?: { search?: string; categoryId?: number } filters?: { search?: string; categoryId?: number }
): Promise<ListApprovedCoursesResponse> { ): Promise<ListApprovedCoursesResponse> {
try { try {
@ -108,10 +106,8 @@ export class RecommendedCoursesService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to list approved courses', { error }); logger.error('Failed to list approved courses', { error });
const decoded = jwt.decode(token) as { id: number } | null;
if (decoded?.id) {
await auditService.logSync({ await auditService.logSync({
userId: decoded.id, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'RecommendedCourses', entityType: 'RecommendedCourses',
entityId: 0, entityId: 0,
@ -120,7 +116,6 @@ export class RecommendedCoursesService {
error: error instanceof Error ? error.message : String(error) error: error instanceof Error ? error.message : String(error)
} }
}); });
}
throw error; throw error;
} }
} }
@ -128,7 +123,7 @@ export class RecommendedCoursesService {
/** /**
* Get course by ID (for admin to view details) * Get course by ID (for admin to view details)
*/ */
static async getCourseById(token: string, courseId: number): Promise<GetCourseByIdResponse> { static async getCourseById(userId: number, courseId: number): Promise<GetCourseByIdResponse> {
try { try {
const course = await prisma.course.findUnique({ const course = await prisma.course.findUnique({
where: { id: courseId }, where: { id: courseId },
@ -213,10 +208,8 @@ export class RecommendedCoursesService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to get course by ID', { error }); logger.error('Failed to get course by ID', { error });
const decoded = jwt.decode(token) as { id: number } | null;
if (decoded?.id) {
await auditService.logSync({ await auditService.logSync({
userId: decoded.id, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'RecommendedCourses', entityType: 'RecommendedCourses',
entityId: 0, entityId: 0,
@ -225,7 +218,6 @@ export class RecommendedCoursesService {
error: error instanceof Error ? error.message : String(error) error: error instanceof Error ? error.message : String(error)
} }
}); });
}
throw error; throw error;
} }
} }
@ -234,12 +226,11 @@ export class RecommendedCoursesService {
* Toggle course recommendation status * Toggle course recommendation status
*/ */
static async toggleRecommended( static async toggleRecommended(
token: string, userId: number,
courseId: number, courseId: number,
isRecommended: boolean isRecommended: boolean
): Promise<ToggleRecommendedResponse> { ): Promise<ToggleRecommendedResponse> {
try { try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
const course = await prisma.course.findUnique({ where: { id: courseId } }); const course = await prisma.course.findUnique({ where: { id: courseId } });
if (!course) { if (!course) {
@ -257,7 +248,7 @@ export class RecommendedCoursesService {
// Audit log // Audit log
await auditService.logSync({ await auditService.logSync({
userId: decoded.id, userId,
action: AuditAction.UPDATE, action: AuditAction.UPDATE,
entityType: 'Course', entityType: 'Course',
entityId: courseId, entityId: courseId,
@ -276,9 +267,8 @@ export class RecommendedCoursesService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to toggle recommended status', { error }); logger.error('Failed to toggle recommended status', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'RecommendedCourses', entityType: 'RecommendedCourses',
entityId: courseId, entityId: courseId,

View file

@ -1,8 +1,6 @@
import { prisma } from '../config/database'; import { prisma } from '../config/database';
import { config } from '../config';
import { logger } from '../config/logger'; import { logger } from '../config/logger';
import { UnauthorizedError, ForbiddenError, NotFoundError } from '../middleware/errorHandler'; import { ForbiddenError, NotFoundError } from '../middleware/errorHandler';
import jwt from 'jsonwebtoken';
import { import {
ListAnnouncementResponse, ListAnnouncementResponse,
CreateAnnouncementInput, CreateAnnouncementInput,
@ -31,27 +29,26 @@ export class AnnouncementsService {
*/ */
async listAnnouncement(input: ListAnnouncementInput): Promise<ListAnnouncementResponse> { async listAnnouncement(input: ListAnnouncementInput): Promise<ListAnnouncementResponse> {
try { try {
const { token, course_id, page = 1, limit = 10 } = input; const { userId, course_id, page = 1, limit = 10 } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
// Check user access - instructor, admin, or enrolled student // Check user access - instructor, admin, or enrolled student
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: decoded.id }, where: { id: userId },
include: { role: true }, include: { role: true },
}); });
if (!user) throw new UnauthorizedError('Invalid token'); if (!user) throw new ForbiddenError('User not found');
// Admin can access all courses // Admin can access all courses
const isAdmin = user.role.code === 'ADMIN'; const isAdmin = user.role.code === 'ADMIN';
// Check if instructor of this course // Check if instructor of this course
const isInstructor = await prisma.courseInstructor.findFirst({ const isInstructor = await prisma.courseInstructor.findFirst({
where: { course_id, user_id: decoded.id }, where: { course_id, user_id: userId },
}); });
// Check if enrolled student // Check if enrolled student
const isEnrolled = await prisma.enrollment.findFirst({ const isEnrolled = await prisma.enrollment.findFirst({
where: { course_id, user_id: decoded.id }, where: { course_id, user_id: userId },
}); });
if (!isAdmin && !isInstructor && !isEnrolled) throw new ForbiddenError('You do not have access to this course announcements'); if (!isAdmin && !isInstructor && !isEnrolled) throw new ForbiddenError('You do not have access to this course announcements');
@ -130,9 +127,8 @@ export class AnnouncementsService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error listing announcements: ${error}`); logger.error(`Error listing announcements: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Announcement', entityType: 'Announcement',
entityId: 0, entityId: 0,
@ -150,11 +146,10 @@ export class AnnouncementsService {
*/ */
async createAnnouncement(input: CreateAnnouncementInput): Promise<CreateAnnouncementResponse> { async createAnnouncement(input: CreateAnnouncementInput): Promise<CreateAnnouncementResponse> {
try { try {
const { token, course_id, title, content, status, is_pinned, published_at, files } = input; const { userId, course_id, title, content, status, is_pinned, published_at, files } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor access // Validate instructor access
await CoursesInstructorService.validateCourseInstructor(token, course_id); await CoursesInstructorService.validateCourseInstructor(userId, course_id);
// Determine published_at: use provided value or default to now if status is PUBLISHED // Determine published_at: use provided value or default to now if status is PUBLISHED
let finalPublishedAt: Date | null = null; let finalPublishedAt: Date | null = null;
@ -171,7 +166,7 @@ export class AnnouncementsService {
status: status as any, status: status as any,
is_pinned, is_pinned,
published_at: finalPublishedAt, published_at: finalPublishedAt,
created_by: decoded.id, created_by: userId,
}, },
}); });
@ -236,9 +231,8 @@ export class AnnouncementsService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error creating announcement: ${error}`); logger.error(`Error creating announcement: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Announcement', entityType: 'Announcement',
entityId: 0, entityId: 0,
@ -256,11 +250,10 @@ export class AnnouncementsService {
*/ */
async updateAnnouncement(input: UpdateAnnouncementInput): Promise<UpdateAnnouncementResponse> { async updateAnnouncement(input: UpdateAnnouncementInput): Promise<UpdateAnnouncementResponse> {
try { try {
const { token, course_id, announcement_id, title, content, status, is_pinned, published_at } = input; const { userId, course_id, announcement_id, title, content, status, is_pinned, published_at } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor access // Validate instructor access
await CoursesInstructorService.validateCourseInstructor(token, course_id); await CoursesInstructorService.validateCourseInstructor(userId, course_id);
// Check announcement exists and belongs to course // Check announcement exists and belongs to course
const existing = await prisma.announcement.findFirst({ const existing = await prisma.announcement.findFirst({
@ -289,7 +282,7 @@ export class AnnouncementsService {
status: status as any, status: status as any,
is_pinned, is_pinned,
published_at: finalPublishedAt, published_at: finalPublishedAt,
updated_by: decoded.id, updated_by: userId,
}, },
include: { include: {
attachments: true, attachments: true,
@ -320,9 +313,8 @@ export class AnnouncementsService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error updating announcement: ${error}`); logger.error(`Error updating announcement: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Announcement', entityType: 'Announcement',
entityId: 0, entityId: 0,
@ -340,11 +332,10 @@ export class AnnouncementsService {
*/ */
async deleteAnnouncement(input: DeleteAnnouncementInput): Promise<DeleteAnnouncementResponse> { async deleteAnnouncement(input: DeleteAnnouncementInput): Promise<DeleteAnnouncementResponse> {
try { try {
const { token, course_id, announcement_id } = input; const { userId, course_id, announcement_id } = input;
jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor access // Validate instructor access
await CoursesInstructorService.validateCourseInstructor(token, course_id); await CoursesInstructorService.validateCourseInstructor(userId, course_id);
// Check announcement exists and belongs to course // Check announcement exists and belongs to course
const existing = await prisma.announcement.findFirst({ const existing = await prisma.announcement.findFirst({
@ -376,9 +367,8 @@ export class AnnouncementsService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error deleting announcement: ${error}`); logger.error(`Error deleting announcement: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Announcement', entityType: 'Announcement',
entityId: 0, entityId: 0,
@ -396,11 +386,10 @@ export class AnnouncementsService {
*/ */
async uploadAttachment(input: UploadAnnouncementAttachmentInput): Promise<UploadAnnouncementAttachmentResponse> { async uploadAttachment(input: UploadAnnouncementAttachmentInput): Promise<UploadAnnouncementAttachmentResponse> {
try { try {
const { token, course_id, announcement_id, file } = input; const { userId, course_id, announcement_id, file } = input;
jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor access // Validate instructor access
await CoursesInstructorService.validateCourseInstructor(token, course_id); await CoursesInstructorService.validateCourseInstructor(userId, course_id);
// Check announcement exists and belongs to course // Check announcement exists and belongs to course
const existing = await prisma.announcement.findFirst({ const existing = await prisma.announcement.findFirst({
@ -451,9 +440,8 @@ export class AnnouncementsService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error uploading attachment: ${error}`); logger.error(`Error uploading attachment: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Announcement', entityType: 'Announcement',
entityId: 0, entityId: 0,
@ -471,11 +459,10 @@ export class AnnouncementsService {
*/ */
async deleteAttachment(input: DeleteAnnouncementAttachmentInput): Promise<DeleteAnnouncementAttachmentResponse> { async deleteAttachment(input: DeleteAnnouncementAttachmentInput): Promise<DeleteAnnouncementAttachmentResponse> {
try { try {
const { token, course_id, announcement_id, attachment_id } = input; const { userId, course_id, announcement_id, attachment_id } = input;
jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor access // Validate instructor access
await CoursesInstructorService.validateCourseInstructor(token, course_id); await CoursesInstructorService.validateCourseInstructor(userId, course_id);
// Check attachment exists and belongs to announcement in this course // Check attachment exists and belongs to announcement in this course
const attachment = await prisma.announcementAttachment.findFirst({ const attachment = await prisma.announcementAttachment.findFirst({
@ -508,9 +495,8 @@ export class AnnouncementsService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error deleting attachment: ${error}`); logger.error(`Error deleting attachment: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Announcement', entityType: 'Announcement',
entityId: 0, entityId: 0,

View file

@ -74,7 +74,6 @@ export class AuthService {
data: { data: {
token, token,
refreshToken, refreshToken,
user: await this.formatUserResponse(user)
} }
}; };
} }

View file

@ -1,10 +1,7 @@
import { prisma } from '../config/database'; import { prisma } from '../config/database';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { config } from '../config';
import { logger } from '../config/logger'; import { logger } from '../config/logger';
import jwt from 'jsonwebtoken';
import { createCategory, createCategoryResponse, deleteCategoryResponse, updateCategory, updateCategoryResponse, ListCategoriesResponse, Category } from '../types/categories.type'; import { createCategory, createCategoryResponse, deleteCategoryResponse, updateCategory, updateCategoryResponse, ListCategoriesResponse, Category } from '../types/categories.type';
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
import { auditService } from './audit.service'; import { auditService } from './audit.service';
import { AuditAction } from '@prisma/client'; import { AuditAction } from '@prisma/client';
@ -26,14 +23,13 @@ export class CategoryService {
} }
} }
async createCategory(token: string, category: createCategory): Promise<createCategoryResponse> { async createCategory(userId: number, category: createCategory): Promise<createCategoryResponse> {
try { try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string };
const newCategory = await prisma.category.create({ const newCategory = await prisma.category.create({
data: category data: category
}); });
auditService.log({ auditService.log({
userId: decoded.id, userId,
action: AuditAction.CREATE, action: AuditAction.CREATE,
entityType: 'Category', entityType: 'Category',
entityId: newCategory.id, entityId: newCategory.id,
@ -47,13 +43,13 @@ export class CategoryService {
name: newCategory.name as { th: string; en: string }, name: newCategory.name as { th: string; en: string },
slug: newCategory.slug, slug: newCategory.slug,
description: newCategory.description as { th: string; en: string }, description: newCategory.description as { th: string; en: string },
created_by: decoded.id, created_by: userId,
} }
}; };
} catch (error) { } catch (error) {
logger.error('Failed to create category', { error }); logger.error('Failed to create category', { error });
await auditService.logSync({ await auditService.logSync({
userId: 0, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Category', entityType: 'Category',
entityId: 0, entityId: 0,
@ -66,15 +62,14 @@ export class CategoryService {
} }
} }
async updateCategory(token: string, id: number, category: updateCategory): Promise<updateCategoryResponse> { async updateCategory(userId: number, id: number, category: updateCategory): Promise<updateCategoryResponse> {
try { try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string };
const updatedCategory = await prisma.category.update({ const updatedCategory = await prisma.category.update({
where: { id }, where: { id },
data: category data: category
}); });
auditService.log({ auditService.log({
userId: decoded.id, userId,
action: AuditAction.UPDATE, action: AuditAction.UPDATE,
entityType: 'Category', entityType: 'Category',
entityId: id, entityId: id,
@ -88,13 +83,13 @@ export class CategoryService {
name: updatedCategory.name as { th: string; en: string }, name: updatedCategory.name as { th: string; en: string },
slug: updatedCategory.slug, slug: updatedCategory.slug,
description: updatedCategory.description as { th: string; en: string }, description: updatedCategory.description as { th: string; en: string },
updated_by: decoded.id, updated_by: userId,
} }
}; };
} catch (error) { } catch (error) {
logger.error('Failed to update category', { error }); logger.error('Failed to update category', { error });
await auditService.logSync({ await auditService.logSync({
userId: 0, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Category', entityType: 'Category',
entityId: 0, entityId: 0,
@ -107,14 +102,13 @@ export class CategoryService {
} }
} }
async deleteCategory(token: string, id: number): Promise<deleteCategoryResponse> { async deleteCategory(userId: number, id: number): Promise<deleteCategoryResponse> {
try { try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string };
const deletedCategory = await prisma.category.delete({ const deletedCategory = await prisma.category.delete({
where: { id } where: { id }
}); });
auditService.log({ auditService.log({
userId: decoded.id, userId,
action: AuditAction.DELETE, action: AuditAction.DELETE,
entityType: 'Category', entityType: 'Category',
entityId: id, entityId: id,
@ -127,7 +121,7 @@ export class CategoryService {
} catch (error) { } catch (error) {
logger.error('Failed to delete category', { error }); logger.error('Failed to delete category', { error });
await auditService.logSync({ await auditService.logSync({
userId: 0, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Category', entityType: 'Category',
entityId: 0, entityId: 0,

View file

@ -1,8 +1,6 @@
import { prisma } from '../config/database'; import { prisma } from '../config/database';
import { config } from '../config';
import { logger } from '../config/logger'; import { logger } from '../config/logger';
import { NotFoundError, ForbiddenError, ValidationError } from '../middleware/errorHandler'; import { NotFoundError, ForbiddenError, ValidationError } from '../middleware/errorHandler';
import jwt from 'jsonwebtoken';
import { PDFDocument, rgb } from 'pdf-lib'; import { PDFDocument, rgb } from 'pdf-lib';
import fontkit from '@pdf-lib/fontkit'; import fontkit from '@pdf-lib/fontkit';
import * as fs from 'fs'; import * as fs from 'fs';
@ -29,14 +27,13 @@ export class CertificateService {
*/ */
async generateCertificate(input: GenerateCertificateInput): Promise<GenerateCertificateResponse> { async generateCertificate(input: GenerateCertificateInput): Promise<GenerateCertificateResponse> {
try { try {
const { token, course_id } = input; const { userId, course_id } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Check enrollment and completion // Check enrollment and completion
const enrollment = await prisma.enrollment.findUnique({ const enrollment = await prisma.enrollment.findUnique({
where: { where: {
unique_enrollment: { unique_enrollment: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
}, },
}, },
@ -65,7 +62,7 @@ export class CertificateService {
// Check if certificate already exists // Check if certificate already exists
const existingCertificate = await prisma.certificate.findFirst({ const existingCertificate = await prisma.certificate.findFirst({
where: { where: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
}, },
}); });
@ -103,13 +100,13 @@ export class CertificateService {
// Upload to MinIO // Upload to MinIO
const timestamp = Date.now(); const timestamp = Date.now();
const filePath = `certificates/${course_id}/${decoded.id}/${timestamp}.pdf`; const filePath = `certificates/${course_id}/${userId}/${timestamp}.pdf`;
await uploadFile(filePath, Buffer.from(pdfBytes), 'application/pdf'); await uploadFile(filePath, Buffer.from(pdfBytes), 'application/pdf');
// Save to database // Save to database
const certificate = await prisma.certificate.create({ const certificate = await prisma.certificate.create({
data: { data: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
enrollment_id: enrollment.id, enrollment_id: enrollment.id,
file_path: filePath, file_path: filePath,
@ -118,7 +115,7 @@ export class CertificateService {
}); });
auditService.log({ auditService.log({
userId: decoded.id, userId,
action: AuditAction.CREATE, action: AuditAction.CREATE,
entityType: 'Certificate', entityType: 'Certificate',
entityId: certificate.id, entityId: certificate.id,
@ -139,9 +136,8 @@ export class CertificateService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to generate certificate', { error }); logger.error('Failed to generate certificate', { error });
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Certificate', entityType: 'Certificate',
entityId: 0, entityId: 0,
@ -160,12 +156,11 @@ export class CertificateService {
*/ */
async getCertificate(input: GetCertificateInput): Promise<GetCertificateResponse> { async getCertificate(input: GetCertificateInput): Promise<GetCertificateResponse> {
try { try {
const { token, course_id } = input; const { userId, course_id } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
const certificate = await prisma.certificate.findFirst({ const certificate = await prisma.certificate.findFirst({
where: { where: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
}, },
include: { include: {
@ -202,9 +197,8 @@ export class CertificateService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to get certificate', { error }); logger.error('Failed to get certificate', { error });
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Certificate', entityType: 'Certificate',
entityId: 0, entityId: 0,
@ -223,12 +217,11 @@ export class CertificateService {
*/ */
async listMyCertificates(input: ListMyCertificatesInput): Promise<ListMyCertificatesResponse> { async listMyCertificates(input: ListMyCertificatesInput): Promise<ListMyCertificatesResponse> {
try { try {
const { token } = input; const { userId } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
const certificates = await prisma.certificate.findMany({ const certificates = await prisma.certificate.findMany({
where: { where: {
user_id: decoded.id, user_id: userId,
}, },
include: { include: {
enrollment: { enrollment: {
@ -267,9 +260,8 @@ export class CertificateService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to list certificates', { error }); logger.error('Failed to list certificates', { error });
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Certificate', entityType: 'Certificate',
entityId: 0, entityId: 0,

View file

@ -24,15 +24,10 @@ import { auditService } from './audit.service';
import { AuditAction } from '@prisma/client'; import { AuditAction } from '@prisma/client';
export class UserService { export class UserService {
async getUserProfile(token: string): Promise<UserResponse> { async getUserProfile(userId: number): Promise<UserResponse> {
try { try {
// Decode JWT token to get user ID
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string };
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { where: { id: userId },
id: decoded.id
},
include: { include: {
profile: true, profile: true,
role: true role: true
@ -68,14 +63,6 @@ export class UserService {
} : undefined } : undefined
}; };
} catch (error) { } catch (error) {
if (error instanceof jwt.JsonWebTokenError) {
logger.error('Invalid JWT token:', error);
throw new UnauthorizedError('Invalid token');
}
if (error instanceof jwt.TokenExpiredError) {
logger.error('JWT token expired:', error);
throw new UnauthorizedError('Token expired');
}
logger.error('Error fetching user profile:', error); logger.error('Error fetching user profile:', error);
throw error; throw error;
} }
@ -84,12 +71,9 @@ export class UserService {
/** /**
* Change user password * Change user password
*/ */
async changePassword(token: string, oldPassword: string, newPassword: string): Promise<ChangePasswordResponse> { async changePassword(userId: number, oldPassword: string, newPassword: string): Promise<ChangePasswordResponse> {
try { try {
// Decode JWT token to get user ID const user = await prisma.user.findUnique({ where: { id: userId } });
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string };
const user = await prisma.user.findUnique({ where: { id: decoded.id } });
if (!user) throw new UnauthorizedError('User not found'); if (!user) throw new UnauthorizedError('User not found');
// Check if account is deactivated // Check if account is deactivated
@ -127,21 +111,12 @@ export class UserService {
message: 'Password changed successfully' message: 'Password changed successfully'
}; };
} catch (error) { } catch (error) {
if (error instanceof jwt.JsonWebTokenError) {
logger.error('Invalid JWT token:', error);
throw new UnauthorizedError('Invalid token');
}
if (error instanceof jwt.TokenExpiredError) {
logger.error('JWT token expired:', error);
throw new UnauthorizedError('Token expired');
}
logger.error('Failed to change password', { error }); logger.error('Failed to change password', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'User', entityType: 'User',
entityId: decoded?.id || 0, entityId: userId,
metadata: { metadata: {
operation: 'change_password', operation: 'change_password',
error: error instanceof Error ? error.message : String(error) error: error instanceof Error ? error.message : String(error)
@ -154,12 +129,9 @@ export class UserService {
/** /**
* Update user profile * Update user profile
*/ */
async updateProfile(token: string, profile: ProfileUpdate): Promise<ProfileUpdateResponse> { async updateProfile(userId: number, profile: ProfileUpdate): Promise<ProfileUpdateResponse> {
try { try {
// Decode JWT token to get user ID const user = await prisma.user.findUnique({ where: { id: userId } });
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string };
const user = await prisma.user.findUnique({ where: { id: decoded.id } });
if (!user) throw new UnauthorizedError('User not found'); if (!user) throw new UnauthorizedError('User not found');
// Check if account is deactivated // Check if account is deactivated
@ -189,21 +161,12 @@ export class UserService {
} }
}; };
} catch (error) { } catch (error) {
if (error instanceof jwt.JsonWebTokenError) {
logger.error('Invalid JWT token:', error);
throw new UnauthorizedError('Invalid token');
}
if (error instanceof jwt.TokenExpiredError) {
logger.error('JWT token expired:', error);
throw new UnauthorizedError('Token expired');
}
logger.error('Failed to update profile', { error }); logger.error('Failed to update profile', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId,
action: AuditAction.UPDATE, action: AuditAction.UPDATE,
entityType: 'UserProfile', entityType: 'UserProfile',
entityId: decoded?.id || 0, entityId: userId,
metadata: { metadata: {
operation: 'update_profile', operation: 'update_profile',
error: error instanceof Error ? error.message : String(error) error: error instanceof Error ? error.message : String(error)
@ -213,9 +176,8 @@ export class UserService {
} }
} }
async getRoles(token: string): Promise<rolesResponse> { async getRoles(): Promise<rolesResponse> {
try { try {
jwt.verify(token, config.jwt.secret);
const roles = await prisma.role.findMany({ const roles = await prisma.role.findMany({
select: { select: {
id: true, id: true,
@ -224,14 +186,6 @@ export class UserService {
}); });
return { roles }; return { roles };
} catch (error) { } catch (error) {
if (error instanceof jwt.TokenExpiredError) {
logger.error('JWT token expired:', error);
throw new UnauthorizedError('Token expired');
}
if (error instanceof jwt.JsonWebTokenError) {
logger.error('Invalid JWT token:', error);
throw new UnauthorizedError('Invalid token');
}
logger.error('Failed to get roles', { error }); logger.error('Failed to get roles', { error });
throw error; throw error;
} }
@ -240,13 +194,11 @@ export class UserService {
/** /**
* Upload avatar picture to MinIO * Upload avatar picture to MinIO
*/ */
async uploadAvatarPicture(token: string, file: Express.Multer.File): Promise<updateAvatarResponse> { async uploadAvatarPicture(userId: number, file: Express.Multer.File): Promise<updateAvatarResponse> {
try { try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Check if user exists // Check if user exists
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: decoded.id }, where: { id: userId },
include: { profile: true } include: { profile: true }
}); });
@ -265,7 +217,7 @@ export class UserService {
const fileName = file.originalname || 'avatar'; const fileName = file.originalname || 'avatar';
const extension = fileName.split('.').pop() || 'jpg'; const extension = fileName.split('.').pop() || 'jpg';
const safeFilename = `${timestamp}-${uniqueId}.${extension}`; const safeFilename = `${timestamp}-${uniqueId}.${extension}`;
const filePath = `avatars/${decoded.id}/${safeFilename}`; const filePath = `avatars/${userId}/${safeFilename}`;
// Delete old avatar if exists // Delete old avatar if exists
if (user.profile?.avatar_url) { if (user.profile?.avatar_url) {
@ -285,13 +237,13 @@ export class UserService {
// Update or create profile - store only file path // Update or create profile - store only file path
if (user.profile) { if (user.profile) {
await prisma.userProfile.update({ await prisma.userProfile.update({
where: { user_id: decoded.id }, where: { user_id: userId },
data: { avatar_url: filePath } data: { avatar_url: filePath }
}); });
} else { } else {
await prisma.userProfile.create({ await prisma.userProfile.create({
data: { data: {
user_id: decoded.id, user_id: userId,
avatar_url: filePath, avatar_url: filePath,
first_name: '', first_name: '',
last_name: '' last_name: ''
@ -301,10 +253,10 @@ export class UserService {
// Audit log - UPLOAD_AVATAR // Audit log - UPLOAD_AVATAR
await auditService.logSync({ await auditService.logSync({
userId: decoded.id, userId,
action: AuditAction.UPLOAD_FILE, action: AuditAction.UPLOAD_FILE,
entityType: 'User', entityType: 'User',
entityId: decoded.id, entityId: userId,
metadata: { metadata: {
operation: 'upload_avatar', operation: 'upload_avatar',
filePath filePath
@ -318,26 +270,17 @@ export class UserService {
code: 200, code: 200,
message: 'Avatar uploaded successfully', message: 'Avatar uploaded successfully',
data: { data: {
id: decoded.id, id: userId,
avatar_url: presignedUrl avatar_url: presignedUrl
} }
}; };
} catch (error) { } catch (error) {
if (error instanceof jwt.JsonWebTokenError) {
logger.error('Invalid JWT token:', error);
throw new UnauthorizedError('Invalid token');
}
if (error instanceof jwt.TokenExpiredError) {
logger.error('JWT token expired:', error);
throw new UnauthorizedError('Token expired');
}
logger.error('Failed to upload avatar', { error }); logger.error('Failed to upload avatar', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId,
action: AuditAction.UPLOAD_FILE, action: AuditAction.UPLOAD_FILE,
entityType: 'UserProfile', entityType: 'UserProfile',
entityId: decoded?.id || 0, entityId: userId,
metadata: { metadata: {
operation: 'upload_avatar', operation: 'upload_avatar',
error: error instanceof Error ? error.message : String(error) error: error instanceof Error ? error.message : String(error)
@ -390,12 +333,10 @@ export class UserService {
/** /**
* Send verification email to user * Send verification email to user
*/ */
async sendVerifyEmail(token: string): Promise<SendVerifyEmailResponse> { async sendVerifyEmail(userId: number): Promise<SendVerifyEmailResponse> {
try { try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; email: string; roleCode: string };
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: decoded.id }, where: { id: userId },
include: { role: true } include: { role: true }
}); });
@ -453,15 +394,12 @@ export class UserService {
message: 'Verification email sent successfully' message: 'Verification email sent successfully'
}; };
} catch (error) { } catch (error) {
if (error instanceof jwt.JsonWebTokenError) throw new UnauthorizedError('Invalid token');
if (error instanceof jwt.TokenExpiredError) throw new UnauthorizedError('Token expired');
logger.error('Failed to send verification email', { error }); logger.error('Failed to send verification email', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'UserProfile', entityType: 'UserProfile',
entityId: decoded?.id || 0, entityId: userId,
metadata: { metadata: {
operation: 'send_verification_email', operation: 'send_verification_email',
error: error instanceof Error ? error.message : String(error) error: error instanceof Error ? error.message : String(error)

View file

@ -98,18 +98,18 @@ export interface ChapterData {
// ============================================ // ============================================
export interface ChaptersRequest { export interface ChaptersRequest {
token: string; userId: number;
course_id: number; course_id: number;
} }
export interface GetChapterRequest { export interface GetChapterRequest {
token: string; userId: number;
course_id: number; course_id: number;
chapter_id: number; chapter_id: number;
} }
export interface CreateChapterInput { export interface CreateChapterInput {
token: string; userId: number;
course_id: number; course_id: number;
title: MultiLanguageText; title: MultiLanguageText;
description?: MultiLanguageText; description?: MultiLanguageText;
@ -118,13 +118,13 @@ export interface CreateChapterInput {
} }
export interface CreateChapterRequest { export interface CreateChapterRequest {
token: string; userId: number;
course_id: number; course_id: number;
data: CreateChapterInput; data: CreateChapterInput;
} }
export interface UpdateChapterInput { export interface UpdateChapterInput {
token: string; userId: number;
course_id: number; course_id: number;
chapter_id: number; chapter_id: number;
title?: MultiLanguageText; title?: MultiLanguageText;
@ -134,20 +134,20 @@ export interface UpdateChapterInput {
} }
export interface UpdateChapterRequest { export interface UpdateChapterRequest {
token: string; userId: number;
course_id: number; course_id: number;
chapter_id: number; chapter_id: number;
data: UpdateChapterInput; data: UpdateChapterInput;
} }
export interface DeleteChapterRequest { export interface DeleteChapterRequest {
token: string; userId: number;
course_id: number; course_id: number;
chapter_id: number; chapter_id: number;
} }
export interface ReorderChapterRequest { export interface ReorderChapterRequest {
token: string; userId: number;
course_id: number; course_id: number;
chapter_id: number; chapter_id: number;
sort_order: number; sort_order: number;
@ -199,7 +199,7 @@ export interface ReorderChapterResponse {
// ============================================ // ============================================
export interface GetLessonRequest { export interface GetLessonRequest {
token: string; userId: number;
course_id: number; course_id: number;
chapter_id: number; chapter_id: number;
lesson_id: number; lesson_id: number;
@ -216,7 +216,7 @@ export interface UploadedFileInfo {
} }
export interface CreateLessonInput { export interface CreateLessonInput {
token: string; userId: number;
course_id: number; course_id: number;
chapter_id: number; chapter_id: number;
title: MultiLanguageText; title: MultiLanguageText;
@ -293,7 +293,7 @@ export interface QuizChoiceData {
} }
export interface CreateLessonRequest { export interface CreateLessonRequest {
token: string; userId: number;
course_id: number; course_id: number;
chapter_id: number; chapter_id: number;
data: CreateLessonInput; data: CreateLessonInput;
@ -311,7 +311,7 @@ export interface UpdateLessonInput {
} }
export interface UpdateLessonRequest { export interface UpdateLessonRequest {
token: string; userId: number;
course_id: number; course_id: number;
chapter_id: number; chapter_id: number;
lesson_id: number; lesson_id: number;
@ -319,14 +319,14 @@ export interface UpdateLessonRequest {
} }
export interface DeleteLessonRequest { export interface DeleteLessonRequest {
token: string; userId: number;
course_id: number; course_id: number;
chapter_id: number; chapter_id: number;
lesson_id: number; lesson_id: number;
} }
export interface ReorderLessonsRequest { export interface ReorderLessonsRequest {
token: string; userId: number;
course_id: number; course_id: number;
chapter_id: number; chapter_id: number;
lesson_id: number; lesson_id: number;
@ -365,7 +365,7 @@ export interface UpdateLessonResponse {
* Input for uploading video to a lesson * Input for uploading video to a lesson
*/ */
export interface UploadVideoInput { export interface UploadVideoInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
video: UploadedFileInfo; video: UploadedFileInfo;
@ -375,7 +375,7 @@ export interface UploadVideoInput {
* Input for updating (replacing) video in a lesson * Input for updating (replacing) video in a lesson
*/ */
export interface UpdateVideoInput { export interface UpdateVideoInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
video: UploadedFileInfo; video: UploadedFileInfo;
@ -385,7 +385,7 @@ export interface UpdateVideoInput {
* Input for setting YouTube video to a lesson * Input for setting YouTube video to a lesson
*/ */
export interface SetYouTubeVideoInput { export interface SetYouTubeVideoInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
youtube_video_id: string; youtube_video_id: string;
@ -411,7 +411,7 @@ export interface YouTubeVideoResponse {
* Input for uploading a single attachment to a lesson * Input for uploading a single attachment to a lesson
*/ */
export interface UploadAttachmentInput { export interface UploadAttachmentInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
attachment: UploadedFileInfo; attachment: UploadedFileInfo;
@ -421,7 +421,7 @@ export interface UploadAttachmentInput {
* Input for deleting an attachment from a lesson * Input for deleting an attachment from a lesson
*/ */
export interface DeleteAttachmentInput { export interface DeleteAttachmentInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
attachment_id: number; attachment_id: number;
@ -490,7 +490,7 @@ export interface LessonWithDetailsResponse {
* Input for adding quiz to an existing QUIZ lesson * Input for adding quiz to an existing QUIZ lesson
*/ */
export interface AddQuizToLessonInput { export interface AddQuizToLessonInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
quiz_data: { quiz_data: {
@ -509,7 +509,7 @@ export interface AddQuizToLessonInput {
* Input for adding a single question to a quiz lesson * Input for adding a single question to a quiz lesson
*/ */
export interface AddQuestionInput { export interface AddQuestionInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
question: MultiLanguageText; question: MultiLanguageText;
@ -532,7 +532,7 @@ export interface AddQuestionResponse {
* Input for updating a question * Input for updating a question
*/ */
export interface UpdateQuestionInput { export interface UpdateQuestionInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
question_id: number; question_id: number;
@ -556,14 +556,14 @@ export interface UpdateQuestionResponse {
* Input for deleting a question * Input for deleting a question
*/ */
export interface DeleteQuestionInput { export interface DeleteQuestionInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
question_id: number; question_id: number;
} }
export interface ReorderQuestionInput { export interface ReorderQuestionInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
question_id: number; question_id: number;
@ -588,7 +588,7 @@ export interface DeleteQuestionResponse {
* Input for updating quiz settings * Input for updating quiz settings
*/ */
export interface UpdateQuizInput { export interface UpdateQuizInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
title?: MultiLanguageText; title?: MultiLanguageText;

View file

@ -24,7 +24,7 @@ export interface createCourseResponse {
} }
export interface ListMyCoursesInput { export interface ListMyCoursesInput {
token: string; userId: number;
status?: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'ARCHIVED'; status?: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'ARCHIVED';
} }
@ -42,7 +42,7 @@ export interface GetMyCourseResponse {
} }
export interface getmyCourse { export interface getmyCourse {
token: string; userId: number;
course_id: number; course_id: number;
} }
@ -94,13 +94,13 @@ export interface listCourseinstructorResponse {
} }
export interface addinstructorCourse { export interface addinstructorCourse {
token: string; userId: number;
email_or_username: string; email_or_username: string;
course_id: number; course_id: number;
} }
export interface SearchInstructorInput { export interface SearchInstructorInput {
token: string; userId: number;
query: string; query: string;
course_id: number; course_id: number;
} }
@ -145,12 +145,12 @@ export interface listinstructorCourseResponse {
} }
export interface listinstructorCourse { export interface listinstructorCourse {
token: string; userId: number;
course_id: number; course_id: number;
} }
export interface removeinstructorCourse { export interface removeinstructorCourse {
token: string; userId: number;
user_id: number; user_id: number;
course_id: number; course_id: number;
} }
@ -161,7 +161,7 @@ export interface removeinstructorCourseResponse {
} }
export interface setprimaryCourseInstructor { export interface setprimaryCourseInstructor {
token: string; userId: number;
user_id: number; user_id: number;
course_id: number; course_id: number;
} }
@ -172,12 +172,12 @@ export interface setprimaryCourseInstructorResponse {
} }
export interface sendCourseForReview { export interface sendCourseForReview {
token: string; userId: number;
course_id: number; course_id: number;
} }
export interface setCourseDraft { export interface setCourseDraft {
token: string; userId: number;
course_id: number; course_id: number;
} }
@ -220,7 +220,7 @@ export interface GetCourseApprovalsResponse {
// ============================================ // ============================================
export interface GetEnrolledStudentsInput { export interface GetEnrolledStudentsInput {
token: string; userId: number;
course_id: number; course_id: number;
page?: number; page?: number;
limit?: number; limit?: number;
@ -254,7 +254,7 @@ export interface GetEnrolledStudentsResponse {
// ============================================ // ============================================
export interface GetQuizScoresInput { export interface GetQuizScoresInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
page?: number; page?: number;
@ -305,7 +305,7 @@ export interface GetQuizScoresResponse {
// ============================================ // ============================================
export interface GetQuizAttemptDetailInput { export interface GetQuizAttemptDetailInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
student_id: number; student_id: number;
@ -353,7 +353,7 @@ export interface GetQuizAttemptDetailResponse {
// ============================================ // ============================================
export interface GetEnrolledStudentDetailInput { export interface GetEnrolledStudentDetailInput {
token: string; userId: number;
course_id: number; course_id: number;
student_id: number; student_id: number;
} }
@ -435,7 +435,7 @@ export interface GetCourseApprovalHistoryResponse {
} }
export interface CloneCourseInput { export interface CloneCourseInput {
token: string; userId: number;
course_id: number; course_id: number;
title: MultiLanguageText; title: MultiLanguageText;
} }
@ -448,3 +448,14 @@ export interface CloneCourseResponse {
title: MultiLanguageText; title: MultiLanguageText;
}; };
} }
// ============================================
// Get All Students across all instructor courses
// ============================================
export interface GetAllMyStudentsResponse {
code: number;
message: string;
total_students: number;
total_completed: number;
}

View file

@ -9,7 +9,7 @@ export type MultiLangText = MultiLanguageText;
// ============================================ // ============================================
export interface EnrollCourseInput { export interface EnrollCourseInput {
token: string; userId: number;
course_id: number; course_id: number;
} }
@ -26,7 +26,7 @@ export interface EnrollCourseResponse {
} }
export interface ListEnrolledCoursesInput { export interface ListEnrolledCoursesInput {
token: string; userId: number;
page?: number; page?: number;
limit?: number; limit?: number;
status?: EnrollmentStatus; status?: EnrollmentStatus;
@ -64,7 +64,7 @@ export interface ListEnrolledCoursesResponse {
// ============================================ // ============================================
export interface GetCourseLearningInput { export interface GetCourseLearningInput {
token: string; userId: number;
course_id: number; course_id: number;
} }
@ -126,7 +126,7 @@ export interface GetCourseLearningResponse {
// ============================================ // ============================================
export interface GetLessonContentInput { export interface GetLessonContentInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
} }
@ -204,7 +204,7 @@ export interface GetLessonContentResponse {
// ============================================ // ============================================
export interface CheckLessonAccessInput { export interface CheckLessonAccessInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
} }
@ -236,7 +236,7 @@ export interface CheckLessonAccessResponse {
// ============================================ // ============================================
export interface SaveVideoProgressInput { export interface SaveVideoProgressInput {
token: string; userId: number;
lesson_id: number; lesson_id: number;
video_progress_seconds: number; video_progress_seconds: number;
video_duration_seconds?: number; video_duration_seconds?: number;
@ -258,7 +258,7 @@ export interface SaveVideoProgressResponse {
} }
export interface GetVideoProgressInput { export interface GetVideoProgressInput {
token: string; userId: number;
lesson_id: number; lesson_id: number;
} }
@ -281,7 +281,7 @@ export interface GetVideoProgressResponse {
// ============================================ // ============================================
export interface MarkLessonCompleteInput { export interface MarkLessonCompleteInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
} }
@ -314,7 +314,7 @@ export interface EnrollCourseBody {
} }
export interface CompleteLessonInput { export interface CompleteLessonInput {
token: string; userId: number;
lesson_id: number; lesson_id: number;
} }
@ -342,7 +342,7 @@ export interface QuizAnswerInput {
} }
export interface SubmitQuizInput { export interface SubmitQuizInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
answers: QuizAnswerInput[]; answers: QuizAnswerInput[];
@ -384,7 +384,7 @@ export interface SubmitQuizResponse {
// ============================================ // ============================================
export interface GetQuizAttemptsInput { export interface GetQuizAttemptsInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
} }

View file

@ -32,14 +32,14 @@ export interface ListAnnouncementResponse{
} }
export interface ListAnnouncementInput { export interface ListAnnouncementInput {
token: string; userId: number;
course_id: number; course_id: number;
page?: number; page?: number;
limit?: number; limit?: number;
} }
export interface CreateAnnouncementInput { export interface CreateAnnouncementInput {
token: string; userId: number;
course_id: number; course_id: number;
title: MultiLanguageText; title: MultiLanguageText;
content: MultiLanguageText; content: MultiLanguageText;
@ -50,7 +50,7 @@ export interface CreateAnnouncementInput{
} }
export interface UploadAnnouncementAttachmentInput { export interface UploadAnnouncementAttachmentInput {
token: string; userId: number;
course_id: number; course_id: number;
announcement_id: number; announcement_id: number;
file: File; file: File;
@ -63,7 +63,7 @@ export interface UploadAnnouncementAttachmentResponse{
} }
export interface DeleteAnnouncementAttachmentInput { export interface DeleteAnnouncementAttachmentInput {
token: string; userId: number;
course_id: number; course_id: number;
announcement_id: number; announcement_id: number;
attachment_id: number; attachment_id: number;
@ -81,7 +81,7 @@ export interface CreateAnnouncementResponse{
} }
export interface UpdateAnnouncementInput { export interface UpdateAnnouncementInput {
token: string; userId: number;
course_id: number; course_id: number;
announcement_id: number; announcement_id: number;
title: MultiLanguageText; title: MultiLanguageText;
@ -99,7 +99,7 @@ export interface UpdateAnnouncementResponse{
} }
export interface DeleteAnnouncementInput { export interface DeleteAnnouncementInput {
token: string; userId: number;
course_id: number; course_id: number;
announcement_id: number; announcement_id: number;
} }

View file

@ -28,7 +28,6 @@ export interface LoginResponse {
data: { data: {
token: string; token: string;
refreshToken: string; refreshToken: string;
user: UserResponse;
}; };
} }

View file

@ -3,7 +3,7 @@
// ============================================ // ============================================
export interface GenerateCertificateInput { export interface GenerateCertificateInput {
token: string; userId: number;
course_id: number; course_id: number;
} }
@ -19,7 +19,7 @@ export interface GenerateCertificateResponse {
} }
export interface GetCertificateInput { export interface GetCertificateInput {
token: string; userId: number;
course_id: number; course_id: number;
} }
@ -37,7 +37,7 @@ export interface GetCertificateResponse {
} }
export interface ListMyCertificatesInput { export interface ListMyCertificatesInput {
token: string; userId: number;
} }
export interface ListMyCertificatesResponse { export interface ListMyCertificatesResponse {

View file

@ -0,0 +1,14 @@
{
"extends": "../tsconfig.test.json",
"compilerOptions": {
"rootDir": "..",
"types": [
"node",
"jest"
]
},
"include": [
"../src/**/*",
"./**/*"
]
}

View file

@ -0,0 +1,67 @@
import {
ApproveCourseValidator,
RejectCourseValidator,
} from '@/validators/AdminCourseApproval.validator';
describe('ApproveCourseValidator', () => {
it('should pass with no body (comment optional)', () => {
const { error } = ApproveCourseValidator.validate({});
expect(error).toBeUndefined();
});
it('should pass with optional comment', () => {
const { error } = ApproveCourseValidator.validate({
comment: 'Looks great!',
});
expect(error).toBeUndefined();
});
it('should fail when comment exceeds 1000 characters', () => {
const { error } = ApproveCourseValidator.validate({
comment: 'a'.repeat(1001),
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/must not exceed 1000/i);
});
it('should pass with comment exactly 1000 characters', () => {
const { error } = ApproveCourseValidator.validate({
comment: 'a'.repeat(1000),
});
expect(error).toBeUndefined();
});
});
describe('RejectCourseValidator', () => {
it('should pass with valid rejection comment', () => {
const { error } = RejectCourseValidator.validate({
comment: 'The content is incomplete and needs more details.',
});
expect(error).toBeUndefined();
});
it('should fail without comment', () => {
const { error } = RejectCourseValidator.validate({});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Comment is required when rejecting/i);
});
it('should fail when comment is too short (< 10 chars)', () => {
const { error } = RejectCourseValidator.validate({ comment: 'Too short' });
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/at least 10 characters/i);
});
it('should pass with exactly 10 characters', () => {
const { error } = RejectCourseValidator.validate({ comment: '1234567890' });
expect(error).toBeUndefined();
});
it('should fail when comment exceeds 1000 characters', () => {
const { error } = RejectCourseValidator.validate({
comment: 'a'.repeat(1001),
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/must not exceed 1000/i);
});
});

View file

@ -0,0 +1,263 @@
import {
CreateChapterValidator,
UpdateChapterValidator,
ReorderChapterValidator,
CreateLessonValidator,
UpdateLessonValidator,
ReorderLessonsValidator,
AddQuestionValidator,
UpdateQuizValidator,
} from '@/validators/ChaptersLesson.validator';
// ============================================================
// Chapter Validators
// ============================================================
describe('CreateChapterValidator', () => {
it('should pass with valid data', () => {
const { error } = CreateChapterValidator.validate({
title: { th: 'บทที่ 1', en: 'Chapter 1' },
});
expect(error).toBeUndefined();
});
it('should pass with optional fields', () => {
const { error } = CreateChapterValidator.validate({
title: { th: 'บทที่ 1', en: 'Chapter 1' },
description: { th: 'คำอธิบาย', en: 'Description' },
sort_order: 0,
});
expect(error).toBeUndefined();
});
it('should fail if title is missing', () => {
const { error } = CreateChapterValidator.validate({});
expect(error).toBeDefined();
});
it('should fail if title.th is missing', () => {
const { error } = CreateChapterValidator.validate({
title: { en: 'Chapter 1' },
});
expect(error).toBeDefined();
});
it('should fail if title.en is missing', () => {
const { error } = CreateChapterValidator.validate({
title: { th: 'บทที่ 1' },
});
expect(error).toBeDefined();
});
});
describe('UpdateChapterValidator', () => {
it('should pass with empty object (all fields optional)', () => {
const { error } = UpdateChapterValidator.validate({});
expect(error).toBeUndefined();
});
it('should pass with partial fields', () => {
const { error } = UpdateChapterValidator.validate({
is_published: true,
});
expect(error).toBeUndefined();
});
});
describe('ReorderChapterValidator', () => {
it('should pass with valid sort_order', () => {
const { error } = ReorderChapterValidator.validate({ sort_order: 0 });
expect(error).toBeUndefined();
});
it('should fail without sort_order', () => {
const { error } = ReorderChapterValidator.validate({});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Sort order is required/i);
});
it('should fail with negative sort_order', () => {
const { error } = ReorderChapterValidator.validate({ sort_order: -1 });
expect(error).toBeDefined();
});
});
// ============================================================
// Lesson Validators
// ============================================================
describe('CreateLessonValidator', () => {
it('should pass with VIDEO type', () => {
const { error } = CreateLessonValidator.validate({
title: { th: 'บทเรียน 1', en: 'Lesson 1' },
type: 'VIDEO',
});
expect(error).toBeUndefined();
});
it('should pass with QUIZ type', () => {
const { error } = CreateLessonValidator.validate({
title: { th: 'แบบทดสอบ', en: 'Quiz' },
type: 'QUIZ',
});
expect(error).toBeUndefined();
});
it('should fail with invalid type', () => {
const { error } = CreateLessonValidator.validate({
title: { th: 'บทเรียน', en: 'Lesson' },
type: 'DOCUMENT',
});
expect(error).toBeDefined();
});
it('should fail without title', () => {
const { error } = CreateLessonValidator.validate({ type: 'VIDEO' });
expect(error).toBeDefined();
});
it('should fail without type', () => {
const { error } = CreateLessonValidator.validate({
title: { th: 'บทเรียน', en: 'Lesson' },
});
expect(error).toBeDefined();
});
});
describe('UpdateLessonValidator — prerequisite_lesson_ids', () => {
it('should pass with valid array of ids', () => {
const { error } = UpdateLessonValidator.validate({
prerequisite_lesson_ids: [1, 2, 3],
});
expect(error).toBeUndefined();
});
it('should pass with null (clear prerequisites)', () => {
const { error } = UpdateLessonValidator.validate({
prerequisite_lesson_ids: null,
});
expect(error).toBeUndefined();
});
it('should pass with empty array (clear prerequisites)', () => {
const { error } = UpdateLessonValidator.validate({
prerequisite_lesson_ids: [],
});
expect(error).toBeUndefined();
});
it('should pass without the field (no change)', () => {
const { error } = UpdateLessonValidator.validate({});
expect(error).toBeUndefined();
});
it('should fail with non-integer ids', () => {
const { error } = UpdateLessonValidator.validate({
prerequisite_lesson_ids: [1.5],
});
expect(error).toBeDefined();
});
it('should fail with negative ids', () => {
const { error } = UpdateLessonValidator.validate({
prerequisite_lesson_ids: [-1],
});
expect(error).toBeDefined();
});
it('should fail with string ids', () => {
const { error } = UpdateLessonValidator.validate({
prerequisite_lesson_ids: ['abc'],
});
expect(error).toBeDefined();
});
});
describe('ReorderLessonsValidator', () => {
it('should pass with valid data', () => {
const { error } = ReorderLessonsValidator.validate({
lesson_id: 1,
sort_order: 0,
});
expect(error).toBeUndefined();
});
it('should fail without lesson_id', () => {
const { error } = ReorderLessonsValidator.validate({ sort_order: 0 });
expect(error).toBeDefined();
});
it('should fail without sort_order', () => {
const { error } = ReorderLessonsValidator.validate({ lesson_id: 1 });
expect(error).toBeDefined();
});
});
// ============================================================
// Quiz Validators
// ============================================================
describe('AddQuestionValidator', () => {
it('should pass with MULTIPLE_CHOICE type + choices', () => {
const { error } = AddQuestionValidator.validate({
question: { th: 'ข้อที่ 1 คืออะไร?', en: 'What is question 1?' },
question_type: 'MULTIPLE_CHOICE',
choices: [
{ text: { th: 'ก', en: 'A' }, is_correct: true },
{ text: { th: 'ข', en: 'B' }, is_correct: false },
],
});
expect(error).toBeUndefined();
});
it('should pass with TRUE_FALSE type without choices', () => {
const { error } = AddQuestionValidator.validate({
question: { th: 'ถูกหรือผิด?', en: 'True or False?' },
question_type: 'TRUE_FALSE',
});
expect(error).toBeUndefined();
});
it('should fail with invalid question_type', () => {
const { error } = AddQuestionValidator.validate({
question: { th: 'คำถาม', en: 'Question' },
question_type: 'ESSAY',
});
expect(error).toBeDefined();
});
it('should fail without question', () => {
const { error } = AddQuestionValidator.validate({
question_type: 'TRUE_FALSE',
});
expect(error).toBeDefined();
});
});
describe('UpdateQuizValidator', () => {
it('should pass with empty object (all optional)', () => {
const { error } = UpdateQuizValidator.validate({});
expect(error).toBeUndefined();
});
it('should pass with valid passing_score', () => {
const { error } = UpdateQuizValidator.validate({ passing_score: 70 });
expect(error).toBeUndefined();
});
it('should fail with passing_score > 100', () => {
const { error } = UpdateQuizValidator.validate({ passing_score: 101 });
expect(error).toBeDefined();
});
it('should fail with passing_score < 0', () => {
const { error } = UpdateQuizValidator.validate({ passing_score: -1 });
expect(error).toBeDefined();
});
it('should pass with time_limit 0 (no limit)', () => {
const { error } = UpdateQuizValidator.validate({ time_limit: 0 });
expect(error).toBeUndefined();
});
});

View file

@ -0,0 +1,150 @@
import {
CreateCourseValidator,
UpdateCourseValidator,
CloneCourseValidator,
addInstructorCourseValidator,
} from '@/validators/CoursesInstructor.validator';
// ============================================================
// addInstructorCourseValidator
// ============================================================
describe('addInstructorCourseValidator', () => {
it('should pass with valid user_id and course_id', () => {
const { error } = addInstructorCourseValidator.validate({
user_id: 1,
course_id: 2,
});
expect(error).toBeUndefined();
});
it('should fail without user_id', () => {
const { error } = addInstructorCourseValidator.validate({ course_id: 2 });
expect(error).toBeDefined();
});
it('should fail without course_id', () => {
const { error } = addInstructorCourseValidator.validate({ user_id: 1 });
expect(error).toBeDefined();
});
});
// ============================================================
// CreateCourseValidator
// ============================================================
describe('CreateCourseValidator', () => {
const validPayload = {
category_id: 1,
title: { th: 'คอร์สทดสอบ', en: 'Test Course' },
slug: 'test-course',
description: { th: 'คำอธิบาย', en: 'Description' },
price: 500,
is_free: false,
have_certificate: true,
};
it('should pass with all required fields', () => {
const { error } = CreateCourseValidator.validate(validPayload);
expect(error).toBeUndefined();
});
it('should fail if title.th is missing', () => {
const { error } = CreateCourseValidator.validate({
...validPayload,
title: { en: 'Test Course' },
});
expect(error).toBeDefined();
});
it('should fail if slug is missing', () => {
const { error } = CreateCourseValidator.validate({
...validPayload,
slug: undefined,
});
expect(error).toBeDefined();
});
it('should fail if price is missing', () => {
const { error } = CreateCourseValidator.validate({
...validPayload,
price: undefined,
});
expect(error).toBeDefined();
});
it('should fail if is_free is missing', () => {
const { error } = CreateCourseValidator.validate({
...validPayload,
is_free: undefined,
});
expect(error).toBeDefined();
});
it('should allow price = 0 (free course)', () => {
const { error } = CreateCourseValidator.validate({
...validPayload,
price: 0,
is_free: true,
});
expect(error).toBeUndefined();
});
});
// ============================================================
// UpdateCourseValidator
// ============================================================
describe('UpdateCourseValidator', () => {
it('should pass with empty object (all optional)', () => {
const { error } = UpdateCourseValidator.validate({});
expect(error).toBeUndefined();
});
it('should pass with partial update', () => {
const { error } = UpdateCourseValidator.validate({ price: 999 });
expect(error).toBeUndefined();
});
it('should pass with title partial update (th only)', () => {
const { error } = UpdateCourseValidator.validate({
title: { th: 'ชื่อใหม่' },
});
expect(error).toBeUndefined();
});
});
// ============================================================
// CloneCourseValidator
// ============================================================
describe('CloneCourseValidator', () => {
it('should pass with valid title', () => {
const { error } = CloneCourseValidator.validate({
title: { th: 'คอร์ส Copy', en: 'Course Copy' },
});
expect(error).toBeUndefined();
});
it('should fail without title.th', () => {
const { error } = CloneCourseValidator.validate({
title: { en: 'Course Copy' },
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Thai title is required/i);
});
it('should fail without title.en', () => {
const { error } = CloneCourseValidator.validate({
title: { th: 'คอร์ส Copy' },
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/English title is required/i);
});
it('should fail without title entirely', () => {
const { error } = CloneCourseValidator.validate({});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Title is required/i);
});
});

View file

@ -0,0 +1,96 @@
import {
SaveVideoProgressValidator,
SubmitQuizValidator,
} from '@/validators/CoursesStudent.validator';
describe('SaveVideoProgressValidator', () => {
it('should pass with required field only', () => {
const { error } = SaveVideoProgressValidator.validate({
video_progress_seconds: 60,
});
expect(error).toBeUndefined();
});
it('should pass with all fields', () => {
const { error } = SaveVideoProgressValidator.validate({
video_progress_seconds: 120,
video_duration_seconds: 600,
});
expect(error).toBeUndefined();
});
it('should pass with progress = 0 (start of video)', () => {
const { error } = SaveVideoProgressValidator.validate({
video_progress_seconds: 0,
});
expect(error).toBeUndefined();
});
it('should fail without video_progress_seconds', () => {
const { error } = SaveVideoProgressValidator.validate({});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Video progress seconds is required/i);
});
it('should fail with negative progress', () => {
const { error } = SaveVideoProgressValidator.validate({
video_progress_seconds: -1,
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/at least 0/i);
});
it('should fail with negative video duration', () => {
const { error } = SaveVideoProgressValidator.validate({
video_progress_seconds: 10,
video_duration_seconds: -1,
});
expect(error).toBeDefined();
});
});
describe('SubmitQuizValidator', () => {
const validAnswer = { question_id: 1, choice_id: 2 };
it('should pass with valid answers', () => {
const { error } = SubmitQuizValidator.validate({
answers: [validAnswer, { question_id: 2, choice_id: 5 }],
});
expect(error).toBeUndefined();
});
it('should fail without answers', () => {
const { error } = SubmitQuizValidator.validate({});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Answers are required/i);
});
it('should fail with empty answers array', () => {
const { error } = SubmitQuizValidator.validate({ answers: [] });
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/At least one answer/i);
});
it('should fail if question_id is missing in an answer', () => {
const { error } = SubmitQuizValidator.validate({
answers: [{ choice_id: 2 }],
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Question ID is required/i);
});
it('should fail if choice_id is missing in an answer', () => {
const { error } = SubmitQuizValidator.validate({
answers: [{ question_id: 1 }],
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Choice ID is required/i);
});
it('should fail if question_id is not a positive integer', () => {
const { error } = SubmitQuizValidator.validate({
answers: [{ question_id: -1, choice_id: 1 }],
});
expect(error).toBeDefined();
});
});

View file

@ -0,0 +1,45 @@
import { SetYouTubeVideoValidator } from '@/validators/Lessons.validator';
describe('SetYouTubeVideoValidator', () => {
it('should pass with valid youtube_video_id and video_title', () => {
const { error } = SetYouTubeVideoValidator.validate({
youtube_video_id: 'dQw4w9WgXcQ',
video_title: 'Introduction to TypeScript',
});
expect(error).toBeUndefined();
});
it('should fail without youtube_video_id', () => {
const { error } = SetYouTubeVideoValidator.validate({
video_title: 'Intro to TS',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/YouTube video ID is required/i);
});
it('should fail with empty youtube_video_id string', () => {
const { error } = SetYouTubeVideoValidator.validate({
youtube_video_id: '',
video_title: 'Intro to TS',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/cannot be empty/i);
});
it('should fail without video_title', () => {
const { error } = SetYouTubeVideoValidator.validate({
youtube_video_id: 'dQw4w9WgXcQ',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Video title is required/i);
});
it('should fail with empty video_title string', () => {
const { error } = SetYouTubeVideoValidator.validate({
youtube_video_id: 'dQw4w9WgXcQ',
video_title: '',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/cannot be empty/i);
});
});

View file

@ -0,0 +1,115 @@
import {
CreateAnnouncementValidator,
UpdateAnnouncementValidator,
} from '@/validators/announcements.validator';
describe('CreateAnnouncementValidator', () => {
const validPayload = {
title: { th: 'ประกาศใหม่', en: 'New Announcement' },
content: { th: 'เนื้อหา', en: 'Content' },
status: 'DRAFT',
is_pinned: false,
};
it('should pass with all required fields', () => {
const { error } = CreateAnnouncementValidator.validate(validPayload);
expect(error).toBeUndefined();
});
it('should pass with optional published_at as ISO date', () => {
const { error } = CreateAnnouncementValidator.validate({
...validPayload,
published_at: '2026-03-01T00:00:00.000Z',
});
expect(error).toBeUndefined();
});
it('should fail with invalid published_at format', () => {
const { error } = CreateAnnouncementValidator.validate({
...validPayload,
published_at: '01-03-2026',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/ISO date/i);
});
it('should fail with invalid status', () => {
const { error } = CreateAnnouncementValidator.validate({
...validPayload,
status: 'HIDDEN',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/DRAFT, PUBLISHED, ARCHIVED/i);
});
it('should pass with PUBLISHED status', () => {
const { error } = CreateAnnouncementValidator.validate({
...validPayload,
status: 'PUBLISHED',
});
expect(error).toBeUndefined();
});
it('should pass with ARCHIVED status', () => {
const { error } = CreateAnnouncementValidator.validate({
...validPayload,
status: 'ARCHIVED',
});
expect(error).toBeUndefined();
});
it('should fail without title', () => {
const { error } = CreateAnnouncementValidator.validate({
...validPayload,
title: undefined,
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Title is required/i);
});
it('should fail without content', () => {
const { error } = CreateAnnouncementValidator.validate({
...validPayload,
content: undefined,
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Content is required/i);
});
it('should fail without is_pinned', () => {
const { error } = CreateAnnouncementValidator.validate({
...validPayload,
is_pinned: undefined,
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/is_pinned is required/i);
});
});
describe('UpdateAnnouncementValidator', () => {
it('should pass with empty object (all optional)', () => {
const { error } = UpdateAnnouncementValidator.validate({});
expect(error).toBeUndefined();
});
it('should pass with partial update', () => {
const { error } = UpdateAnnouncementValidator.validate({
status: 'PUBLISHED',
is_pinned: true,
});
expect(error).toBeUndefined();
});
it('should fail with invalid status', () => {
const { error } = UpdateAnnouncementValidator.validate({ status: 'DELETED' });
expect(error).toBeDefined();
});
it('should fail with invalid published_at format', () => {
const { error } = UpdateAnnouncementValidator.validate({
published_at: 'not-a-date',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/ISO date/i);
});
});

View file

@ -0,0 +1,246 @@
import {
loginSchema,
registerSchema,
refreshTokenSchema,
resetPasswordSchema,
changePasswordSchema,
resetRequestSchema,
} from '@/validators/auth.validator';
// ============================================================
// loginSchema
// ============================================================
describe('loginSchema', () => {
it('should pass with valid email and password', () => {
const { error } = loginSchema.validate({
email: 'user@example.com',
password: 'password123',
});
expect(error).toBeUndefined();
});
it('should fail with invalid email format', () => {
const { error } = loginSchema.validate({
email: 'not-an-email',
password: 'password123',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/valid email/i);
});
it('should fail without email', () => {
const { error } = loginSchema.validate({ password: 'password123' });
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Email is required/i);
});
it('should fail without password', () => {
const { error } = loginSchema.validate({ email: 'user@example.com' });
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Password is required/i);
});
it('should fail with password shorter than 6 characters', () => {
const { error } = loginSchema.validate({
email: 'user@example.com',
password: '12345',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/at least 6 characters/i);
});
});
// ============================================================
// registerSchema
// ============================================================
describe('registerSchema', () => {
const validPayload = {
username: 'john_doe',
email: 'john@example.com',
password: 'securepass',
first_name: 'John',
last_name: 'Doe',
phone: '0812345678',
};
it('should pass with all required fields', () => {
const { error } = registerSchema.validate(validPayload);
expect(error).toBeUndefined();
});
it('should pass with optional prefix', () => {
const { error } = registerSchema.validate({
...validPayload,
prefix: { th: 'นาย', en: 'Mr.' },
});
expect(error).toBeUndefined();
});
it('should fail if username has invalid characters', () => {
const { error } = registerSchema.validate({
...validPayload,
username: 'john doe!',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/letters, numbers, and underscores/i);
});
it('should fail if username is too short (< 3 chars)', () => {
const { error } = registerSchema.validate({
...validPayload,
username: 'ab',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/at least 3 characters/i);
});
it('should fail if username is too long (> 50 chars)', () => {
const { error } = registerSchema.validate({
...validPayload,
username: 'a'.repeat(51),
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/not exceed 50 characters/i);
});
it('should fail with invalid email', () => {
const { error } = registerSchema.validate({
...validPayload,
email: 'bad-email',
});
expect(error).toBeDefined();
});
it('should fail if phone is too short (< 10 chars)', () => {
const { error } = registerSchema.validate({
...validPayload,
phone: '081234',
});
expect(error).toBeDefined();
});
it('should fail without first_name', () => {
const { error } = registerSchema.validate({
...validPayload,
first_name: undefined,
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/First name is required/i);
});
it('should fail without last_name', () => {
const { error } = registerSchema.validate({
...validPayload,
last_name: undefined,
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Last name is required/i);
});
});
// ============================================================
// refreshTokenSchema
// ============================================================
describe('refreshTokenSchema', () => {
it('should pass with a valid refreshToken', () => {
const { error } = refreshTokenSchema.validate({
refreshToken: 'some-refresh-token-string',
});
expect(error).toBeUndefined();
});
it('should fail without refreshToken', () => {
const { error } = refreshTokenSchema.validate({});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Refresh token is required/i);
});
});
// ============================================================
// resetPasswordSchema
// ============================================================
describe('resetPasswordSchema', () => {
it('should pass with valid token and password', () => {
const { error } = resetPasswordSchema.validate({
token: 'reset-token-abc',
password: 'newpassword',
});
expect(error).toBeUndefined();
});
it('should fail without token', () => {
const { error } = resetPasswordSchema.validate({ password: 'newpassword' });
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Reset token is required/i);
});
it('should fail with password too short', () => {
const { error } = resetPasswordSchema.validate({
token: 'abc',
password: '12345',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/at least 6 characters/i);
});
it('should fail with password too long (> 100 chars)', () => {
const { error } = resetPasswordSchema.validate({
token: 'abc',
password: 'a'.repeat(101),
});
expect(error).toBeDefined();
});
});
// ============================================================
// changePasswordSchema (auth)
// ============================================================
describe('changePasswordSchema (auth.validator)', () => {
it('should pass with valid old and new passwords', () => {
const { error } = changePasswordSchema.validate({
oldPassword: 'oldpass123',
newPassword: 'newpass456',
});
expect(error).toBeUndefined();
});
it('should fail without oldPassword', () => {
const { error } = changePasswordSchema.validate({ newPassword: 'newpass456' });
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Password is required/i);
});
it('should fail without newPassword', () => {
const { error } = changePasswordSchema.validate({ oldPassword: 'oldpass123' });
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Password is required/i);
});
});
// ============================================================
// resetRequestSchema
// ============================================================
describe('resetRequestSchema', () => {
it('should pass with valid email', () => {
const { error } = resetRequestSchema.validate({ email: 'user@example.com' });
expect(error).toBeUndefined();
});
it('should fail without email', () => {
const { error } = resetRequestSchema.validate({});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Email is required/i);
});
it('should fail with invalid email', () => {
const { error } = resetRequestSchema.validate({ email: 'not-email' });
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/valid email/i);
});
});

View file

@ -0,0 +1,121 @@
import {
CreateCategoryValidator,
UpdateCategoryValidator,
} from '@/validators/categories.validator';
describe('CreateCategoryValidator', () => {
const validPayload = {
name: { th: 'การพัฒนาเว็บ', en: 'Web Development' },
slug: 'web-development',
description: { th: 'หมวดหมู่การพัฒนาเว็บ', en: 'Web development category' },
};
it('should pass with all required fields', () => {
const { error } = CreateCategoryValidator.validate(validPayload);
expect(error).toBeUndefined();
});
it('should pass with optional created_by', () => {
const { error } = CreateCategoryValidator.validate({
...validPayload,
created_by: 1,
});
expect(error).toBeUndefined();
});
it('should fail without name', () => {
const { error } = CreateCategoryValidator.validate({
...validPayload,
name: undefined,
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Name is required/i);
});
it('should fail without name.th', () => {
const { error } = CreateCategoryValidator.validate({
...validPayload,
name: { en: 'Web Development' },
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Thai name is required/i);
});
it('should fail without slug', () => {
const { error } = CreateCategoryValidator.validate({
...validPayload,
slug: undefined,
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Slug is required/i);
});
it('should fail with invalid slug format (uppercase)', () => {
const { error } = CreateCategoryValidator.validate({
...validPayload,
slug: 'Web-Development',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/lowercase with hyphens/i);
});
it('should fail with invalid slug format (spaces)', () => {
const { error } = CreateCategoryValidator.validate({
...validPayload,
slug: 'web development',
});
expect(error).toBeDefined();
});
it('should pass with valid slug formats', () => {
const slugs = ['web', 'web-dev', 'web-development-101'];
for (const slug of slugs) {
const { error } = CreateCategoryValidator.validate({
...validPayload,
slug,
});
expect(error).toBeUndefined();
}
});
it('should fail without description', () => {
const { error } = CreateCategoryValidator.validate({
...validPayload,
description: undefined,
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Description is required/i);
});
});
describe('UpdateCategoryValidator', () => {
it('should pass with only required id', () => {
const { error } = UpdateCategoryValidator.validate({ id: 1 });
expect(error).toBeUndefined();
});
it('should pass with all optional fields', () => {
const { error } = UpdateCategoryValidator.validate({
id: 1,
name: { th: 'ใหม่', en: 'New' },
slug: 'new-category',
description: { th: 'คำอธิบาย', en: 'Description' },
});
expect(error).toBeUndefined();
});
it('should fail without id', () => {
const { error } = UpdateCategoryValidator.validate({ name: { th: 'ใหม่', en: 'New' } });
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Category ID is required/i);
});
it('should fail with invalid slug format', () => {
const { error } = UpdateCategoryValidator.validate({
id: 1,
slug: 'Invalid Slug!',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/lowercase with hyphens/i);
});
});

View file

@ -0,0 +1,100 @@
import {
profileUpdateSchema,
changePasswordSchema,
} from '@/validators/user.validator';
describe('profileUpdateSchema', () => {
it('should pass with empty object (all optional)', () => {
const { error } = profileUpdateSchema.validate({});
expect(error).toBeUndefined();
});
it('should pass with all fields', () => {
const { error } = profileUpdateSchema.validate({
prefix: { th: 'นาย', en: 'Mr.' },
first_name: 'John',
last_name: 'Doe',
phone: '0812345678',
avatar_url: 'https://example.com/avatar.jpg',
birth_date: new Date('1990-01-01'),
});
expect(error).toBeUndefined();
});
it('should pass with partial update (first_name only)', () => {
const { error } = profileUpdateSchema.validate({ first_name: 'Jane' });
expect(error).toBeUndefined();
});
it('should fail if first_name is empty string', () => {
const { error } = profileUpdateSchema.validate({ first_name: '' });
expect(error).toBeDefined();
});
it('should fail if first_name exceeds 100 characters', () => {
const { error } = profileUpdateSchema.validate({ first_name: 'a'.repeat(101) });
expect(error).toBeDefined();
});
it('should fail if phone is too short (< 10 chars)', () => {
const { error } = profileUpdateSchema.validate({ phone: '081234' });
expect(error).toBeDefined();
});
it('should fail if phone exceeds 15 characters', () => {
const { error } = profileUpdateSchema.validate({ phone: '1'.repeat(16) });
expect(error).toBeDefined();
});
it('should pass with valid birth_date', () => {
const { error } = profileUpdateSchema.validate({ birth_date: new Date('2000-06-15') });
expect(error).toBeUndefined();
});
});
describe('changePasswordSchema (user.validator)', () => {
it('should pass with valid old and new passwords', () => {
const { error } = changePasswordSchema.validate({
oldPassword: 'oldpass123',
newPassword: 'newpass456',
});
expect(error).toBeUndefined();
});
it('should fail without oldPassword', () => {
const { error } = changePasswordSchema.validate({ newPassword: 'newpass456' });
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Old password is required/i);
});
it('should fail without newPassword', () => {
const { error } = changePasswordSchema.validate({ oldPassword: 'oldpass123' });
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/New password is required/i);
});
it('should fail if oldPassword is shorter than 6 chars', () => {
const { error } = changePasswordSchema.validate({
oldPassword: '12345',
newPassword: 'newpass456',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/at least 6 characters/i);
});
it('should fail if newPassword is shorter than 6 chars', () => {
const { error } = changePasswordSchema.validate({
oldPassword: 'oldpass123',
newPassword: '123',
});
expect(error).toBeDefined();
});
it('should fail if oldPassword exceeds 100 characters', () => {
const { error } = changePasswordSchema.validate({
oldPassword: 'a'.repeat(101),
newPassword: 'newpass456',
});
expect(error).toBeDefined();
});
});

View file

@ -0,0 +1,59 @@
import {
getUserByIdValidator,
updateUserRoleValidator,
} from '@/validators/usermanagement.validator';
describe('getUserByIdValidator', () => {
it('should pass with valid id', () => {
const { error } = getUserByIdValidator.validate({ id: 1 });
expect(error).toBeUndefined();
});
it('should fail without id', () => {
const { error } = getUserByIdValidator.validate({});
expect(error).toBeDefined();
});
it('should fail with non-numeric id', () => {
const { error } = getUserByIdValidator.validate({ id: 'abc' });
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/ID must be a number/i);
});
it('should pass with id = 0', () => {
// Joi number() allows 0 by default unless positive() is specified
const { error } = getUserByIdValidator.validate({ id: 0 });
expect(error).toBeUndefined();
});
});
describe('updateUserRoleValidator', () => {
it('should pass with valid id and role_id', () => {
const { error } = updateUserRoleValidator.validate({ id: 1, role_id: 2 });
expect(error).toBeUndefined();
});
it('should fail without id', () => {
const { error } = updateUserRoleValidator.validate({ role_id: 2 });
expect(error).toBeDefined();
});
it('should fail without role_id', () => {
const { error } = updateUserRoleValidator.validate({ id: 1 });
expect(error).toBeDefined();
// Joi uses field name in message when custom messages don't match
expect(error?.details[0].message).toContain('role_id');
});
it('should fail with non-numeric role_id', () => {
const { error } = updateUserRoleValidator.validate({ id: 1, role_id: 'admin' });
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Role ID must be a number/i);
});
it('should fail with non-numeric id', () => {
const { error } = updateUserRoleValidator.validate({ id: 'abc', role_id: 1 });
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/ID must be a number/i);
});
});

View file

@ -0,0 +1,20 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
},
"types": [
"node",
"jest"
]
},
"include": [
"src/**/*",
"tests/**/*"
]
}

1
Frontend-Learner/.nuxtrc Normal file
View file

@ -0,0 +1 @@
setups.@nuxt/test-utils="4.0.0"

View file

@ -7,8 +7,8 @@ WORKDIR /app
# คัดลอกไฟล์จัดการ dependencies # คัดลอกไฟล์จัดการ dependencies
COPY package*.json ./ COPY package*.json ./
# ติดตั้ง dependencies (ใช้ npm ci เพื่อความแม่นยำของเวอร์ชัน) # ติดตั้ง dependencies
RUN npm ci RUN npm install
# คัดลอกไฟล์ทั้งหมดในโปรเจกต์ # คัดลอกไฟล์ทั้งหมดในโปรเจกต์
COPY . . COPY . .

View file

@ -27,8 +27,8 @@ onMounted(() => {
</script> </script>
<template> <template>
<!-- แสดง Loader ระหวางเปลยนหน หรอโหลดขอม --> <!-- แสดงแถบโหลดดานบนจอ (Progress Bar) แทนการโหลดเตมหนาจอ -->
<GlobalLoader /> <NuxtLoadingIndicator color="#2563EB" :height="4" />
<!-- NuxtLayout: แสดง Layout กำหนดในแตละเพจ (default: layouts/default.vue) --> <!-- NuxtLayout: แสดง Layout กำหนดในแตละเพจ (default: layouts/default.vue) -->
<NuxtLayout> <NuxtLayout>

View file

@ -40,7 +40,7 @@ export const useAuth = () => {
// ฟังก์ชันเข้าสู่ระบบ (Login) // ฟังก์ชันเข้าสู่ระบบ (Login)
const login = async (credentials: { email: string; password: string }) => { const login = async (credentials: { email: string; password: string }) => {
try { try {
// API returns { code: 200, message: "...", data: { token, user, ... } } // API returns { code: 200, message: "...", data: { token, refreshToken } }
const response = await $fetch<any>(`${API_BASE_URL}/auth/login`, { const response = await $fetch<any>(`${API_BASE_URL}/auth/login`, {
method: 'POST', method: 'POST',
body: credentials body: credentials
@ -49,16 +49,35 @@ export const useAuth = () => {
if (response && response.data) { if (response && response.data) {
const data = response.data const data = response.data
// Validation: Ensure user and role exist, then check for Role 'STUDENT' // บันทึก Token ก่อน เพื่อใช้เรียก /user/me
if (!data.user || !data.user.role || data.user.role.code !== 'STUDENT') { token.value = data.token
refreshToken.value = data.refreshToken
// ดึงข้อมูลผู้ใช้จาก /user/me (เพราะ API login ไม่ส่ง user กลับมาแล้ว)
try {
const userData = await $fetch<any>(`${API_BASE_URL}/user/me`, {
headers: {
Authorization: `Bearer ${data.token}`
}
})
// Validation: ตรวจสอบ Role ต้องเป็น STUDENT เท่านั้น
if (!userData || !userData.role || userData.role.code !== 'STUDENT') {
// ถ้า Role ไม่ใช่ STUDENT ให้ล้าง Token ออก
token.value = null
refreshToken.value = null
return { success: false, error: 'Email ไม่ถูกต้อง' } return { success: false, error: 'Email ไม่ถูกต้อง' }
} }
token.value = data.token // เก็บข้อมูล User ลง Cookie
refreshToken.value = data.refreshToken // บันทึก Refresh Token user.value = userData
} catch (profileErr) {
// API ส่งข้อมูล profile มาใน user object // ดึงข้อมูลผู้ใช้ไม่สำเร็จ ให้ล้าง Token ออก
user.value = data.user console.error('Failed to fetch user profile after login:', profileErr)
token.value = null
refreshToken.value = null
return { success: false, error: 'ไม่สามารถดึงข้อมูลผู้ใช้ได้' }
}
return { success: true } return { success: true }
} }

View file

@ -117,7 +117,11 @@
"foundTotal": "Found Total", "foundTotal": "Found Total",
"items": "items", "items": "items",
"subtitle": "Choose to learn new skills from our curated quality courses", "subtitle": "Choose to learn new skills from our curated quality courses",
"searchBtn": "Search" "searchBtn": "Search",
"allCategory": "All",
"byInstructor": "by",
"students": "students",
"viewDetails": "View Details"
}, },
"myCourses": { "myCourses": {
"title": "My Courses", "title": "My Courses",

View file

@ -117,7 +117,11 @@
"foundTotal": "พบทั้งหมด", "foundTotal": "พบทั้งหมด",
"items": "รายการ", "items": "รายการ",
"subtitle": "เลือกเรียนรู้ทักษะใหม่ๆ จากหลักสูตรคุณภาพที่คัดสรรมาเพื่อคุณ", "subtitle": "เลือกเรียนรู้ทักษะใหม่ๆ จากหลักสูตรคุณภาพที่คัดสรรมาเพื่อคุณ",
"searchBtn": "ค้นหา" "searchBtn": "ค้นหา",
"allCategory": "ทั้งหมด",
"byInstructor": "โดย",
"students": "นักเรียน",
"viewDetails": "ดูรายละเอียด"
}, },
"myCourses": { "myCourses": {
"title": "คอร์สของฉัน", "title": "คอร์สของฉัน",

File diff suppressed because it is too large Load diff

View file

@ -19,9 +19,14 @@
}, },
"devDependencies": { "devDependencies": {
"@nuxt/eslint-config": "^1.12.1", "@nuxt/eslint-config": "^1.12.1",
"@nuxt/test-utils": "^4.0.0",
"@nuxtjs/i18n": "^10.2.1", "@nuxtjs/i18n": "^10.2.1",
"@playwright/test": "^1.58.2",
"@types/node": "^22.19.8", "@types/node": "^22.19.8",
"@vue/test-utils": "^2.4.6",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"typescript": "^5.4.5" "jsdom": "^28.1.0",
"typescript": "^5.4.5",
"vitest": "^4.0.18"
} }
} }

View file

@ -27,7 +27,7 @@ const sortBy = ref('ยอดนิยม');
const sortOptions = ['ยอดนิยม', 'ล่าสุด', 'ราคาต่ำ-สูงสุด', 'ราคาสูง-ต่ำสุด']; const sortOptions = ['ยอดนิยม', 'ล่าสุด', 'ราคาต่ำ-สูงสุด', 'ราคาสูง-ต่ำสุด'];
const categories = ref<any[]>([]); const categories = ref<any[]>([]);
const courses = ref<any[]>([]); const allCourses = ref<any[]>([]); // client-side
const selectedCourse = ref<any>(null); const selectedCourse = ref<any>(null);
const isLoading = ref(false); const isLoading = ref(false);
@ -76,20 +76,17 @@ const loadCategories = async () => {
if (res.success) categories.value = res.data || []; if (res.success) categories.value = res.data || [];
}; };
const loadCourses = async (page = 1) => { const loadCourses = async () => {
isLoading.value = true; isLoading.value = true;
const categoryId = activeCategory.value === 'all' ? undefined : activeCategory.value as number;
// (limit client-side filter)
const res = await fetchCourses({ const res = await fetchCourses({
category_id: categoryId, limit: 500,
search: searchQuery.value,
page: page,
limit: itemsPerPage,
forceRefresh: true, forceRefresh: true,
}); });
if (res.success) { if (res.success) {
courses.value = (res.data || []).map(c => { allCourses.value = (res.data || []).map(c => {
const cat = categories.value.find(cat => cat.id === c.category_id); const cat = categories.value.find(cat => cat.id === c.category_id);
return { return {
...c, ...c,
@ -100,12 +97,33 @@ const loadCourses = async (page = 1) => {
reviews_count: c.total_lessons ? c.total_lessons * 123 : Math.floor(Math.random() * 2000) + 100 reviews_count: c.total_lessons ? c.total_lessons * 123 : Math.floor(Math.random() * 2000) + 100
} }
}); });
totalPages.value = res.totalPages || 1;
currentPage.value = res.page || 1;
} }
isLoading.value = false; isLoading.value = false;
}; };
// Computed: real-time searchQuery + activeCategory
const filteredCourses = computed(() => {
let result = allCourses.value;
//
if (activeCategory.value !== 'all') {
result = result.filter(c => c.category_id === activeCategory.value);
}
// ( th en)
if (searchQuery.value.trim()) {
const query = searchQuery.value.trim().toLowerCase();
result = result.filter(c => {
const titleTh = (c.title?.th || '').toLowerCase();
const titleEn = (c.title?.en || '').toLowerCase();
const titleStr = (typeof c.title === 'string' ? c.title : '').toLowerCase();
return titleTh.includes(query) || titleEn.includes(query) || titleStr.includes(query);
});
}
return result;
});
const selectCourse = async (id: number) => { const selectCourse = async (id: number) => {
isLoadingDetail.value = true; isLoadingDetail.value = true;
selectedCourse.value = null; selectedCourse.value = null;
@ -137,10 +155,10 @@ watch(
activeCategory, activeCategory,
() => { () => {
currentPage.value = 1; currentPage.value = 1;
loadCourses(1);
} }
); );
onMounted(async () => { onMounted(async () => {
await loadCategories(); await loadCategories();
@ -150,7 +168,7 @@ onMounted(async () => {
activeCategory.value = Number(route.query.category_id); activeCategory.value = Number(route.query.category_id);
} }
await loadCourses(1); await loadCourses();
if (route.query.course_id) { if (route.query.course_id) {
selectCourse(Number(route.query.course_id)); selectCourse(Number(route.query.course_id));
@ -162,19 +180,19 @@ onMounted(async () => {
<div class="bg-[#F8F9FA] dark:bg-[#020617] min-h-screen p-4 md:p-8 transition-colors duration-300"> <div class="bg-[#F8F9FA] dark:bg-[#020617] min-h-screen p-4 md:p-8 transition-colors duration-300">
<div class="max-w-[1240px] mx-auto"> <div class="max-w-[1240px] mx-auto">
<!-- วนของการคนหาคอร (Catalog View) --> <!-- วนของการคนหาคอร (Catalog View) -->
<div v-if="!showDetail" class="bg-white dark:bg-slate-900 rounded-[2rem] p-6 md:p-8 shadow-[0_2px_15px_rgb(0,0,0,0.02)] border border-slate-100 dark:border-slate-800 min-h-[500px] mb-12"> <div v-if="!showDetail" class="bg-white dark:!bg-slate-900 rounded-[2rem] p-6 md:p-8 shadow-[0_2px_15px_rgb(0,0,0,0.02)] border border-slate-100 dark:!border-slate-800 min-h-[500px] mb-12 transition-colors">
<!-- วนหวและการคนหา --> <!-- วนหวและการคนหา -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8"> <div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
<h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-white tracking-tight">คอรสเรยนทงหมด</h2> <h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-[#f8fafc] tracking-tight">{{ $t('discovery.title') }}</h2>
<div class="flex flex-wrap sm:flex-nowrap items-center gap-3 w-full md:w-auto"> <div class="flex flex-wrap sm:flex-nowrap items-center gap-3 w-full md:w-auto">
<div class="relative w-full sm:w-[260px] flex-1"> <div class="relative w-full sm:w-[260px] flex-1">
<q-icon name="search" size="18px" class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-[#3B6BE8]" /> <q-icon name="search" size="18px" class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" />
<input v-model="searchQuery" @keyup.enter="loadCourses(1)" class="w-full bg-slate-100 dark:bg-slate-800 border-none rounded-xl py-2.5 pl-11 pr-4 text-sm font-medium text-slate-700 dark:text-slate-200 placeholder:text-slate-400 focus:ring-2 focus:ring-blue-500/20 outline-none transition-all shadow-sm" placeholder="ค้นหาคอร์ส..." /> <input v-model="searchQuery" class="w-full bg-slate-100 dark:bg-slate-800 border-none rounded-xl py-2.5 pl-11 pr-4 text-sm font-medium text-slate-700 dark:text-slate-200 placeholder:text-slate-400 focus:ring-2 focus:ring-blue-500/20 outline-none transition-all shadow-sm" :placeholder="$t('discovery.searchPlaceholder')" />
</div> </div>
<div class="flex items-center gap-2 shrink-0"> <div class="flex items-center gap-2 shrink-0">
<button @click="viewMode = 'grid'" :class="viewMode === 'grid' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-[#3B6BE8]' : 'bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700 text-slate-400 hover:bg-slate-50'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="grid_view" size="20px" /></button> <button @click="viewMode = 'grid'" :class="viewMode === 'grid' ? 'bg-[#E9EFFD] dark:!bg-blue-900/40 text-[#3B6BE8] dark:!text-blue-400 border-[#3B6BE8] dark:!border-blue-400' : 'bg-white border-slate-200 dark:!bg-slate-800 dark:!border-slate-700 text-slate-400 dark:!text-slate-300 hover:bg-slate-50 dark:hover:!bg-slate-700'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="grid_view" size="20px" /></button>
<button @click="viewMode = 'list'" :class="viewMode === 'list' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-[#3B6BE8]' : 'bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700 text-slate-400 hover:bg-slate-50'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="view_list" size="20px" /></button> <button @click="viewMode = 'list'" :class="viewMode === 'list' ? 'bg-[#E9EFFD] dark:!bg-blue-900/40 text-[#3B6BE8] dark:!text-blue-400 border-[#3B6BE8] dark:!border-blue-400' : 'bg-white border-slate-200 dark:!bg-slate-800 dark:!border-slate-700 text-slate-400 dark:!text-slate-300 hover:bg-slate-50 dark:hover:!bg-slate-700'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="view_list" size="20px" /></button>
</div> </div>
</div> </div>
</div> </div>
@ -185,17 +203,17 @@ onMounted(async () => {
<div class="flex flex-wrap items-center gap-3 w-full xl:w-auto"> <div class="flex flex-wrap items-center gap-3 w-full xl:w-auto">
<button <button
@click="activeCategory = 'all'" @click="activeCategory = 'all'"
:class="activeCategory === 'all' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-transparent font-bold' : 'bg-white dark:bg-transparent border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-300 hover:border-slate-300 font-medium'" :class="activeCategory === 'all' ? 'bg-[#E9EFFD] dark:!bg-blue-900/40 text-[#3B6BE8] dark:!text-blue-400 border-transparent font-bold' : 'bg-white dark:!bg-slate-800 border-slate-200 dark:!border-slate-700 text-slate-800 dark:!text-slate-200 hover:border-slate-300 dark:hover:!border-slate-600 font-medium'"
class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none"> class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none">
<q-icon name="check_circle_outline" size="18px" :class="activeCategory === 'all' ? 'text-[#3B6BE8]' : 'text-slate-400'"/> งหมด <q-icon name="check_circle_outline" size="18px" :class="activeCategory === 'all' ? 'text-[#3B6BE8] dark:!text-blue-400' : 'text-slate-400 dark:!text-slate-400'"/> {{ $t('discovery.allCategory') }}
</button> </button>
<button <button
v-for="cat in categories" :key="cat.id" v-for="cat in categories" :key="cat.id"
@click="activeCategory = cat.id" @click="activeCategory = cat.id"
:class="activeCategory === cat.id ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-transparent font-bold' : 'bg-white dark:bg-transparent border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-300 hover:border-slate-300 font-medium'" :class="activeCategory === cat.id ? 'bg-[#E9EFFD] dark:!bg-blue-900/40 text-[#3B6BE8] dark:!text-blue-400 border-transparent font-bold' : 'bg-white dark:!bg-slate-800 border-slate-200 dark:!border-slate-700 text-slate-800 dark:!text-slate-200 hover:border-slate-300 dark:hover:!border-slate-600 font-medium'"
class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none bg-transparent"> class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none">
<q-icon :name="getCategoryIcon(cat.name)" size="18px" :class="activeCategory === cat.id ? 'text-[#3B6BE8]' : 'text-slate-600 dark:text-slate-400'"/> <q-icon :name="getCategoryIcon(cat.name)" size="18px" :class="activeCategory === cat.id ? 'text-[#3B6BE8] dark:!text-blue-400' : 'text-slate-600 dark:!text-slate-400'"/>
{{ getLocalizedText(cat.name) }} {{ getLocalizedText(cat.name) }}
</button> </button>
</div> </div>
@ -208,10 +226,10 @@ onMounted(async () => {
<q-spinner size="3rem" color="primary" /> <q-spinner size="3rem" color="primary" />
</div> </div>
<div v-else-if="courses.length > 0"> <div v-else-if="filteredCourses.length > 0">
<!-- GRID VIEW --> <!-- GRID VIEW -->
<div v-if="viewMode === 'grid'" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> <div v-if="viewMode === 'grid'" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<div v-for="course in courses" :key="course.id" class="flex flex-col rounded-[1.5rem] bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 overflow-hidden shadow-[0_2px_10px_rgb(0,0,0,0.03)] hover:shadow-[0_8px_30px_rgb(0,0,0,0.08)] transition-all duration-300 group cursor-pointer" @click="selectCourse(course.id)"> <div v-for="course in filteredCourses" :key="course.id" class="flex flex-col rounded-[1.5rem] bg-white dark:!bg-slate-900 border border-slate-100 dark:!border-slate-800 overflow-hidden shadow-[0_2px_10px_rgb(0,0,0,0.03)] hover:shadow-[0_8px_30px_rgb(0,0,0,0.08)] transition-all duration-300 group cursor-pointer" @click="selectCourse(course.id)">
<!-- Thumbnail --> <!-- Thumbnail -->
<div class="relative w-full aspect-[16/10] bg-slate-100 dark:bg-slate-800 overflow-hidden"> <div class="relative w-full aspect-[16/10] bg-slate-100 dark:bg-slate-800 overflow-hidden">
<img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" /> <img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
@ -222,7 +240,7 @@ onMounted(async () => {
<!-- Body --> <!-- Body -->
<div class="p-5 flex flex-col flex-1"> <div class="p-5 flex flex-col flex-1">
<h3 class="font-bold text-slate-900 dark:text-white text-[15px] leading-snug line-clamp-2 mb-2">{{ getLocalizedText(course.title) }}</h3> <h3 class="font-bold text-slate-900 dark:text-[#f8fafc] text-[15px] leading-snug line-clamp-2 mb-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">{{ getLocalizedText(course.title) }}</h3>
@ -230,8 +248,7 @@ onMounted(async () => {
<div class="font-[900] text-[18px]" :class="course.is_free ? 'text-green-500' : 'text-[#2563EB] dark:text-blue-400'"> <div class="font-[900] text-[18px]" :class="course.is_free ? 'text-green-500' : 'text-[#2563EB] dark:text-blue-400'">
{{ course.formatted_price }} {{ course.formatted_price }}
</div> </div>
<!-- Eye icon circle button --> <button class="w-[38px] h-[38px] rounded-full bg-slate-50 dark:!bg-slate-800 text-slate-400 dark:!text-slate-300 flex items-center justify-center hover:bg-blue-50 hover:text-blue-600 dark:hover:!bg-slate-700 dark:hover:!text-blue-400 border border-slate-100 dark:!border-slate-700 transition-colors shadow-sm outline-none">
<button class="w-[38px] h-[38px] rounded-full bg-slate-50 dark:bg-slate-800 text-slate-400 dark:text-slate-500 flex items-center justify-center hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-slate-700 border border-slate-100 dark:border-slate-700 transition-colors shadow-sm outline-none">
<q-icon name="visibility" size="18px" /> <q-icon name="visibility" size="18px" />
</button> </button>
</div> </div>
@ -241,7 +258,7 @@ onMounted(async () => {
<!-- LIST VIEW --> <!-- LIST VIEW -->
<div v-else class="flex flex-col gap-5"> <div v-else class="flex flex-col gap-5">
<div v-for="course in courses" :key="course.id" class="flex flex-col sm:flex-row rounded-[1.5rem] bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 p-3 sm:p-4 gap-4 sm:gap-6 shadow-sm hover:shadow-[0_8px_30px_rgb(0,0,0,0.06)] transition-all duration-300 group cursor-pointer" @click="selectCourse(course.id)"> <div v-for="course in filteredCourses" :key="course.id" class="flex flex-col sm:flex-row rounded-[1.5rem] bg-white dark:!bg-slate-900 border border-slate-100 dark:!border-slate-800 p-3 sm:p-4 gap-4 sm:gap-6 shadow-sm hover:shadow-[0_8px_30px_rgb(0,0,0,0.06)] transition-all duration-300 group cursor-pointer" @click="selectCourse(course.id)">
<div class="relative w-full sm:w-[260px] aspect-[16/10] sm:aspect-auto sm:h-[160px] rounded-[1rem] bg-slate-100 dark:bg-slate-800 overflow-hidden shrink-0"> <div class="relative w-full sm:w-[260px] aspect-[16/10] sm:aspect-auto sm:h-[160px] rounded-[1rem] bg-slate-100 dark:bg-slate-800 overflow-hidden shrink-0">
<img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" /> <img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
<div v-if="course.category_name" class="absolute top-3 left-3 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md text-[#3B6BE8] dark:text-blue-400 font-bold text-[10px] px-3.5 py-1.5 rounded-full shadow-sm" style="border-radius: 9999px; padding: 4px 12px;"> <div v-if="course.category_name" class="absolute top-3 left-3 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md text-[#3B6BE8] dark:text-blue-400 font-bold text-[10px] px-3.5 py-1.5 rounded-full shadow-sm" style="border-radius: 9999px; padding: 4px 12px;">
@ -250,15 +267,15 @@ onMounted(async () => {
</div> </div>
<div class="flex flex-col flex-1 py-1"> <div class="flex flex-col flex-1 py-1">
<div class="flex-1"> <div class="flex-1">
<h3 class="font-bold text-slate-900 dark:text-white text-[16px] md:text-[18px] leading-snug line-clamp-2 md:line-clamp-1 mb-2">{{ getLocalizedText(course.title) }}</h3> <h3 class="font-bold text-slate-900 dark:text-[#f8fafc] text-[16px] md:text-[18px] leading-snug line-clamp-2 md:line-clamp-1 mb-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">{{ getLocalizedText(course.title) }}</h3>
</div> </div>
<div class="mt-4 sm:mt-auto flex items-center justify-between"> <div class="mt-4 sm:mt-auto flex items-center justify-between">
<div class="font-[900] text-[20px]" :class="course.is_free ? 'text-green-500' : 'text-[#2563EB] dark:text-blue-400'"> <div class="font-[900] text-[20px]" :class="course.is_free ? 'text-green-500' : 'text-[#2563EB] dark:text-blue-400'">
{{ course.formatted_price }} {{ course.formatted_price }}
</div> </div>
<button class="px-6 py-2 rounded-full bg-slate-50 text-slate-600 dark:bg-slate-800 dark:text-slate-300 font-bold text-[13px] flex items-center gap-2 hover:bg-blue-50 border border-slate-100 dark:border-slate-700 hover:text-blue-600 transition-colors"> <button class="px-6 py-2 rounded-full bg-slate-50 text-slate-600 dark:!bg-slate-800 dark:!text-slate-300 font-bold text-[13px] flex items-center gap-2 hover:bg-blue-50 hover:text-blue-600 dark:hover:!bg-slate-700 dark:hover:!text-blue-400 border border-slate-100 dark:!border-slate-700 transition-colors">
<q-icon name="visibility" size="16px" /> รายละเอยด <q-icon name="visibility" size="16px" /> {{ $t('discovery.viewDetails') }}
</button> </button>
</div> </div>
</div> </div>
@ -272,9 +289,9 @@ onMounted(async () => {
</div> </div>
<!-- Empty State --> <!-- Empty State -->
<div v-else class="flex flex-col items-center justify-center py-20 bg-white dark:bg-slate-900/40 rounded-3xl border border-dashed border-slate-200 dark:border-slate-800"> <div v-else class="flex flex-col items-center justify-center py-20 bg-white dark:!bg-slate-900/50 rounded-3xl border border-dashed border-slate-200 dark:!border-slate-800">
<q-icon name="search_off" size="64px" class="text-slate-300 dark:text-slate-600 mb-4" /> <q-icon name="search_off" size="64px" class="text-slate-300 dark:text-slate-600 mb-4" />
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">{{ $t("discovery.emptyTitle") }}</h3> <h3 class="text-xl font-bold text-slate-900 dark:!text-white mb-2">{{ $t("discovery.emptyTitle") }}</h3>
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md">{{ $t("discovery.emptyDesc") }}</p> <p class="text-slate-500 dark:text-slate-400 text-center max-w-md">{{ $t("discovery.emptyDesc") }}</p>
<button class="mt-6 font-bold text-blue-600 hover:text-blue-700 transition-colors" @click="searchQuery = ''; activeCategory = 'all';"> <button class="mt-6 font-bold text-blue-600 hover:text-blue-700 transition-colors" @click="searchQuery = ''; activeCategory = 'all';">
{{ $t("discovery.showAll") }} {{ $t("discovery.showAll") }}

View file

@ -13,6 +13,7 @@ useHead({
title: 'คอร์สทั้งหมด - E-Learning System' title: 'คอร์สทั้งหมด - E-Learning System'
}) })
const { t } = useI18n()
const searchQuery = ref('') const searchQuery = ref('')
const { fetchCourses } = useCourse() const { fetchCourses } = useCourse()
const { fetchCategories, categories } = useCategory() const { fetchCategories, categories } = useCategory()
@ -54,7 +55,7 @@ await useAsyncData('categories-list', () => fetchCategories())
const { data: coursesResponse, pending: isLoading, error, refresh } = await useAsyncData( const { data: coursesResponse, pending: isLoading, error, refresh } = await useAsyncData(
'browse-courses-list', 'browse-courses-list',
() => { () => {
const params: any = {} const params: any = { limit: 500 }
if (selectedCategory.value !== 'all') { if (selectedCategory.value !== 'all') {
const category = categories.value.find(c => c.slug === selectedCategory.value) const category = categories.value.find(c => c.slug === selectedCategory.value)
if (category) { if (category) {
@ -131,11 +132,11 @@ const viewMode = ref<'grid' | 'list'>('grid')
<!-- วนหวและการคนหา --> <!-- วนหวและการคนหา -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8"> <div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
<h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-white tracking-tight">คอรสเรยนทงหมด</h2> <h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-white tracking-tight">{{ $t('discovery.title') }}</h2>
<div class="flex flex-wrap sm:flex-nowrap items-center gap-3 w-full md:w-auto"> <div class="flex flex-wrap sm:flex-nowrap items-center gap-3 w-full md:w-auto">
<div class="relative w-full sm:w-[260px] flex-1"> <div class="relative w-full sm:w-[260px] flex-1">
<q-icon name="search" size="18px" class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-[#3B6BE8]" /> <q-icon name="search" size="18px" class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-[#3B6BE8]" />
<input v-model="searchQuery" class="w-full bg-slate-100 dark:bg-slate-800 border-none rounded-xl py-2.5 pl-11 pr-4 text-sm font-medium text-slate-700 dark:text-slate-200 placeholder:text-slate-400 focus:ring-2 focus:ring-blue-500/20 outline-none transition-all shadow-sm" placeholder="ค้นหาคอร์ส..." /> <input v-model="searchQuery" class="w-full bg-slate-100 dark:bg-slate-800 border-none rounded-xl py-2.5 pl-11 pr-4 text-sm font-medium text-slate-700 dark:text-slate-200 placeholder:text-slate-400 focus:ring-2 focus:ring-blue-500/20 outline-none transition-all shadow-sm" :placeholder="$t('discovery.searchPlaceholder')" />
</div> </div>
<div class="flex items-center gap-2 shrink-0"> <div class="flex items-center gap-2 shrink-0">
<button @click="viewMode = 'grid'" :class="viewMode === 'grid' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-[#3B6BE8]' : 'bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700 text-slate-400 hover:bg-slate-50'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="grid_view" size="20px" /></button> <button @click="viewMode = 'grid'" :class="viewMode === 'grid' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-[#3B6BE8]' : 'bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700 text-slate-400 hover:bg-slate-50'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="grid_view" size="20px" /></button>
@ -151,7 +152,7 @@ const viewMode = ref<'grid' | 'list'>('grid')
@click="selectCategory('all')" @click="selectCategory('all')"
:class="selectedCategory === 'all' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-transparent font-bold' : 'bg-white dark:bg-transparent border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-300 hover:border-slate-300 font-medium'" :class="selectedCategory === 'all' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-transparent font-bold' : 'bg-white dark:bg-transparent border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-300 hover:border-slate-300 font-medium'"
class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none"> class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none">
<q-icon name="check_circle_outline" size="18px" :class="selectedCategory === 'all' ? 'text-[#3B6BE8]' : 'text-slate-400'"/> งหมด <q-icon name="check_circle_outline" size="18px" :class="selectedCategory === 'all' ? 'text-[#3B6BE8]' : 'text-slate-400'"/> {{ $t('discovery.allCategory') }}
</button> </button>
<button <button
@ -187,13 +188,13 @@ const viewMode = ref<'grid' | 'list'>('grid')
<h3 class="font-bold text-slate-900 dark:text-white text-[15px] leading-snug line-clamp-2 mb-2 group-hover:text-blue-600 transition-colors">{{ getLocalizedText(course.title) }}</h3> <h3 class="font-bold text-slate-900 dark:text-white text-[15px] leading-snug line-clamp-2 mb-2 group-hover:text-blue-600 transition-colors">{{ getLocalizedText(course.title) }}</h3>
<div class="flex items-center gap-2 mb-4"> <div class="flex items-center gap-2 mb-4">
<span class="text-[12px] text-slate-500 font-medium">โดย {{ course.instructor_name }}</span> <span class="text-[12px] text-slate-500 font-medium">{{ $t('discovery.byInstructor') }} {{ course.instructor_name }}</span>
</div> </div>
<div class="flex items-center gap-1.5 mb-5"> <div class="flex items-center gap-1.5 mb-5">
<q-icon name="star" class="text-amber-400" size="16px" /> <q-icon name="star" class="text-amber-400" size="16px" />
<span class="text-[13px] font-bold text-slate-800 dark:text-slate-200">{{ course.rating }}</span> <span class="text-[13px] font-bold text-slate-800 dark:text-slate-200">{{ course.rating }}</span>
<span class="text-[12px] text-slate-400">({{ course.reviews_count.toLocaleString() }} กเรยน)</span> <span class="text-[12px] text-slate-400">({{ course.reviews_count.toLocaleString() }} {{ $t('discovery.students') }})</span>
</div> </div>
<div class="mt-auto flex items-center justify-between"> <div class="mt-auto flex items-center justify-between">
@ -222,12 +223,12 @@ const viewMode = ref<'grid' | 'list'>('grid')
<div class="flex-1"> <div class="flex-1">
<h3 class="font-bold text-slate-900 dark:text-white text-[16px] md:text-[18px] leading-snug line-clamp-2 md:line-clamp-1 mb-2 group-hover:text-blue-600 transition-colors">{{ getLocalizedText(course.title) }}</h3> <h3 class="font-bold text-slate-900 dark:text-white text-[16px] md:text-[18px] leading-snug line-clamp-2 md:line-clamp-1 mb-2 group-hover:text-blue-600 transition-colors">{{ getLocalizedText(course.title) }}</h3>
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">
<span class="text-[13px] text-slate-500 font-medium">โดย {{ course.instructor_name }}</span> <span class="text-[13px] text-slate-500 font-medium">{{ $t('discovery.byInstructor') }} {{ course.instructor_name }}</span>
</div> </div>
<div class="flex items-center gap-1.5 mb-2"> <div class="flex items-center gap-1.5 mb-2">
<q-icon name="star" class="text-amber-400" size="16px" /> <q-icon name="star" class="text-amber-400" size="16px" />
<span class="text-[13px] font-bold text-slate-800 dark:text-slate-200">{{ course.rating }}</span> <span class="text-[13px] font-bold text-slate-800 dark:text-slate-200">{{ course.rating }}</span>
<span class="text-[12px] text-slate-400">({{ course.reviews_count.toLocaleString() }} กเรยน)</span> <span class="text-[12px] text-slate-400">({{ course.reviews_count.toLocaleString() }} {{ $t('discovery.students') }})</span>
</div> </div>
</div> </div>
<div class="mt-4 sm:mt-auto flex items-center justify-between"> <div class="mt-4 sm:mt-auto flex items-center justify-between">
@ -235,7 +236,7 @@ const viewMode = ref<'grid' | 'list'>('grid')
{{ course.formatted_price }} {{ course.formatted_price }}
</div> </div>
<button class="px-6 py-2 rounded-full bg-slate-50 text-slate-600 dark:bg-slate-800 dark:text-slate-300 font-bold text-[13px] flex items-center gap-2 hover:bg-blue-50 border border-slate-100 dark:border-slate-700 hover:text-blue-600 transition-colors"> <button class="px-6 py-2 rounded-full bg-slate-50 text-slate-600 dark:bg-slate-800 dark:text-slate-300 font-bold text-[13px] flex items-center gap-2 hover:bg-blue-50 border border-slate-100 dark:border-slate-700 hover:text-blue-600 transition-colors">
<q-icon name="visibility" size="16px" /> รายละเอยด <q-icon name="visibility" size="16px" /> {{ $t('discovery.viewDetails') }}
</button> </button>
</div> </div>
</div> </div>
@ -246,10 +247,10 @@ const viewMode = ref<'grid' | 'list'>('grid')
<!-- กรณไมพบขอมลคอร (Empty State) --> <!-- กรณไมพบขอมลคอร (Empty State) -->
<div v-else class="flex flex-col items-center justify-center py-20 bg-white dark:bg-slate-900/40 rounded-3xl border border-dashed border-slate-200 dark:border-slate-800"> <div v-else class="flex flex-col items-center justify-center py-20 bg-white dark:bg-slate-900/40 rounded-3xl border border-dashed border-slate-200 dark:border-slate-800">
<q-icon name="search_off" size="64px" class="text-slate-300 dark:text-slate-600 mb-4" /> <q-icon name="search_off" size="64px" class="text-slate-300 dark:text-slate-600 mb-4" />
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">{{ searchQuery ? 'ไม่พบคอร์สที่คุณค้นหา' : 'ไม่มีคอร์สในหมวดหมู่นี้' }}</h3> <h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">{{ $t('discovery.emptyTitle') }}</h3>
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md">ลองใชคำคนหาอ หรอเลอกหมวดหมนเพอดคอรสทเรามใหบรการ</p> <p class="text-slate-500 dark:text-slate-400 text-center max-w-md">{{ $t('discovery.emptyDesc') }}</p>
<button class="mt-6 font-bold text-blue-600 hover:text-blue-700 transition-colors" @click="searchQuery = ''; selectedCategory = 'all';"> <button class="mt-6 font-bold text-blue-600 hover:text-blue-700 transition-colors" @click="searchQuery = ''; selectedCategory = 'all';">
แสดงคอรสทงหมด {{ $t('discovery.showAll') }}
</button> </button>
</div> </div>
</div> </div>

View file

@ -36,9 +36,13 @@ const userAnswers = ref<Record<number, number>>({}) // ID คำถาม -> ID
const visitedQuestions = ref<Set<number>>(new Set()) // (Track visited indices) const visitedQuestions = ref<Set<number>>(new Set()) // (Track visited indices)
const quizResult = ref<any>(null) const quizResult = ref<any>(null)
const questionPageSize = 10
const questionPage = ref(0)
// (Tracking visited questions) // (Tracking visited questions)
watch(currentQuestionIndex, (newVal) => { watch(currentQuestionIndex, (newVal) => {
visitedQuestions.value.add(newVal) visitedQuestions.value.add(newVal)
questionPage.value = Math.floor(newVal / questionPageSize)
}, { immediate: true }) }, { immediate: true })
// : (Helper: Get Status Color Class) // : (Helper: Get Status Color Class)
@ -93,6 +97,29 @@ const jumpToQuestion = (targetIndex: number) => {
currentQuestionIndex.value = targetIndex currentQuestionIndex.value = targetIndex
} }
const totalQuestionPages = computed(() => Math.ceil(totalQuestions.value / questionPageSize))
const visibleQuestions = computed(() => {
if (!quizData.value?.questions) return []
const start = questionPage.value * questionPageSize
return quizData.value.questions.slice(start, start + questionPageSize).map((q: any, i: number) => ({
...q,
originalIndex: start + i
}))
})
const nextQuestionPage = () => {
if (questionPage.value < totalQuestionPages.value - 1) {
questionPage.value++
}
}
const prevQuestionPage = () => {
if (questionPage.value > 0) {
questionPage.value--
}
}
// Computed (Computed Properties) // Computed (Computed Properties)
const currentQuestion = computed(() => { const currentQuestion = computed(() => {
if (!quizData.value || !quizData.value.questions) return null if (!quizData.value || !quizData.value.questions) return null
@ -490,15 +517,39 @@ const getCorrectChoiceId = (questionId: number) => {
</div> </div>
<!-- แผนทคำถาม / การเปลยนหน (Question Map / Pagination) --> <!-- แผนทคำถาม / การเปลยนหน (Question Map / Pagination) -->
<div class="flex flex-wrap gap-2 mb-8 mt-4"> <div class="flex items-center justify-center gap-2 mb-8 mt-4 select-none bg-slate-50 dark:bg-[#0b121f]/50 p-2 rounded-2xl border border-slate-100 dark:border-white/5 w-fit mx-auto">
<!-- มยอนกล (หนากอนหน) -->
<button <button
v-for="(q, idx) in quizData?.questions" v-if="totalQuestionPages > 1"
:key="q.id" @click="prevQuestionPage"
@click="jumpToQuestion(Number(idx))" :disabled="questionPage === 0"
class="w-8 h-8 md:w-10 md:h-10 rounded-lg flex items-center justify-center text-xs md:text-sm font-bold transition-all border" class="w-8 h-8 md:w-10 md:h-10 rounded-lg flex items-center justify-center transition-all flex-shrink-0"
:class="getQuestionStatusClass(Number(idx), q.id)" :class="questionPage === 0 ? 'text-slate-300 dark:text-slate-600 bg-transparent cursor-not-allowed' : 'text-slate-600 dark:text-slate-300 bg-white dark:bg-[#1e293b] hover:bg-slate-200 dark:hover:bg-white/10 shadow-sm border border-slate-200 dark:border-white/10 text-lg hover:-translate-x-0.5'"
> >
{{ Number(idx) + 1 }} <q-icon name="chevron_left" />
</button>
<div class="flex flex-wrap items-center justify-center gap-2">
<button
v-for="q in visibleQuestions"
:key="q.id"
@click="jumpToQuestion(q.originalIndex)"
class="w-8 h-8 md:w-10 md:h-10 rounded-lg flex items-center justify-center text-xs md:text-sm font-bold transition-all border"
:class="getQuestionStatusClass(q.originalIndex, q.id)"
>
{{ q.originalIndex + 1 }}
</button>
</div>
<!-- มถดไป (หนาถดไป) -->
<button
v-if="totalQuestionPages > 1"
@click="nextQuestionPage"
:disabled="questionPage === totalQuestionPages - 1"
class="w-8 h-8 md:w-10 md:h-10 rounded-lg flex items-center justify-center transition-all flex-shrink-0"
:class="questionPage === totalQuestionPages - 1 ? 'text-slate-300 dark:text-slate-600 bg-slate-100 dark:bg-white/5 cursor-not-allowed' : 'text-slate-600 dark:text-slate-300 bg-white dark:bg-[#1e293b] hover:bg-slate-100 dark:hover:bg-white/10 shadow-sm border border-slate-200 dark:border-white/10 text-lg hover:translate-x-0.5'"
>
<q-icon name="chevron_right" />
</button> </button>
</div> </div>

View file

@ -270,11 +270,11 @@ const navigateToCategory = (catName: string) => {
<div class="flex w-full gap-3 mt-7 relative z-10 px-2"> <div class="flex w-full gap-3 mt-7 relative z-10 px-2">
<div class="flex-1 bg-[#F8FAFC] dark:bg-slate-800 rounded-2xl p-3.5 flex flex-col items-center justify-center transition-colors shadow-sm"> <div class="flex-1 bg-[#F8FAFC] dark:bg-slate-800 rounded-2xl p-3.5 flex flex-col items-center justify-center transition-colors shadow-sm">
<span class="text-[1.35rem] font-black text-[#3B6BE8] dark:text-blue-400 mb-1 leading-none">{{ String(enrolledCourses.length || 0).padStart(2, '0') }}</span> <span class="text-[1.35rem] font-black text-[#3B6BE8] dark:text-blue-400 mb-1 leading-none">{{ enrolledCourses.length || 0 }}</span>
<span class="text-slate-400 text-[10px] font-bold tracking-wider">{{ $t('myCourses.filterProgress') }}</span> <span class="text-slate-400 text-[10px] font-bold tracking-wider">{{ $t('sidebar.onlineCourses') }}</span>
</div> </div>
<div class="flex-1 bg-[#F8FAFC] dark:bg-slate-800 rounded-2xl p-3.5 flex flex-col items-center justify-center transition-colors shadow-sm"> <div class="flex-1 bg-[#F8FAFC] dark:bg-slate-800 rounded-2xl p-3.5 flex flex-col items-center justify-center transition-colors shadow-sm">
<span class="text-[1.35rem] font-black text-[#10B981] dark:text-emerald-400 mb-1 leading-none z-10">{{ String(enrolledCourses.filter(c => c.progress >= 100).length || 0).padStart(2, '0') }}</span> <span class="text-[1.35rem] font-black text-[#10B981] dark:text-emerald-400 mb-1 leading-none z-10">{{ enrolledCourses.filter(c => c.progress >= 100).length || 0 }}</span>
<span class="text-slate-400 text-[10px] font-bold tracking-wider z-10">{{ $t('myCourses.filterCompleted') }}</span> <span class="text-slate-400 text-[10px] font-bold tracking-wider z-10">{{ $t('myCourses.filterCompleted') }}</span>
</div> </div>
</div> </div>

View file

@ -59,6 +59,19 @@ const loadData = async () => {
} }
} }
const getCategoryIcon = (categoryName: string) => {
if (!categoryName) return 'o_category'
const text = categoryName.toLowerCase()
if (text.includes('ออกแบบ') || text.includes('design')) return 'o_palette'
if (text.includes('โปรแกรม') || text.includes('code') || text.includes('dev')) return 'o_terminal'
if (text.includes('ธุรกิจ') || text.includes('business') || text.includes('การตลาด') || text.includes('market')) return 'o_storefront'
if (text.includes('ภาษา') || text.includes('language')) return 'o_language'
if (text.includes('ข้อมูล') || text.includes('data')) return 'o_analytics'
return 'o_interests'
}
const goBrowse = (slug: string) => { const goBrowse = (slug: string) => {
navigateTo({ path: '/browse', query: { category: slug } }) navigateTo({ path: '/browse', query: { category: slug } })
} }
@ -274,7 +287,7 @@ onMounted(() => {
:class="selectedCategory === category.slug ? 'bg-blue-600 text-white border-blue-600 font-semibold' : 'bg-white border-slate-200 text-slate-700 hover:border-slate-300'" :class="selectedCategory === category.slug ? 'bg-blue-600 text-white border-blue-600 font-semibold' : 'bg-white border-slate-200 text-slate-700 hover:border-slate-300'"
@click="selectedCategory = category.slug" @click="selectedCategory = category.slug"
> >
<q-icon :name="category.icon || 'o_label'" size="20px" class="mr-1" /> <q-icon :name="getCategoryIcon(getLocalizedText(category.name))" size="20px" class="mr-1" />
{{ getLocalizedText(category.name) }} {{ getLocalizedText(category.name) }}
</button> </button>
</div> </div>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,57 @@
import { defineConfig, devices } from '@playwright/test';
/**
* @file playwright.config.ts
* @description Automated E2E Testing Playwright
*/
export default defineConfig({
// โฟลเดอร์ที่เก็บไฟล์เทส (ชี้ไปที่โฟลเดอร์ปลายทางที่เราสร้าง)
testDir: './tests/e2e',
// รันเทสแบบขนาน (พร้อมๆ กันหลายไฟล์) เพื่อให้เสร็จเร็วขึ้น
fullyParallel: true,
// หากการรันเทสบน CI/CD ล้มเหลว ให้ลองรันซ้ำ 2 ครั้ง
retries: process.env.CI ? 2 : 0,
// จำนวน Worker ที่ใช้รันเทส
workers: process.env.CI ? 1 : undefined,
// รูปแบบการแสดงผลลัพธ์ (Reporter)
reporter: 'html',
use: {
// กำหนดล่วงหน้าว่าเว็บที่เรากำลังจะพุ่งไปหาคือ URL อะไร (พอร์ต 3000 ของ Nuxt)
baseURL: 'http://localhost:3000',
// ตั้งค่าให้เก็บประวัติแบบติดตามผล (Trace) ถ้าระบบพัง จะได้กลับมาดูได้
trace: 'on-first-retry',
// ตั้งค่าเก็บรูปภาพหน้าจอเมื่อพัง (Screenshot on failure)
screenshot: 'only-on-failure'
},
// ตั้งค่าอุปกรณ์ที่ใช้ทดสอบ (Browsers)
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
// หากต้องการเทสบน Firefox หรือ Safari สามารถเปิดคอมเมนต์บรรทัดถัดไปได้เลย
// {
// name: 'firefox',
// use: { ...devices['Desktop Firefox'] },
// },
// {
// name: 'webkit',
// use: { ...devices['Desktop Safari'] },
// },
],
// (Optional) หากต้องการให้เปิด Local Server อัตโนมัติก่อนรันเทส สามารถเอาคอมเมนต์ออกได้
// webServer: {
// command: 'npm run dev',
// url: 'http://localhost:3000',
// reuseExistingServer: !process.env.CI,
// },
});

View file

@ -0,0 +1,261 @@
/**
* @file auth.spec.ts
* @description (Authentication) Login, Register, Forgot Password
*/
import { test, expect, type Page, type Locator } from '@playwright/test';
import {
BASE_URL, TEST_EMAIL, TEST_PASSWORD, TIMEOUT,
waitAppSettled, expectAnyVisible,
emailLocator, passwordLocator, loginButtonLocator,
} from './helpers';
// ---------------------------
// Helpers: Register
// ---------------------------
function regHeading(page: Page) { return page.getByRole('heading', { name: 'สร้างบัญชีผู้ใช้งาน' }); }
function regUsername(page: Page) { return page.getByRole('textbox', { name: 'username' }).first(); }
function regEmail(page: Page) { return page.getByRole('textbox', { name: 'student@example.com' }).first(); }
function regPrefix(page: Page) { return page.getByRole('combobox').first(); }
function regFirstName(page: Page) { return page.getByText(/^ชื่อ\s*\*$/).locator('..').getByRole('textbox').first(); }
function regLastName(page: Page) { return page.getByText(/^นามสกุล\s*\*$/).locator('..').getByRole('textbox').first(); }
function regPhone(page: Page) { return page.getByText(/^เบอร์โทรศัพท์\s*\*$/).locator('..').getByRole('textbox').first(); }
function regPassword(page: Page) { return page.getByText(/^รหัสผ่าน\s*\*$/).locator('..').getByRole('textbox').first(); }
function regConfirmPassword(page: Page) { return page.getByText(/^ยืนยันรหัสผ่าน\s*\*$/).locator('..').getByRole('textbox').first(); }
function regSubmit(page: Page) { return page.getByRole('button', { name: 'สร้างบัญชี' }); }
function regLoginLink(page: Page) { return page.getByRole('link', { name: 'เข้าสู่ระบบ' }); }
function regErrorBox(page: Page) {
return page.locator(['.q-field__messages', '.q-field__bottom', '.text-negative', '.q-notification', '.q-banner', '[role="alert"]'].join(', '));
}
async function pickPrefix(page: Page, value: 'นาย' | 'นาง' | 'นางสาว' = 'นาย') {
const combo = regPrefix(page);
await combo.selectOption({ label: value }).catch(async () => {
await combo.click();
await page.getByRole('option', { name: value }).click();
});
}
function uniqueUser() {
const n = Date.now().toString().slice(-6);
const rand8 = Math.floor(Math.random() * 1e8).toString().padStart(8, '0');
return {
username: `e2e_user_${n}`,
email: `e2e_${n}@example.com`,
firstName: 'ทดสอบ',
lastName: 'ระบบ',
phone: `09${rand8}`,
password: 'Admin12345!',
};
}
// ---------------------------
// Helpers: Forgot Password
// ---------------------------
const FORGOT_URL = `${BASE_URL}/auth/forgot-password`;
function forgotEmail(page: Page) { return page.locator('input[type="email"]').or(page.getByRole('textbox')).first(); }
function forgotSubmit(page: Page) { return page.getByRole('button', { name: /ส่งลิงก์รีเซ็ต/i }).first(); }
function forgotBackLink(page: Page) { return page.getByRole('link', { name: /กลับไปหน้าเข้าสู่ระบบ/i }).first(); }
// ================== TESTS ==================
test.describe('ระบบยืนยันตัวตน (Authentication)', () => {
// --- LOGIN ---
test.describe('การเข้าสู่ระบบ (Login)', () => {
test('Success Login แล้วเข้า /dashboard ได้', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await emailLocator(page).fill(TEST_EMAIL);
await passwordLocator(page).fill(TEST_PASSWORD);
await loginButtonLocator(page).click();
await page.waitForURL('**/dashboard', { timeout: TIMEOUT.LOGIN });
await waitAppSettled(page);
const dashboardEvidence = [
page.locator('.q-page-container').first(),
page.locator('.q-drawer').first(),
page.locator('img[src*="avataaars"]').first(),
page.locator('img[alt],[alt="User Avatar"]').first()
];
await expectAnyVisible(page, dashboardEvidence, TIMEOUT.PAGE_LOAD);
});
test('Invalid Email - Thai characters (พิมพ์ภาษาไทย)', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await emailLocator(page).fill('ทดสอบภาษาไทย');
await passwordLocator(page).fill(TEST_PASSWORD);
await expect(page.getByText('ห้ามใส่ภาษาไทย').first()).toBeVisible({ timeout: TIMEOUT.ELEMENT });
});
test('Invalid Email Format (อีเมลผิดรูปแบบ)', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await emailLocator(page).fill('test@domain');
await passwordLocator(page).fill(TEST_PASSWORD);
await loginButtonLocator(page).click();
await waitAppSettled(page);
await expect(
page.getByText('กรุณากรอกอีเมลให้ถูกต้อง (you@example.com)').first()
).toBeVisible({ timeout: TIMEOUT.ELEMENT });
});
test('Wrong Password (รหัสผ่านผิด หรืออีเมลไม่ถูกต้องในระบบ)', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await emailLocator(page).fill(TEST_EMAIL);
await passwordLocator(page).fill('wrong-password-123');
await loginButtonLocator(page).click();
await waitAppSettled(page);
await expect(
page.getByText('กรุณาเช็ค Email หรือ รหัสผ่านใหม่อีกครั้ง').first()
).toBeVisible({ timeout: TIMEOUT.ELEMENT });
});
});
// --- REGISTER ---
test.describe('การสมัครสมาชิก (Register)', () => {
test('หน้า Register ต้องโหลดได้ (Smoke)', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await expect(regHeading(page)).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
await expect(regSubmit(page)).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
});
test('ลิงก์ "เข้าสู่ระบบ" ต้องกดแล้วไปหน้า login ได้', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await regLoginLink(page).click();
await page.waitForURL('**/auth/login', { timeout: TIMEOUT.PAGE_LOAD });
});
test('สมัครสมาชิกสำเร็จ (Happy Path) → redirect ไป /auth/login', async ({ page }) => {
const u = uniqueUser();
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await regUsername(page).fill(u.username);
await regEmail(page).fill(u.email);
await pickPrefix(page, 'นาย');
await regFirstName(page).fill(u.firstName);
await regLastName(page).fill(u.lastName);
await regPhone(page).fill(u.phone);
await regPassword(page).fill(u.password);
await regConfirmPassword(page).fill(u.password);
await regSubmit(page).click();
await waitAppSettled(page);
// รอ 3 สัญญาณ: redirect ไป login / success toast / error
const navToLogin = page.waitForURL('**/auth/login', { timeout: TIMEOUT.LOGIN, waitUntil: 'domcontentloaded' }).then(() => 'login' as const).catch(() => null);
const successToast = page.getByText(/สมัครสมาชิกสำเร็จ|success/i, { exact: false }).first().waitFor({ state: 'visible', timeout: TIMEOUT.LOGIN }).then(() => 'success' as const).catch(() => null);
const anyError = regErrorBox(page).first().waitFor({ state: 'visible', timeout: TIMEOUT.LOGIN }).then(() => 'error' as const).catch(() => null);
const result = await Promise.race([navToLogin, successToast, anyError]);
if (result === 'error') {
const errs = await regErrorBox(page).allInnerTexts().catch(() => []);
throw new Error(`Register failed with errors: ${errs.join(' | ')}`);
}
// ถ้ามี toast แต่ยัง redirect ไม่ไป ให้ navigate เอง
if (!page.url().includes('/auth/login')) {
const hasSuccess = await page.getByText(/สมัครสมาชิกสำเร็จ/i, { exact: false }).first().isVisible().catch(() => false);
if (hasSuccess) {
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
}
}
await expect(page).toHaveURL(/\/auth\/login/i, { timeout: TIMEOUT.PAGE_LOAD });
await expect(page.getByRole('heading', { name: 'เข้าสู่ระบบ' })).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
await expect(page.getByRole('button', { name: 'เข้าสู่ระบบ' })).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
});
test('Invalid Email - ใส่ภาษาไทย ต้องขึ้น error', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await regEmail(page).fill('ทดสอบภาษาไทย');
await regUsername(page).click(); // blur trigger
const err = page
.getByText(/ห้ามใส่ภาษาไทย|อีเมลไม่ถูกต้อง|รูปแบบอีเมล/i, { exact: false })
.or(regErrorBox(page).filter({ hasText: /ไทย|อีเมล|email|invalid/i }));
await expect(err.first()).toBeVisible({ timeout: TIMEOUT.ELEMENT });
});
test('Password ไม่ตรงกัน ต้องขึ้น error (ต้องกดสร้างบัญชีก่อน)', async ({ page }) => {
const u = uniqueUser();
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await regUsername(page).fill(u.username);
await regEmail(page).fill(u.email);
await pickPrefix(page, 'นาย');
await regFirstName(page).fill(u.firstName);
await regLastName(page).fill(u.lastName);
await regPhone(page).fill(u.phone);
await regPassword(page).fill('Admin12345!');
await regConfirmPassword(page).fill('Admin12345?'); // mismatch
await regSubmit(page).click();
await waitAppSettled(page);
const mismatchErr = page
.getByText(/รหัสผ่านไม่ตรงกัน/i, { exact: false })
.or(regErrorBox(page).filter({ hasText: /รหัสผ่านไม่ตรงกัน/i }));
await expect(mismatchErr.first()).toBeVisible({ timeout: TIMEOUT.ELEMENT });
});
});
// --- FORGOT PASSWORD ---
test.describe('หน้าลืมรหัสผ่าน (Forgot Password)', () => {
test.beforeEach(async ({ page }) => {
await page.goto(FORGOT_URL, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
});
test('โหลดหน้าลืมรหัสผ่านได้ครบถ้วน (Smoke Test)', async ({ page }) => {
await expect(page.getByRole('heading', { name: /ลืมรหัสผ่าน/i })).toBeVisible();
await expect(forgotEmail(page)).toBeVisible();
await expect(forgotSubmit(page)).toBeVisible();
await expect(forgotBackLink(page)).toBeVisible();
});
test('Validation: ใส่อีเมลภาษาไทยแล้วขึ้น Error', async ({ page }) => {
await forgotEmail(page).fill('ฟฟฟฟ');
await page.getByRole('heading', { name: /ลืมรหัสผ่าน/i }).click();
await expect(page.getByText(/ห้ามใส่ภาษาไทย/i).first()).toBeVisible({ timeout: TIMEOUT.ELEMENT });
});
test('กดลิงก์กลับไปหน้า Login ได้', async ({ page }) => {
await forgotBackLink(page).click();
await page.waitForURL('**/auth/login', { timeout: TIMEOUT.ELEMENT });
await expect(page).toHaveURL(/\/auth\/login/i);
});
test('ทดลองส่งลิงก์รีเซ็ตรหัสผ่าน (API Mock)', async ({ page }) => {
await page.route('**/*', async (route) => {
const req = route.request();
const url = req.url();
const method = req.method();
const looksLikeForgotApi = method === 'POST' && /forgot|reset/i.test(url) && !/\.(png|jpg|jpeg|webp|svg|css|js|map)$/i.test(url);
if (looksLikeForgotApi) {
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: { message: 'Reset link sent' } }) });
return;
}
await route.continue();
});
await forgotEmail(page).fill('test@gmail.com');
await forgotSubmit(page).click();
await expect(page.getByText(/ส่งลิงก์เรียบร้อยแล้ว/i, { exact: false })).toBeVisible({ timeout: TIMEOUT.ELEMENT });
await expect(page.getByText(/กรุณาตรวจสอบกล่องจดหมาย/i, { exact: false })).toBeVisible();
});
});
});

View file

@ -0,0 +1,175 @@
/**
* @file classroom.spec.ts
* @description
* (Classroom, Learning & Quiz System)
*
* 2 module:
* - Classroom & Learning (Layout, Access Control, Video/Quiz area)
* - Quiz System (Start Screen, Pagination, Submit & Navigation)
*/
import { test, expect } from '@playwright/test';
import { BASE_URL, TIMEOUT, waitAppSettled, setupLogin } from './helpers';
// ==========================================
// Mock: ข้อมูล Quiz สำหรับ test
// ==========================================
async function mockQuizData(page: any) {
await page.route('**/lessons/*', async (route: any) => {
const mockQuestions = Array.from({ length: 15 }, (_, i) => ({
id: i + 1,
question: { th: `คำถามข้อที่ ${i + 1}?`, en: `Question ${i + 1}?` },
text: { th: `คำถามข้อที่ ${i + 1}?`, en: `Question ${i + 1}?` },
choices: [
{ id: i * 10 + 1, text: { th: 'ก', en: 'A' } },
{ id: i * 10 + 2, text: { th: 'ข', en: 'B' } },
{ id: i * 10 + 3, text: { th: 'ค', en: 'C' } }
]
}));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: {
id: 17,
type: 'QUIZ',
quiz: {
id: 99,
title: { th: 'แบบทดสอบปลายภาค (Mock)', en: 'Final Exam (Mock)' },
time_limit: 30,
questions: mockQuestions
}
},
progress: {}
})
});
});
}
// ==========================================
// Tests
// ==========================================
test.describe('ระบบห้องเรียนออนไลน์และแบบทดสอบ (Classroom & Quiz)', () => {
test.beforeEach(async ({ page }) => {
await setupLogin(page);
});
// --------------------------------------------------
// Section 1: ห้องเรียน (Classroom & Learning)
// --------------------------------------------------
test.describe('ห้องเรียน (Classroom Layout & Access)', () => {
test('6.1 เข้าห้องเรียนหลัก (Classroom Basic Layout)', async ({ page }) => {
await page.goto(`${BASE_URL}/classroom/learning?course_id=1`);
// 1. โครงร่างของหน้า — ปุ่มกลับ + ไอคอนแผงด้านข้าง
const backBtn = page.getByRole('button').filter({ has: page.locator('i.q-icon', { hasText: 'arrow_back' }) }).first();
await expect(backBtn).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
const menuCurriculumBtn = page.getByRole('button').filter({ has: page.locator('i.q-icon', { hasText: 'menu_open' }) }).first();
await expect(menuCurriculumBtn).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
// 2. Sidebar หลักสูตร
const sidebar = page.locator('.q-drawer').first();
if (!await sidebar.isVisible()) {
await menuCurriculumBtn.click();
}
await expect(sidebar).toBeVisible();
});
test('6.2 เช็คสถานะการเข้าถึงเนื้อหา (Access Control)', async ({ page }) => {
page.on('dialog', async dialog => {
expect(dialog.message()).toBeTruthy();
await dialog.accept();
});
await page.goto(`${BASE_URL}/classroom/learning?course_id=99999`);
const loadingMask = page.locator('.animate-pulse, .q-spinner');
await loadingMask.first().waitFor({ state: 'hidden', timeout: TIMEOUT.PAGE_LOAD }).catch(() => {});
});
test('6.3 การแสดงผลช่องวิดีโอ หรือ พื้นที่ทำข้อสอบ (Video / Quiz)', async ({ page }) => {
await page.goto(`${BASE_URL}/classroom/learning?course_id=1`);
const videoLocator = page.locator('video').first();
const quizLocator = page.getByText(/เริ่มทำแบบทดสอบ|แบบทดสอบ/i).first();
const errorLocator = page.getByText(/ไม่สามารถเข้าถึง/i).first();
try {
await Promise.race([
videoLocator.waitFor({ state: 'visible', timeout: TIMEOUT.PAGE_LOAD }),
quizLocator.waitFor({ state: 'visible', timeout: TIMEOUT.PAGE_LOAD }),
errorLocator.waitFor({ state: 'visible', timeout: TIMEOUT.PAGE_LOAD })
]);
const isOkay = (await videoLocator.isVisible()) || (await quizLocator.isVisible()) || (await errorLocator.isVisible());
expect(isOkay).toBeTruthy();
} catch {
await page.screenshot({ path: 'tests/e2e/screenshots/classroom-blank-state.png', fullPage: true });
}
});
});
// --------------------------------------------------
// Section 2: แบบทดสอบ (Quiz System)
// --------------------------------------------------
test.describe('แบบทดสอบ (Quiz System)', () => {
test('7.1 โหลดหน้า Quiz และเริ่มทำข้อสอบได้ (Start Screen)', async ({ page }) => {
await mockQuizData(page);
await page.goto(`${BASE_URL}/classroom/quiz?course_id=2&lesson_id=17`);
const startBtn = page.getByRole('button', { name: /เริ่มทำแบบทดสอบ|Start/i }).first();
await expect(startBtn).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
// กดเริ่มทำ
await startBtn.click();
// เช็คว่าหน้า Taking (คำถามข้อที่ 1) โผล่มา
const questionText = page.locator('h3').first();
await expect(questionText).toBeVisible({ timeout: TIMEOUT.ELEMENT });
});
test('7.2 แถบข้อสอบแบ่งหน้า (Pagination — เลื่อนซ้าย/ขวา)', async ({ page }) => {
await mockQuizData(page);
await page.goto(`${BASE_URL}/classroom/quiz?course_id=2&lesson_id=17`);
const startBtn = page.getByRole('button', { name: /เริ่มทำแบบทดสอบ|Start/i }).first();
await expect(startBtn).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
await startBtn.click();
// ลูกศรเลื่อนหน้าข้อสอบ
const nextPaginationPageBtn = page.locator('button').filter({ has: page.locator('i.q-icon:has-text("chevron_right")') }).first();
if (await nextPaginationPageBtn.isVisible()) {
await expect(nextPaginationPageBtn).toBeEnabled();
await nextPaginationPageBtn.click();
// ข้อที่ 11 ต้องแสดง
const question11Btn = page.locator('button').filter({ hasText: /^11$/ }).first();
await expect(question11Btn).toBeVisible();
}
});
test('7.3 การแสดงผลปุ่มถัดไป/ส่งคำตอบ (Submit & Navigation UI)', async ({ page }) => {
await mockQuizData(page);
await page.goto(`${BASE_URL}/classroom/quiz?course_id=2&lesson_id=17`);
const startBtn = page.getByRole('button', { name: /เริ่มทำแบบทดสอบ|Start/i }).first();
await expect(startBtn).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
await startBtn.click();
// รอคำถามโหลดเสร็จ
await expect(page.locator('h3').first()).toBeVisible({ timeout: TIMEOUT.ELEMENT });
const submitBtn = page.locator('button').filter({ hasText: /(ส่งคำตอบ|Submit)/i }).first();
const nextBtn = page.locator('button').filter({ hasText: /(ถัดไป|Next)/i }).first();
// ข้อแรกต้องมีปุ่มถัดไปหรือปุ่มส่ง
await expect(submitBtn.or(nextBtn)).toBeVisible({ timeout: TIMEOUT.ELEMENT });
});
});
});

View file

@ -0,0 +1,103 @@
/**
* @file discovery.spec.ts
* @description (Discovery & Browse)
*/
import { test, expect } from '@playwright/test';
import { BASE_URL, TIMEOUT, waitAppSettled } from './helpers';
test.describe('หมวดหน้าค้นหาคอร์สและผลลัพธ์ (Discovery & Browse)', () => {
test.describe('ส่วนหน้าแรก (Home)', () => {
test('โหลดหน้าแรก และตรวจสอบแสดงผลครบถ้วน (Hero, Cards, Categories)', async ({ page }) => {
await page.goto(BASE_URL);
await waitAppSettled(page);
const heroTitle = page.locator('h1, h2, .hero-title').first();
await expect(heroTitle).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
const ctaButton = page.locator('a[href="/browse"]').first();
if (await ctaButton.isVisible()) {
await expect(ctaButton).toBeVisible();
}
const courseSectionHeading = page.getByText(/คอร์สออนไลน์|Online Courses/i).first();
await expect(courseSectionHeading).toBeVisible({ timeout: TIMEOUT.ELEMENT });
const allCategoryBtn = page.getByRole('button', { name: /ทั้งหมด|All/i }).first();
await expect(allCategoryBtn).toBeVisible();
const courseCards = page.locator('div.cursor-pointer').filter({ has: page.locator('img') });
await expect(courseCards.first()).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
expect(await courseCards.count()).toBeGreaterThan(0);
await page.screenshot({ path: 'tests/e2e/screenshots/discovery-home.png', fullPage: true });
});
});
test.describe('ส่วนค้นหาและแคตตาล็อก (Browse)', () => {
test('ค้นหาหลักสูตร (Search Course)', async ({ page }) => {
await page.goto(`${BASE_URL}/browse`);
await waitAppSettled(page);
const searchInput = page.locator('input[placeholder="ค้นหาคอร์ส..."]').first();
await expect(searchInput).toBeVisible({ timeout: TIMEOUT.ELEMENT });
await searchInput.fill('Python');
await searchInput.press('Enter');
await waitAppSettled(page);
// ต้องเจออย่างใดอย่างหนึ่ง: ผลลัพธ์คอร์ส หรือ empty state
const searchResults = page.locator('a[href*="/course/"]').filter({ has: page.locator('img') }).first();
const emptyState = page.getByText(/ไม่พบ|ไม่เจอ|No result|not found/i).first()
.or(page.locator('i.q-icon').filter({ hasText: 'search_off' }).first());
await expect(searchResults.or(emptyState)).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
await page.screenshot({ path: 'tests/e2e/screenshots/discovery-search.png', fullPage: true });
});
test('ตัวกรองหมวดหมู่คอร์ส (Category Filter)', async ({ page }) => {
await page.goto(`${BASE_URL}/browse`);
await waitAppSettled(page);
const categoryButton = page.locator('button').filter({ hasText: 'การออกแบบ' }).first();
if (await categoryButton.isVisible()) {
await categoryButton.click();
const courseCard = page.locator('a[href*="/course/"]').filter({ has: page.locator('img') }).first();
await expect(courseCard).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
}
await page.screenshot({ path: 'tests/e2e/screenshots/discovery-filter.png', fullPage: true });
});
});
test.describe('หน้ารายละเอียดคอร์ส (Course Detail)', () => {
test('โหลดเนื้อหาวิชา (Curriculum) แถบรายละเอียดขึ้นปกติ', async ({ page }) => {
await page.goto(`${BASE_URL}/course/1`);
await waitAppSettled(page);
const courseTitle = page.locator('h1').first();
await expect(courseTitle).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
const curriculumTab = page.getByRole('tab', { name: /เนื้อหาวิชา|ส่วนหลักสูตร|Curriculum/i }).first();
if (await curriculumTab.isVisible()) {
await curriculumTab.click();
}
const lessonItems = page.locator('.q-expansion-item, .lesson-item, [role="listitem"]');
await expect(lessonItems.first()).toBeVisible().catch(() => {});
await page.screenshot({ path: 'tests/e2e/screenshots/discovery-curriculum.png', fullPage: true });
});
test('การแสดงผลปุ่ม เข้าเรียน/ลงทะเบียน (Enroll / Start Learning)', async ({ page }) => {
await page.goto(`${BASE_URL}/course/1`);
await waitAppSettled(page);
const enrollStartBtn = page.locator('button').filter({ hasText: /(เข้าสู่ระบบ|ลงทะเบียน|เข้าเรียน|เรียนต่อ|ซื้อ|ฟรี|Enroll|Start|Buy|Login)/i }).first();
await expect(enrollStartBtn).toBeVisible({ timeout: TIMEOUT.ELEMENT });
await page.screenshot({ path: 'tests/e2e/screenshots/discovery-enroll-btn.png', fullPage: true });
});
});
});

View file

@ -0,0 +1,129 @@
/**
* @file helpers.ts
* @description Shared E2E test helpers test
* รวม: waitAppSettled, login helpers, common locators, constants
*/
import { type Page, type Locator, expect } from '@playwright/test';
// ==========================================
// Constants
// ==========================================
export const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
export const TEST_EMAIL = 'studentedtest@example.com';
export const TEST_PASSWORD = 'admin123';
/** Timeout configs — ปรับค่าได้ที่เดียว */
export const TIMEOUT: Record<string, number> = {
/** รอหน้าโหลด */
PAGE_LOAD: 15_000,
/** รอ login + redirect */
LOGIN: 25_000,
/** รอ element แสดงผล */
ELEMENT: 12_000,
/** รอ network settle */
SETTLE: 300,
};
// ==========================================
// Wait Helpers
// ==========================================
/**
* (DOM + Network + hydration)
*/
export async function waitAppSettled(page: Page, ms = TIMEOUT.SETTLE) {
await page.waitForLoadState('domcontentloaded');
await page.waitForLoadState('networkidle').catch(() => {});
await page.waitForTimeout(ms);
}
/**
* locator array visible
* @throws locator visible timeout
*/
export async function expectAnyVisible(
page: Page,
locators: Locator[],
timeout = TIMEOUT.PAGE_LOAD
) {
const start = Date.now();
while (Date.now() - start < timeout) {
for (const loc of locators) {
try {
if (await loc.isVisible()) return;
} catch { /* locator detached / stale — ลองใหม่ */ }
}
await page.waitForTimeout(200);
}
throw new Error(
`None of the expected locators became visible within ${timeout}ms`
);
}
// ==========================================
// Login Locators
// ==========================================
export function emailLocator(page: Page): Locator {
return page
.locator('input[type="email"]')
.or(page.getByRole('textbox', { name: /อีเมล|email/i }))
.first();
}
export function passwordLocator(page: Page): Locator {
return page
.locator('input[type="password"]')
.or(page.getByRole('textbox', { name: /รหัสผ่าน|password/i }))
.first();
}
export function loginButtonLocator(page: Page): Locator {
return page
.getByRole('button', { name: /เข้าสู่ระบบ|login/i })
.or(page.locator('button[type="submit"]'))
.first();
}
// ==========================================
// Login Flow
// ==========================================
/**
* test account beforeEach tests authenticate
*
* @param page Playwright Page
* @param opts
* @param opts.assertDashboard (default: true) true assert dashboard
*
* @throws login dashboard
*/
export async function setupLogin(
page: Page,
opts: { assertDashboard?: boolean } = {}
) {
const { assertDashboard = true } = opts;
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
// กรอกข้อมูล
await emailLocator(page).fill(TEST_EMAIL);
await passwordLocator(page).fill(TEST_PASSWORD);
await loginButtonLocator(page).click();
// รอ redirect ไป dashboard
await page.waitForURL('**/dashboard', { timeout: TIMEOUT.LOGIN });
await waitAppSettled(page);
if (assertDashboard) {
// ยืนยันว่าเข้า dashboard ได้จริง
const evidence = [
page.locator('.q-page-container').first(),
page.locator('.q-drawer').first(),
page.locator('img[src*="avataaars"]').first(),
page.locator('img[alt],[alt="User Avatar"]').first(),
];
await expectAnyVisible(page, evidence, TIMEOUT.PAGE_LOAD);
}
}

View file

@ -0,0 +1,139 @@
/**
* @file student-account.spec.ts
* @description (Student Account / Portal)
*/
import { test, expect } from '@playwright/test';
import { BASE_URL, TIMEOUT, waitAppSettled, setupLogin } from './helpers';
test.describe('ระบบพื้นที่ส่วนตัวผู้เรียน (Student Account / Portal)', () => {
test.describe('การตั้งค่าและส่วนติดต่อผู้ใช้ (Settings & UI Theme)', () => {
test('เปลี่ยนภาษาการแสดงผล (Localisation/i18n)', async ({ page }) => {
await page.goto(BASE_URL);
await waitAppSettled(page);
// หาปุ่มภาษา — ถ้าไม่เจอให้ test.skip แทนที่จะผ่านเงียบๆ
const langBtn = page.getByRole('button', { name: 'Language' })
.or(page.locator('button').filter({ hasText: /TH|EN/ }))
.first();
const isLangBtnVisible = await langBtn.isVisible().catch(() => false);
if (!isLangBtnVisible) {
test.skip(true, 'Language button not found on page — skipping');
return;
}
await langBtn.click();
const englishOpt = page.locator('text=English, text=EN').first();
await englishOpt.click();
const loginLink = page.locator('a[href*="login"], button').filter({ hasText: /Log in|Sign In/i });
await expect(loginLink).toBeVisible({ timeout: TIMEOUT.ELEMENT });
await page.screenshot({ path: 'tests/e2e/screenshots/student-i18n.png', fullPage: true });
});
test('เปลี่ยนโหมดมืดสว่าง (Theme Switcher)', async ({ page }) => {
await page.goto(BASE_URL);
await waitAppSettled(page);
// หาปุ่ม Theme — ถ้าไม่เจอให้ test.skip แทนที่จะผ่านเงียบๆ
const themeBtn = page.locator('.dark-mode-toggle, button[aria-label*="theme"]').first();
const isThemeBtnVisible = await themeBtn.isVisible().catch(() => false);
if (!isThemeBtnVisible) {
test.skip(true, 'Theme toggle button not found on page — skipping');
return;
}
const htmlBefore = await page.evaluate(() => document.documentElement.className);
await themeBtn.click();
await page.waitForTimeout(500);
const htmlAfter = await page.evaluate(() => document.documentElement.className);
expect(htmlBefore).not.toEqual(htmlAfter);
await page.screenshot({ path: 'tests/e2e/screenshots/student-theme.png', fullPage: true });
});
});
test.describe('ระบบหน้าแดชบอร์ดนักเรียน (Dashboard & My Courses)', () => {
test.beforeEach(async ({ page }) => {
await setupLogin(page);
});
test('หน้าแรกของ Dashboard โหลดได้ปกติ', async ({ page }) => {
await page.goto(`${BASE_URL}/dashboard`);
await waitAppSettled(page, 1000);
const welcomeText = page.getByText(/ยินดีต้อนรับกลับ/i, { exact: false });
const profileSummary = page.locator('.q-avatar, img[alt*="Profile"], img[src*="avatar"]').first();
await expect(welcomeText.first().or(profileSummary)).toBeVisible({ timeout: TIMEOUT.ELEMENT });
await page.screenshot({ path: 'tests/e2e/screenshots/student-dashboard.png', fullPage: true });
});
test('โหลดหน้า คอร์สของฉัน (My Courses)', async ({ page }) => {
await page.goto(`${BASE_URL}/dashboard/my-courses`);
await waitAppSettled(page);
const heading = page.locator('h2').filter({ hasText: /คอร์สของฉัน|My Courses/i }).first();
await expect(heading).toBeVisible({ timeout: TIMEOUT.ELEMENT });
const searchInput = page.getByPlaceholder(/ค้นหา|Search/i).first();
await expect(searchInput).toBeVisible({ timeout: TIMEOUT.ELEMENT });
await expect(page.locator('i.q-icon').filter({ hasText: 'grid_view' }).first()).toBeVisible();
await expect(page.locator('i.q-icon').filter({ hasText: 'view_list' }).first()).toBeVisible();
await page.screenshot({ path: 'tests/e2e/screenshots/student-my-courses.png', fullPage: true });
});
test('ลองค้นหาคอร์ส (Search Input) ไม่พบข้อมูล', async ({ page }) => {
await page.goto(`${BASE_URL}/dashboard/my-courses`);
await waitAppSettled(page);
const searchInput = page.getByPlaceholder(/ค้นหา|Search/i).first();
await expect(searchInput).toBeVisible({ timeout: TIMEOUT.ELEMENT });
await searchInput.fill('คอร์สที่ไม่มีอยู่จริงแน่นอน1234');
const emptyState = page.locator('h3').filter({ hasText: /ไม่พบ|ไม่เจอ|No result/i }).first()
.or(page.locator('i.q-icon').filter({ hasText: 'search_off' }));
await expect(emptyState.first()).toBeVisible({ timeout: TIMEOUT.ELEMENT });
await page.screenshot({ path: 'tests/e2e/screenshots/student-search-empty.png', fullPage: true });
});
test('แก้ไขและบันทึกข้อมูลส่วนตัว (Edit Profile)', async ({ page }) => {
await page.goto(`${BASE_URL}/dashboard/profile`);
await waitAppSettled(page, 1000);
// หา input ชื่อ — ใช้ textbox "First Name" หรือ input[type="text"] ตัวแรก
const nameInput = page.getByRole('textbox', { name: /First Name|ชื่อ/i }).first()
.or(page.locator('input[type="text"]').first());
const isNameVisible = await nameInput.isVisible().catch(() => false);
if (!isNameVisible) {
test.skip(true, 'Profile name input not found — skipping');
return;
}
const oldName = await nameInput.inputValue();
await nameInput.clear();
await nameInput.fill(`${oldName}แก้ไข`);
// ปุ่มบันทึก — รองรับทั้งภาษาไทยและอังกฤษ
const saveBtn = page.getByRole('button', { name: /บันทึก|Save Changes|Save/i }).first();
await expect(saveBtn).toBeVisible({ timeout: TIMEOUT.ELEMENT });
await saveBtn.click();
// Toast สำเร็จ — รองรับทั้ง 2 ภาษา
const successNotify = page.getByText(/อัปเดตข้อมูลสำเร็จ|บันทึกข้อมูล|updated|saved|success/i).first();
await expect(successNotify).toBeVisible({ timeout: TIMEOUT.ELEMENT }).catch(() => {});
await page.screenshot({ path: 'tests/e2e/screenshots/student-edit-profile.png', fullPage: true });
});
});
});

View file

@ -9,8 +9,8 @@
ควบคุมการทำงานและการนำทาง (Routing) ของผู้ใช้งานแต่ละหน้า โดยจะเรียกใช้ `components/` และ `composables/` มาทำงานร่วมกัน ควบคุมการทำงานและการนำทาง (Routing) ของผู้ใช้งานแต่ละหน้า โดยจะเรียกใช้ `components/` และ `composables/` มาทำงานร่วมกัน
- **`pages/index.vue` (หน้า Landing Page):** - **`pages/index.vue` (หน้า Landing Page):**
- **หน้าที่:** หน้าแรกสุดของระบบสำหรับผู้เยี่ยมชม (Guest) นำเสนอคอร์สเด่นและจุดเด่นของระบบ - **หน้าที่:** หน้าแรกสุดของระบบสำหรับผู้เยี่ยมชม นำเสนอคอร์สเด่น รายละเอียดสถิติ และรีวิวล่าสุด โดยดึงข้อมูลจริงจาก API (Dynamic Data) ขับเคลื่อน UI ทันสมัย
- **การเชื่อมโยง:** ใช้ Layout `landing`, เชื่อมต่อ `useCourse`, `useCategory` และ components เช่น `CourseCard`, `LandingHeader`, `LandingFooter` - **การเชื่อมโยง:** ใช้ Layout `landing`, เชื่อมต่อ `useCourse`, `useCategory` และ components พื้นฐานเช่น `CourseCard`, `LandingHeader`, `LandingFooter`, `StarIcon`, และ `NuxtTime`
- **`pages/auth/` (กลุ่มหน้าเข้าสู่ระบบและสมัครสมาชิก):** - **`pages/auth/` (กลุ่มหน้าเข้าสู่ระบบและสมัครสมาชิก):**
- **`login.vue`:** หน้าเข้าสู่ระบบ (เชื่อม `useAuth` -> `login()`) - **`login.vue`:** หน้าเข้าสู่ระบบ (เชื่อม `useAuth` -> `login()`)
- **`register.vue`:** หน้าลงทะเบียนผู้ใช้ใหม่ (เชื่อม `useAuth` -> `register()`) - **`register.vue`:** หน้าลงทะเบียนผู้ใช้ใหม่ (เชื่อม `useAuth` -> `register()`)
@ -34,12 +34,12 @@
--- ---
## 2. 🧩 ตัวจัดการตรรกะและข้อมูล (Composables Directory: `composables/`) ## 2. 🧩 ตัวจัดการข้อมูล (Composables Directory: `composables/`)
เปรียบเสมือน "สมอง" ของระบบ แยกการจัดการข้อมูล (State) และการเรียก API (Fetch) ออกจากหน้าจอ (UI) เปรียบเสมือน "สมอง" ของระบบ แยกการจัดการข้อมูล (State) และการเรียก API (Fetch) ออกจากหน้าจอ (UI)
- **`useAuth.ts`:** - **`useAuth.ts`:**
- **หน้าที่:** จัดการ Login, Register, Logout, ระบบ Token (Access & Refresh), ดึง Data ของผู้ใช้ - **หน้าที่:** จัดการ Login, Register, Request Reset Password, อัปโหลดรูปโปรไฟล์ (Avatar), ระบบ Token (มีกลไก Refresh Token อัตโนมัติเมื่อพบ 401), และดึง/อัปเดตข้อมูลผู้ใช้
- **ถูกเรียกใช้โดย:** แทบทุกหน้าจอและ `middleware/auth.ts` - **ถูกเรียกใช้โดย:** แทบทุกหน้าจอและ `middleware/auth.ts`
- **`useCourse.ts`:** - **`useCourse.ts`:**
- **หน้าที่:** ศูนย์รวม API คอร์สเรียน (ดึงรายการคอร์ส, รายละเอียด, รายชื่อบทเรียน, การลงทะเบียน, ส่งความคืบหน้า, โหลดหน้าวิดีโอ) - **หน้าที่:** ศูนย์รวม API คอร์สเรียน (ดึงรายการคอร์ส, รายละเอียด, รายชื่อบทเรียน, การลงทะเบียน, ส่งความคืบหน้า, โหลดหน้าวิดีโอ)
@ -96,7 +96,7 @@
### ภาพรวมการหมุนเวียนข้อมูล (Data Flow Example) ### ภาพรวมการหมุนเวียนข้อมูล (Data Flow Example)
1. **ล็อกอิน:** หน้า `login.vue` กดยืนยัน -> เรียก `useAuth.login()` -> ได้ Token และ Role ยัดลง Local Cache (Cookie) 1. **ล็อกอินและจัดการ Session:** หน้า `login.vue` กดยืนยัน -> เรียก `useAuth.login()` -> ได้ Token และรายละเอียดผู้ใช้ยัดลง Cookie -> _หากดึงโปรไฟล์แล้ว API หมดอายุ (401)_ ระบบจะเรียก `refreshAccessToken()` อัตโนมัติเพื่อใช้ต่อ
2. **เข้าห้องเรียน:** หน้า `index.vue` กดคอร์ส -> ส่งเข้า `middleware/auth.ts` เช็คผ่านเข้าหน้าเรียน -> `pages/course/[id].vue` เรียก `useCourse.fetchCourseById(id)` -> ส่งข้อมูลให้ UI 2. **เข้าห้องเรียน:** หน้า `index.vue` กดคอร์ส -> ส่งเข้า `middleware/auth.ts` เช็คผ่านเข้าหน้าเรียน -> `pages/course/[id].vue` เรียก `useCourse.fetchCourseById(id)` -> ส่งข้อมูลให้ UI
3. **เปิดคลิปเรียน:** `learning.vue` ส่ง URL ให้ `<VideoPlayer />` เล่น 3. **เปิดคลิปเรียน:** `learning.vue` ส่ง URL ให้ `<VideoPlayer />` เล่น
4. **บันทึกเวลาเรียน:** `VideoPlayer` เฝ้าดูเวลา (TimeUpdate) -> รายงานตัวเลขกลับมายังฟังก์ชัน `handleVideoTimeUpdate()` ที่อยู่ใน `learning.vue` -> เรียก `useCourse.saveVideoProgress()` ส่ง API กลับ Database 4. **บันทึกเวลาเรียน:** `VideoPlayer` เฝ้าดูเวลา (TimeUpdate) -> รายงานตัวเลขกลับมายังบัญชีผู้ใช้ใน `learning.vue` -> เรียก `useCourse.saveVideoProgress()` ส่ง API กลับ Database พร้อมเช็คสถานะเนื้อหาว่าสำเร็จหรือไม่

View file

@ -3,7 +3,7 @@
เอกสารฉบับนี้สรุปรายละเอียดทางเทคนิค โครงสร้างโค้ด และกลไกการทำงานของระบบ **Frontend-Learner (ฝั่งผู้เรียน)** เอกสารฉบับนี้สรุปรายละเอียดทางเทคนิค โครงสร้างโค้ด และกลไกการทำงานของระบบ **Frontend-Learner (ฝั่งผู้เรียน)**
ใช้เป็นคู่มือสำหรับการพัฒนา บำรุงรักษา และขยายระบบต่อไป ใช้เป็นคู่มือสำหรับการพัฒนา บำรุงรักษา และขยายระบบต่อไป
> อัปเดตล่าสุด: 27 กุมภาพันธ์ 2026 (อัปเดตคอมเมนต์ภาษาไทย & แก้ไข TypeScript) > อัปเดตล่าสุด: 2 มีนาคม 2026 (อัปเดตระบบแปลภาษา UI & ปรับปรุงหน้า Landing Page เป็น Dynamic Data)
--- ---
@ -39,10 +39,9 @@
### 1.2 Security & Authentication ### 1.2 Security & Authentication
- **Token Management:** ใช้ JWT (Access & Refresh Tokens) จัดเก็บผ่าน `useCookie` - **Token Management:** ใช้ JWT (Access & Refresh Tokens) จัดเก็บผ่าน `useCookie`
โดยตั้งค่าความปลอดภัยระดับ **HTTP-only** และ **SameSite** โดยมีกลไกสกัดจับ (Interceptors) ภายใน `useAuth` เช่นเมื่อโหลดโปรไฟล์หรือ **อัปโหลดรูปภาพ** หากโดน API ดีดกลับสถานะ 401 จะทำการต่ออายุ (Refresh) Token อัตโนมัติทันที
- **Middleware:** `auth.ts` ตรวจสอบสิทธิ์การเข้าถึงหน้า Dashboard และ Classroom แบบ Real-time - **Middleware:** `auth.ts` ตรวจสอบสิทธิ์การเข้าถึงหน้า Dashboard และ Classroom แบบ Real-time
- **Persistence:** ระบบ Remember Me (จดจำอีเมล) ใช้ `localStorage` แยกส่วนจาก Session - **Persistence:** ระบบ Remember Me (จดจำอีเมล) และธีมจัดเก็บผ่าน `localStorage` แยกวงจรจาก Session (จะไม่ลบหายไปตอนสั่ง Logout)
เพื่อความปลอดภัยและสะดวกสำหรับผู้ใช้
--- ---
@ -123,6 +122,7 @@
ที่จำสถานะตามผู้ใช้งาน ที่จำสถานะตามผู้ใช้งาน
- **Interactive Quizzes:** ระบบสอบที่สลับคำถามอัตโนมัติ พร้อมโหมดเฉลย (Answer Review) ที่ชัดเจน - **Interactive Quizzes:** ระบบสอบที่สลับคำถามอัตโนมัติ พร้อมโหมดเฉลย (Answer Review) ที่ชัดเจน
- **Certificate Automation:** ระบบตรวจสอบสิทธิ์ความสำเร็จและออกใบประกาศนียบัตรได้ทันที - **Certificate Automation:** ระบบตรวจสอบสิทธิ์ความสำเร็จและออกใบประกาศนียบัตรได้ทันที
- **Dynamic Landing Page:** หน้าแรกเชื่อมต่อข้อมูลจริงจาก API ครบถ้วน (คอร์สเรียน, รีวิว) ขจัดข้อมูล Mock-up รกๆ ออก ใช้คอมโพเนนต์เสริมที่สร้างเองเช่น `StarIcon` ควบคู่กับ `@nuxt/time` เพื่อจัดฟอร์แมตวันที่แบบมือโปร
--- ---
@ -166,7 +166,7 @@ _(หมายเหตุ: Nuxt จะอ่านค่า `NUXT_PUBLIC_API_BA
ระบบมี 2 รูปแบบการแปลภาษาคือ: ระบบมี 2 รูปแบบการแปลภาษาคือ:
- **UI Elements แบบ Static:** แปลผ่านไฟล์หรือแท็กในระบบ `@nuxtjs/i18n` (การสลับภาษาทำผ่านแฮมเบอร์เกอร์เมนูด้านบนขวา แจ้งสถานะผ่าน `useI18n()`) - **UI Elements แบบ Static:** แปลผ่านไฟล์หรือแท็กในระบบ `@nuxtjs/i18n` อย่างสมบูรณ์ (เช่น Component `AppHeader` ที่รองรับการสลับภาษา, ข้อความบอกโหมด Light/Dark, และชื่อสิทธิ์ User Roles จะถูกเรียกใช้ผ่าน i18n keys ทั้งหมดโดยไม่มีการ Hardcode)
- **API Content แบบ Dynamic:** ในตาราง Course หรือ Quiz จากหลังบ้าน จะใช้โครงสร้างแบบคู่ (เช่น `title: { th: "...", en: "..." }`) โดยในทุกตรรกะหน้าเรียน (Composables) จะมีฟังก์ชันช่วยอย่าง `getLocalizedText()` ไว้คอยแปลงก้อน JSON นี้เป็นภาษาที่ผู้ใช้เลือกในปัจจุบันอัตโนมัติ - **API Content แบบ Dynamic:** ในตาราง Course หรือ Quiz จากหลังบ้าน จะใช้โครงสร้างแบบคู่ (เช่น `title: { th: "...", en: "..." }`) โดยในทุกตรรกะหน้าเรียน (Composables) จะมีฟังก์ชันช่วยอย่าง `getLocalizedText()` ไว้คอยแปลงก้อน JSON นี้เป็นภาษาที่ผู้ใช้เลือกในปัจจุบันอัตโนมัติ
--- ---

View file

@ -26,10 +26,9 @@ deploy.ps1
*.tar *.tar
# Playwright # Playwright
tests test-results/
tests/.auth/
/test-results/
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
playwright.config.ts /tests/fixtures/test-data.ts
.auth

View file

@ -343,10 +343,12 @@ const save = async () => {
saving.value = true; saving.value = true;
try { try {
// Convert local datetime to ISO string to preserve timezone // Convert local datetime to ISO string to preserve timezone
const payload = { ...form.value }; const payload: any = { ...form.value };
if (payload.published_at) { if (payload.published_at) {
const localDate = new Date(payload.published_at.replace(' ', 'T')); const localDate = new Date(payload.published_at.replace(' ', 'T'));
payload.published_at = localDate.toISOString(); payload.published_at = localDate.toISOString();
} else {
delete payload.published_at;
} }
if (editing.value) { if (editing.value) {
@ -447,10 +449,7 @@ const deleteAttachment = async (attachmentId: number) => {
} }
}; };
const formatDate = (dateStr: string) => { // Date formatting function is auto-imported from utils/date.ts
const date = new Date(dateStr);
return date.toLocaleDateString('th-TH', { day: 'numeric', month: 'short', year: 'numeric' });
};
const formatFileSize = (bytes: number) => { const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B'; if (bytes < 1024) return bytes + ' B';

View file

@ -20,7 +20,7 @@
v-for="item in history" v-for="item in history"
:key="item.id" :key="item.id"
:title="titleMap[item.action] || item.action" :title="titleMap[item.action] || item.action"
:subtitle="formatDate(item.created_at)" :subtitle="formatDateTime(item.created_at)"
:color="colorMap[item.action] || 'grey'" :color="colorMap[item.action] || 'grey'"
:icon="iconMap[item.action] || 'circle'" :icon="iconMap[item.action] || 'circle'"
> >
@ -91,12 +91,7 @@ const getActorName = (item: ApprovalHistory) => {
return actor.username || actor.email || 'Unknown User'; return actor.username || actor.email || 'Unknown User';
}; };
const formatDate = (dateString: string) => { // Date formatting function is auto-imported from utils/date.ts
return new Date(dateString).toLocaleString('th-TH', {
dateStyle: 'medium',
timeStyle: 'short'
});
};
onMounted(() => { onMounted(() => {
fetchHistory(); fetchHistory();

View file

@ -450,14 +450,7 @@ const openStudentDetail = async (studentId: number) => {
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
if (!dateStr) return '-'; if (!dateStr) return '-';
const date = new Date(dateStr); return formatDateTime(dateStr);
return date.toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}; };
// Lifecycle // Lifecycle

View file

@ -404,8 +404,7 @@ const getStudentStatusLabel = (status: string) => {
}; };
const formatEnrollDate = (dateStr: string) => { const formatEnrollDate = (dateStr: string) => {
const date = new Date(dateStr); return formatDate(dateStr);
return date.toLocaleDateString('th-TH', { day: 'numeric', month: 'short', year: 'numeric' });
}; };
const getLessonTypeIcon = (type: string) => { const getLessonTypeIcon = (type: string) => {
@ -436,8 +435,7 @@ const formatVideoTime = (seconds: number) => {
const formatCompletedDate = (dateStr: string | null) => { const formatCompletedDate = (dateStr: string | null) => {
if (!dateStr) return '-'; if (!dateStr) return '-';
const date = new Date(dateStr); return formatDate(dateStr);
return date.toLocaleDateString('th-TH', { day: 'numeric', month: 'short' });
}; };
// Fetch on mount // Fetch on mount

View file

@ -17,6 +17,7 @@
"vuedraggable": "^4.1.0" "vuedraggable": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@faker-js/faker": "^10.3.0",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
@ -1075,6 +1076,23 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@faker-js/faker": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.3.0.tgz",
"integrity": "sha512-It0Sne6P3szg7JIi6CgKbvTZoMjxBZhcv91ZrqrNuaZQfB5WoqYYbzCUOq89YR+VY8juY9M1vDWmDDa2TzfXCw==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/fakerjs"
}
],
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0",
"npm": ">=10"
}
},
"node_modules/@ioredis/commands": { "node_modules/@ioredis/commands": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz",

View file

@ -24,6 +24,7 @@
"vuedraggable": "^4.1.0" "vuedraggable": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@faker-js/faker": "^10.3.0",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",

View file

@ -136,7 +136,7 @@
<!-- Created At Custom Column --> <!-- Created At Custom Column -->
<template v-slot:body-cell-created_at="props"> <template v-slot:body-cell-created_at="props">
<q-td :props="props"> <q-td :props="props">
{{ formatDate(props.value) }} {{ formatDateTime(props.value) }}
</q-td> </q-td>
</template> </template>
@ -168,8 +168,8 @@
<q-badge :color="getActionColor(selectedLog.action)">{{ selectedLog.action }}</q-badge> <q-badge :color="getActionColor(selectedLog.action)">{{ selectedLog.action }}</q-badge>
</div> </div>
<div> <div>
<div class="text-subtitle2 text-grey">Time</div> <div class="text-subtitle2 text-grey">Date & Time</div>
<div>{{ formatDate(selectedLog.created_at) }}</div> <div>{{ formatDateTime(selectedLog.created_at) }}</div>
</div> </div>
<div> <div>
@ -241,7 +241,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useQuasar } from 'quasar'; import { useQuasar, type QTableColumn } from 'quasar';
import { adminService, type AuditLog, type AuditLogStats } from '~/services/admin.service'; import { adminService, type AuditLog, type AuditLogStats } from '~/services/admin.service';
definePageMeta({ definePageMeta({
@ -284,15 +284,15 @@ const pagination = ref({
}); });
// Table setup // Table setup
const columns = [ const columns: QTableColumn[] = [
{ name: 'id', label: 'ID', field: 'id', align: 'left', style: 'width: 60px' }, { name: 'id', label: 'ID', field: 'id', align: 'left' as const, style: 'width: 60px' },
{ name: 'action', label: 'Action', field: 'action', align: 'left' }, { name: 'action', label: 'Action', field: 'action', align: 'left' as const },
{ name: 'user', label: 'User', field: 'user', align: 'left' }, { name: 'user', label: 'User', field: 'user', align: 'left' as const },
{ name: 'entity_type', label: 'Entity Type', field: 'entity_type', align: 'left' }, { name: 'entity_type', label: 'Entity Type', field: 'entity_type', align: 'left' as const },
{ name: 'entity_id', label: 'Entity ID', field: 'entity_id', align: 'left' }, { name: 'entity_id', label: 'Entity ID', field: 'entity_id', align: 'left' as const },
{ name: 'created_at', label: 'Time', field: 'created_at', align: 'left' }, { name: 'created_at', label: 'Date & Time', field: 'created_at', align: 'left' as const },
{ name: 'actions', label: '', field: 'actions', align: 'center' } { name: 'actions', label: '', field: 'actions', align: 'center' as const }
]; ];
// Actions options (for filtering) // Actions options (for filtering)
@ -416,18 +416,25 @@ const tryFormatJson = (str: string | null) => {
} }
}; };
const formatDate = (date: string) => { // Date formatting function is auto-imported from utils/date.ts
if (!date) return '-';
return new Date(date).toLocaleString('th-TH'); const ACTION_COLOR_MAP: Record<string, string> = {
DELETE: 'negative',
REJECT: 'negative',
DEACTIVATE: 'negative',
ERROR: 'negative',
UPDATE: 'warning',
CHANGE: 'warning',
CREATE: 'positive',
APPROVE: 'positive',
ACTIVATE: 'positive',
LOGIN: 'info',
}; };
const getActionColor = (action: string) => { const getActionColor = (action: string) => {
if (!action) return 'grey'; if (!action) return 'grey';
if (action.includes('DELETE') || action.includes('REJECT') || action.includes('DEACTIVATE') || action.includes('ERROR')) return 'negative'; const keyword = Object.keys(ACTION_COLOR_MAP).find((key) => action.includes(key));
if (action.includes('UPDATE') || action.includes('CHANGE')) return 'warning'; return keyword ? ACTION_COLOR_MAP[keyword] : 'grey-8';
if (action.includes('CREATE') || action.includes('APPROVE') || action.includes('ACTIVATE')) return 'positive';
if (action.includes('LOGIN')) return 'info';
return 'grey-8';
}; };
// Check for deep link to detail // Check for deep link to detail
@ -443,10 +450,12 @@ onMounted(() => {
:deep(input[type=number]::-webkit-outer-spin-button), :deep(input[type=number]::-webkit-outer-spin-button),
:deep(input[type=number]::-webkit-inner-spin-button) { :deep(input[type=number]::-webkit-inner-spin-button) {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none;
margin: 0; margin: 0;
} }
:deep(input[type=number]) { :deep(input[type=number]) {
-moz-appearance: textfield; -moz-appearance: textfield;
appearance: textfield;
} }
</style> </style>

View file

@ -233,13 +233,7 @@ const fetchCategories = async () => {
} }
}; };
const formatDate = (date: string) => { // Date formatting function is auto-imported from utils/date.ts
return new Date(date).toLocaleDateString('th-TH', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
};
const resetForm = () => { const resetForm = () => {
form.value = { form.value = {
@ -307,8 +301,17 @@ const handleSave = async () => {
const confirmDelete = (category: CategoryResponse) => { const confirmDelete = (category: CategoryResponse) => {
$q.dialog({ $q.dialog({
title: 'ยืนยันการลบ', title: 'ยืนยันการลบ',
message: `คุณต้องการลบหมวดหมู่ "${category.name.th}" หรือไม่?`, message: `คุณต้องการลบหมวดหมู่ "${category.name.th}" หรือไม่?<br><span style="color: red;">การลบหมวดหมู่นี้จะทำให้หมวดหมู่ถูกลบออกจากหลักสูตรทั้งหมดที่ใช้งานอยู่</span>`,
cancel: true, html: true,
cancel: {
label: 'ยกเลิก',
color: 'grey',
flat: true
},
ok: {
label: 'ลบหมวดหมู่',
color: 'negative'
},
persistent: true persistent: true
}).onOk(async () => { }).onOk(async () => {
try { try {

View file

@ -56,7 +56,7 @@
<div class="p-6"> <div class="p-6">
<div class="flex flex-wrap gap-2 mb-4"> <div class="flex flex-wrap gap-2 mb-4">
<q-badge :color="getStatusColor(course.status)" :label="getStatusLabel(course.status)" /> <q-badge :color="getStatusColor(course.status)" :label="getStatusLabel(course.status)" />
<q-badge color="grey" :label="course.category.name.th" /> <q-badge color="grey" :label="course.category?.name?.th || 'ไม่มีหมวดหมู่'" />
<q-badge v-if="course.is_free" color="green" label="ฟรี" /> <q-badge v-if="course.is_free" color="green" label="ฟรี" />
<q-badge v-else color="blue" :label="`฿${course.price.toLocaleString()}`" /> <q-badge v-else color="blue" :label="`฿${course.price.toLocaleString()}`" />
<q-badge v-if="course.have_certificate" color="purple" label="มีใบประกาศนียบัตร" /> <q-badge v-if="course.have_certificate" color="purple" label="มีใบประกาศนียบัตร" />
@ -356,23 +356,7 @@ const getActionColor = (action: string) => {
return colors[action] || 'grey'; return colors[action] || 'grey';
}; };
const formatDate = (date: string) => { // Date formatting functions are auto-imported from utils/date.ts
return new Date(date).toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
year: '2-digit'
});
};
const formatDateTime = (date: string) => {
return new Date(date).toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
year: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
const confirmApprove = () => { const confirmApprove = () => {
if (!course.value) return; if (!course.value) return;

View file

@ -135,7 +135,7 @@
<div v-if="course.latest_submission" class="mt-3 text-sm text-gray-500"> <div v-if="course.latest_submission" class="mt-3 text-sm text-gray-500">
<q-icon name="send" size="16px" class="mr-1" /> <q-icon name="send" size="16px" class="mr-1" />
งโดย {{ course.latest_submission.submitter.username }} งโดย {{ course.latest_submission.submitter.username }}
เม {{ formatDate(course.latest_submission.created_at) }} เม {{ formatDateTime(course.latest_submission.created_at) }}
</div> </div>
</div> </div>
@ -203,7 +203,7 @@
<template v-slot:body-cell-submitted_at="props"> <template v-slot:body-cell-submitted_at="props">
<q-td :props="props"> <q-td :props="props">
<div v-if="props.row.latest_submission"> <div v-if="props.row.latest_submission">
<div class="text-xs">{{ formatDate(props.row.latest_submission.created_at) }}</div> <div class="text-xs">{{ formatDateTime(props.row.latest_submission.created_at) }}</div>
<div class="text-xs text-gray-500">โดย {{ props.row.latest_submission.submitter.username }}</div> <div class="text-xs text-gray-500">โดย {{ props.row.latest_submission.submitter.username }}</div>
</div> </div>
<span v-else>-</span> <span v-else>-</span>
@ -298,15 +298,7 @@ const getPrimaryInstructor = (course: PendingCourse) => {
return primary?.user.username || course.creator.username; return primary?.user.username || course.creator.username;
}; };
const formatDate = (date: string) => { // Date formatting function is auto-imported from utils/date.ts
return new Date(date).toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
year: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
const viewCourse = (course: PendingCourse) => { const viewCourse = (course: PendingCourse) => {
router.push(`/admin/courses/${course.id}`); router.push(`/admin/courses/${course.id}`);

View file

@ -136,7 +136,7 @@
<p class="text-xs text-gray-500 truncate">โดย {{ course.creator.username }}</p> <p class="text-xs text-gray-500 truncate">โดย {{ course.creator.username }}</p>
</div> </div>
<div class="text-xs text-gray-400 whitespace-nowrap"> <div class="text-xs text-gray-400 whitespace-nowrap">
{{ formatDate(course.created_at) }} {{ formatDateStr(course.created_at) }}
</div> </div>
</div> </div>
</div> </div>
@ -170,7 +170,7 @@
<span class="text-gray-600 mx-1">{{ formatAction(log.action) }}</span> <span class="text-gray-600 mx-1">{{ formatAction(log.action) }}</span>
<span class="text-primary-700 font-medium">{{ log.entity_type }} #{{ log.entity_id }}</span> <span class="text-primary-700 font-medium">{{ log.entity_type }} #{{ log.entity_id }}</span>
</p> </p>
<p class="text-xs text-gray-400 mt-0.5">{{ formatDate(log.created_at) }}</p> <p class="text-xs text-gray-400 mt-0.5">{{ formatDateStr(log.created_at) }}</p>
</div> </div>
</div> </div>
</div> </div>
@ -254,14 +254,7 @@ const fetchDashboardData = async () => {
}; };
// Utilities // Utilities
const formatDate = (date: string) => { const formatDateStr = (date: string) => formatDateTime(date);
return new Date(date).toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
};
const getActionIcon = (action: string) => { const getActionIcon = (action: string) => {
if (action.includes('create')) return 'add_circle'; if (action.includes('create')) return 'add_circle';

View file

@ -301,7 +301,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import { userService, type UserProfileResponse } from '~/services/user.service'; import { userService } from '~/services/user.service';
import { authService } from '~/services/auth.service'; import { authService } from '~/services/auth.service';
definePageMeta({ definePageMeta({
@ -368,20 +368,8 @@ const getRoleLabel = (role: string) => {
return labels[role] || role; return labels[role] || role;
}; };
const formatDate = (date: string, includeTime = true) => { // Use formatting utilities from utils/date.ts
const options: Intl.DateTimeFormatOptions = { // Format functions are auto-imported
day: 'numeric',
month: 'short',
year: '2-digit'
};
if (includeTime) {
options.hour = '2-digit';
options.minute = '2-digit';
}
return new Date(date).toLocaleDateString('th-TH', options);
};
// Avatar upload // Avatar upload
const avatarInputRef = ref<HTMLInputElement | null>(null); const avatarInputRef = ref<HTMLInputElement | null>(null);
@ -425,8 +413,8 @@ const handleAvatarUpload = async (event: Event) => {
try { try {
const response = await userService.uploadAvatar(file); const response = await userService.uploadAvatar(file);
// Re-fetch profile to get presigned URL from backend // Force refresh profile cache and update local state
await fetchProfile(); await fetchProfile(true);
$q.notify({ $q.notify({
type: 'positive', type: 'positive',
@ -457,8 +445,8 @@ const handleUpdateProfile = async () => {
phone: editForm.value.phone || null phone: editForm.value.phone || null
}); });
// Refresh profile data from API // Force refresh profile cache and update local state
await fetchProfile(); await fetchProfile(true);
$q.notify({ $q.notify({
type: 'positive', type: 'positive',
@ -546,13 +534,9 @@ watch(showEditModal, (newVal) => {
} }
}); });
// Fetch profile from API // Helper to map fullProfile to local profile state
const fetchProfile = async () => { const mapProfileData = (data: typeof authStore.fullProfile) => {
loading.value = true; if (!data) return;
try {
const data = await userService.getProfile();
// Map API response to profile
profile.value = { profile.value = {
fullName: `${data.profile.first_name} ${data.profile.last_name}`, fullName: `${data.profile.first_name} ${data.profile.last_name}`,
email: data.email, email: data.email,
@ -565,6 +549,14 @@ const fetchProfile = async () => {
avatarUrl: data.profile.avatar_url, avatarUrl: data.profile.avatar_url,
createdAt: data.created_at createdAt: data.created_at
}; };
};
// Fetch profile uses auth store cache, force=true to refresh
const fetchProfile = async (force = false) => {
loading.value = true;
try {
await authStore.fetchUserProfile(force);
mapProfileData(authStore.fullProfile);
} catch (error) { } catch (error) {
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
@ -576,7 +568,7 @@ const fetchProfile = async () => {
} }
}; };
// Load profile on mount // Load profile on mount (uses cache if available)
onMounted(() => { onMounted(() => {
fetchProfile(); fetchProfile();
}); });

View file

@ -153,7 +153,8 @@
<!-- Category --> <!-- Category -->
<div class="bg-gray-50 p-4 rounded-lg gap-2"> <div class="bg-gray-50 p-4 rounded-lg gap-2">
<div class="font-bold mb-2">หมวดหม (Category):</div> <div class="font-bold mb-2">หมวดหม (Category):</div>
<div class="text-gray-700 mb-2">{{ selectedCourse.category.name.th }} ({{ selectedCourse.category.name.en }})</div> <div v-if="selectedCourse.category" class="text-gray-700 mb-2">{{ selectedCourse.category.name.th }} ({{ selectedCourse.category.name.en }})</div>
<div v-else class="text-gray-400 italic mb-2">ไมหมวดหม</div>
</div> </div>
<!-- Instructors --> <!-- Instructors -->
@ -262,7 +263,7 @@ const columns = [
field: (row: RecommendedCourse) => row.instructors?.find((i: any) => i.is_primary)?.user.username || '', field: (row: RecommendedCourse) => row.instructors?.find((i: any) => i.is_primary)?.user.username || '',
align: 'left' as const align: 'left' as const
}, },
{ name: 'category', label: 'Category', field: (row: RecommendedCourse) => row.category.name.th, sortable: true, align: 'left' as const }, { name: 'category', label: 'Category', field: (row: RecommendedCourse) => row.category?.name?.th || 'ไม่มีหมวดหมู่', sortable: true, align: 'left' as const },
{ name: 'price', label: 'Price', field: 'price', sortable: true, align: 'right' as const }, { name: 'price', label: 'Price', field: 'price', sortable: true, align: 'right' as const },
{ name: 'is_recommended', label: 'Recommended', field: 'is_recommended', sortable: true, align: 'center' as const }, { name: 'is_recommended', label: 'Recommended', field: 'is_recommended', sortable: true, align: 'center' as const },
{ name: 'actions', label: 'Actions', field: 'actions', align: 'center' as const }, { name: 'actions', label: 'Actions', field: 'actions', align: 'center' as const },

View file

@ -324,13 +324,7 @@ const getRoleBadgeColor = (roleCode: string) => {
return colors[roleCode] || 'grey'; return colors[roleCode] || 'grey';
}; };
const formatDate = (date: string) => { // Date formatting function is auto-imported from utils/date.ts
return new Date(date).toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
year: '2-digit'
});
};
const viewUser = (user: AdminUserResponse) => { const viewUser = (user: AdminUserResponse) => {
selectedUser.value = user; selectedUser.value = user;

View file

@ -432,7 +432,7 @@ const savePrerequisiteSettings = async () => {
...form.value, ...form.value,
prerequisite_lesson_ids: prerequisiteSettings.value.prerequisite_lesson_ids.length > 0 prerequisite_lesson_ids: prerequisiteSettings.value.prerequisite_lesson_ids.length > 0
? prerequisiteSettings.value.prerequisite_lesson_ids ? prerequisiteSettings.value.prerequisite_lesson_ids
: null : []
}); });
$q.notify({ type: 'positive', message: response.message || 'บันทึกการตั้งค่าสำเร็จ', position: 'top' }); $q.notify({ type: 'positive', message: response.message || 'บันทึกการตั้งค่าสำเร็จ', position: 'top' });
} catch (error) { } catch (error) {

View file

@ -506,7 +506,7 @@ const savePrerequisiteSettings = async () => {
...form.value, ...form.value,
prerequisite_lesson_ids: prerequisiteSettings.value.prerequisite_lesson_ids.length > 0 prerequisite_lesson_ids: prerequisiteSettings.value.prerequisite_lesson_ids.length > 0
? prerequisiteSettings.value.prerequisite_lesson_ids ? prerequisiteSettings.value.prerequisite_lesson_ids
: null : []
}); });
$q.notify({ type: 'positive', message: response.message || 'บันทึกการตั้งค่าสำเร็จ', position: 'top' }); $q.notify({ type: 'positive', message: response.message || 'บันทึกการตั้งค่าสำเร็จ', position: 'top' });
} catch (error) { } catch (error) {

View file

@ -449,13 +449,7 @@ const getStatusLabel = (status: string) => {
return labels[status] || status; return labels[status] || status;
}; };
const formatDate = (date: string) => { // Date formatting function is auto-imported from utils/date.ts
return new Date(date).toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
year: '2-digit'
});
};
// Clone Dialog // Clone Dialog
const cloneDialog = ref(false); const cloneDialog = ref(false);
const cloneLoading = ref(false); const cloneLoading = ref(false);

View file

@ -64,21 +64,21 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8"> <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<q-card class="p-6 text-center"> <q-card class="p-6 text-center">
<div class="text-4xl font-bold text-primary-600 mb-2"> <div class="text-4xl font-bold text-primary-600 mb-2">
{{ instructorStore.stats.totalCourses }} {{ stats.totalCourses }}
</div> </div>
<div class="text-gray-600">หลกสตรทงหมด</div> <div class="text-gray-600">หลกสตรทงหมด</div>
</q-card> </q-card>
<q-card class="p-6 text-center"> <q-card class="p-6 text-center">
<div class="text-4xl font-bold text-secondary-600 mb-2"> <div class="text-4xl font-bold text-secondary-600 mb-2">
{{ instructorStore.stats.totalStudents }} {{ stats.totalStudents }}
</div> </div>
<div class="text-gray-600">เรยนทงหมด</div> <div class="text-gray-600">เรยนทงหมด</div>
</q-card> </q-card>
<q-card class="p-6 text-center"> <q-card class="p-6 text-center">
<div class="text-4xl font-bold text-accent-600 mb-2"> <div class="text-4xl font-bold text-accent-600 mb-2">
{{ instructorStore.stats.completedStudents }} {{ stats.completedStudents }}
</div> </div>
<div class="text-gray-600">เรยนจบแล</div> <div class="text-gray-600">เรยนจบแล</div>
</q-card> </q-card>
@ -96,28 +96,28 @@
<q-icon name="check_circle" color="green" size="24px" /> <q-icon name="check_circle" color="green" size="24px" />
<span class="font-medium text-gray-700">เผยแพรแล</span> <span class="font-medium text-gray-700">เผยแพรแล</span>
</div> </div>
<span class="text-2xl font-bold text-green-600">{{ instructorStore.courseStatusCounts.approved }}</span> <span class="text-2xl font-bold text-green-600">{{ courseStatusCounts.approved }}</span>
</div> </div>
<div class="flex items-center justify-between p-3 bg-orange-50 rounded-lg"> <div class="flex items-center justify-between p-3 bg-orange-50 rounded-lg">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<q-icon name="hourglass_empty" color="orange" size="24px" /> <q-icon name="hourglass_empty" color="orange" size="24px" />
<span class="font-medium text-gray-700">รอตรวจสอบ</span> <span class="font-medium text-gray-700">รอตรวจสอบ</span>
</div> </div>
<span class="text-2xl font-bold text-orange-600">{{ instructorStore.courseStatusCounts.pending }}</span> <span class="text-2xl font-bold text-orange-600">{{ courseStatusCounts.pending }}</span>
</div> </div>
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"> <div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<q-icon name="edit_note" color="grey" size="24px" /> <q-icon name="edit_note" color="grey" size="24px" />
<span class="font-medium text-gray-700">แบบราง</span> <span class="font-medium text-gray-700">แบบราง</span>
</div> </div>
<span class="text-2xl font-bold text-gray-600">{{ instructorStore.courseStatusCounts.draft }}</span> <span class="text-2xl font-bold text-gray-600">{{ courseStatusCounts.draft }}</span>
</div> </div>
<div v-if="instructorStore.courseStatusCounts.rejected > 0" class="flex items-center justify-between p-3 bg-red-50 rounded-lg"> <div v-if="courseStatusCounts.rejected > 0" class="flex items-center justify-between p-3 bg-red-50 rounded-lg">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<q-icon name="cancel" color="red" size="24px" /> <q-icon name="cancel" color="red" size="24px" />
<span class="font-medium text-gray-700">กปฏเสธ</span> <span class="font-medium text-gray-700">กปฏเสธ</span>
</div> </div>
<span class="text-2xl font-bold text-red-600">{{ instructorStore.courseStatusCounts.rejected }}</span> <span class="text-2xl font-bold text-red-600">{{ courseStatusCounts.rejected }}</span>
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
@ -138,7 +138,7 @@
<div class="space-y-4"> <div class="space-y-4">
<q-card <q-card
v-for="course in instructorStore.recentCourses" v-for="course in recentCourses"
:key="course.id" :key="course.id"
class="cursor-pointer hover:shadow-md transition" class="cursor-pointer hover:shadow-md transition"
@click="router.push(`/instructor/courses/${course.id}`)" @click="router.push(`/instructor/courses/${course.id}`)"
@ -172,6 +172,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import { instructorService } from '~/services/instructor.service';
definePageMeta({ definePageMeta({
layout: 'instructor', layout: 'instructor',
@ -179,10 +180,32 @@ definePageMeta({
}); });
const authStore = useAuthStore(); const authStore = useAuthStore();
const instructorStore = useInstructorStore();
const router = useRouter(); const router = useRouter();
const $q = useQuasar(); const $q = useQuasar();
// Dashboard local state
const stats = ref({
totalCourses: 0,
totalStudents: 0,
completedStudents: 0
});
const courseStatusCounts = ref({
approved: 0,
pending: 0,
draft: 0,
rejected: 0
});
const recentCourses = ref<{
id: number;
title: string;
students: number;
lessons: number;
icon: string;
thumbnail: string | null;
}[]>([]);
// Navigation functions // Navigation functions
const goToProfile = () => { const goToProfile = () => {
router.push('/instructor/profile'); router.push('/instructor/profile');
@ -212,9 +235,41 @@ const handleLogout = () => {
}); });
}; };
// Fetch dashboard data on mount // Fetch dashboard data
const fetchDashboardData = async () => {
try {
const [courses, studentStats] = await Promise.all([
instructorService.getCourses(),
instructorService.getMyStudentsStats()
]);
stats.value.totalCourses = courses.length;
stats.value.totalStudents = studentStats.total_students;
stats.value.completedStudents = studentStats.total_completed;
courseStatusCounts.value = {
approved: courses.filter(c => c.status === 'APPROVED').length,
pending: courses.filter(c => c.status === 'PENDING').length,
draft: courses.filter(c => c.status === 'DRAFT').length,
rejected: courses.filter(c => c.status === 'REJECTED').length
};
recentCourses.value = courses.slice(0, 3).map(course => ({
id: course.id,
title: course.title.th,
students: 0,
lessons: 0,
icon: 'book',
thumbnail: course.thumbnail_url || null
}));
} catch (error) {
console.error('Failed to fetch dashboard data:', error);
}
};
// Fetch data on mount
onMounted(() => { onMounted(() => {
instructorStore.fetchDashboardData();
authStore.fetchUserProfile(); authStore.fetchUserProfile();
fetchDashboardData();
}); });
</script> </script>

View file

@ -301,7 +301,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import { userService, type UserProfileResponse } from '~/services/user.service'; import { userService } from '~/services/user.service';
import { authService } from '~/services/auth.service'; import { authService } from '~/services/auth.service';
definePageMeta({ definePageMeta({
@ -368,20 +368,8 @@ const getRoleLabel = (role: string) => {
return labels[role] || role; return labels[role] || role;
}; };
const formatDate = (date: string, includeTime = true) => { // Use formatting utilities from utils/date.ts
const options: Intl.DateTimeFormatOptions = { // Format functions are auto-imported
day: 'numeric',
month: 'short',
year: '2-digit'
};
if (includeTime) {
options.hour = '2-digit';
options.minute = '2-digit';
}
return new Date(date).toLocaleDateString('th-TH', options);
};
// Avatar upload // Avatar upload
const avatarInputRef = ref<HTMLInputElement | null>(null); const avatarInputRef = ref<HTMLInputElement | null>(null);
@ -425,8 +413,8 @@ const handleAvatarUpload = async (event: Event) => {
try { try {
const response = await userService.uploadAvatar(file); const response = await userService.uploadAvatar(file);
// Re-fetch profile to get presigned URL from backend // Force refresh profile cache and update local state
await fetchProfile(); await fetchProfile(true);
$q.notify({ $q.notify({
type: 'positive', type: 'positive',
@ -457,8 +445,8 @@ const handleUpdateProfile = async () => {
phone: editForm.value.phone || null phone: editForm.value.phone || null
}); });
// Refresh profile data from API // Force refresh profile cache and update local state
await fetchProfile(); await fetchProfile(true);
$q.notify({ $q.notify({
type: 'positive', type: 'positive',
@ -546,13 +534,9 @@ watch(showEditModal, (newVal) => {
} }
}); });
// Fetch profile from API // Helper to map fullProfile to local profile state
const fetchProfile = async () => { const mapProfileData = (data: typeof authStore.fullProfile) => {
loading.value = true; if (!data) return;
try {
const data = await userService.getProfile();
// Map API response to profile
profile.value = { profile.value = {
fullName: `${data.profile.first_name} ${data.profile.last_name}`, fullName: `${data.profile.first_name} ${data.profile.last_name}`,
email: data.email, email: data.email,
@ -565,6 +549,14 @@ const fetchProfile = async () => {
avatarUrl: data.profile.avatar_url, avatarUrl: data.profile.avatar_url,
createdAt: data.created_at createdAt: data.created_at
}; };
};
// Fetch profile uses auth store cache, force=true to refresh
const fetchProfile = async (force = false) => {
loading.value = true;
try {
await authStore.fetchUserProfile(force);
mapProfileData(authStore.fullProfile);
} catch (error) { } catch (error) {
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
@ -576,7 +568,7 @@ const fetchProfile = async () => {
} }
}; };
// Load profile on mount // Load profile on mount (uses cache if available)
onMounted(() => { onMounted(() => {
fetchProfile(); fetchProfile();
}); });

View file

@ -36,7 +36,7 @@
<q-input <q-input
v-model="form.email" v-model="form.email"
label="อีเมล *" label="อีเมล *"
type="email" type="text"
outlined outlined
:rules="[ :rules="[
val => !!val || 'กรุณากรอกอีเมล', val => !!val || 'กรุณากรอกอีเมล',

View file

@ -0,0 +1,93 @@
import { defineConfig, devices } from '@playwright/test';
import { fileURLToPath } from 'url';
import path from 'path';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// import path from 'path';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests',
/* Run tests in files in parallel */
timeout: 60000, // timeout ต่อ test (ms)
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
// workers: process.env.CI ? 1 : undefined,
workers: 1,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
baseURL: 'http://localhost:3000/',// ปรับเป็น URL ที่ทดสอบ
headless: false, // false = เห็น browser ขณะรัน
screenshot: 'on', // เก็บ screenshot
trace: 'retain-on-failure', // เก็บ trace เพื่อดีบักเมื่อ fail
launchOptions: {
slowMo: 500,
}, // ช้าลง 10 วินาที
},
/* ──── Setup Projects: login ครั้งเดียว แล้ว save cookies ──── */
projects: [
{
name: 'admin-setup',
testMatch: /admin\.setup\.ts/,
testDir: './tests',
},
{
name: 'instructor-setup',
testMatch: /instructor\.setup\.ts/,
testDir: './tests',
},
/* ──── Auth tests: ไม่ต้อง login ก่อน ──── */
{
name: 'auth-tests',
testDir: './tests/auth',
use: { ...devices['Desktop Chrome'] },
},
/* ──── Admin tests: ใช้ cookies จาก admin-setup ──── */
{
name: 'admin-tests',
testDir: './tests/admin',
use: {
...devices['Desktop Chrome'],
storageState: path.resolve(__dirname, 'tests/.auth/admin.json'),
},
dependencies: ['admin-setup'],
},
/* ──── Instructor tests: ใช้ cookies จาก instructor-setup ──── */
{
name: 'instructor-tests',
testDir: './tests/instructor',
use: {
...devices['Desktop Chrome'],
storageState: path.resolve(__dirname, 'tests/.auth/instructor.json'),
},
dependencies: ['instructor-setup'],
},
],
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://localhost:3000',
// reuseExistingServer: !process.env.CI,
// },
});

View file

@ -3,35 +3,20 @@ export interface LoginRequest {
password: string; password: string;
} }
// API Response structure (from backend) // API Response structure (from backend) - new format: only token/refreshToken
export interface ApiLoginResponse { export interface ApiLoginResponse {
token: string; token: string;
refreshToken: string; refreshToken: string;
user: { }
// JWT Payload structure (decoded from token)
export interface JwtPayload {
id: number; id: number;
username: string; username: string;
email: string; email: string;
updated_at: string; roleCode: string;
created_at: string; iat: number;
role: { exp: number;
code: string;
name: {
en: string;
th: string;
};
};
profile: {
prefix: {
en: string;
th: string;
};
first_name: string;
last_name: string;
phone: string | null;
avatar_url: string | null;
birth_date: string | null;
};
};
} }
// Frontend User structure // Frontend User structure
@ -55,6 +40,21 @@ export interface ApiResponse<T> {
data: T; data: T;
} }
/**
* Decode JWT payload without verification (read-only)
* Verification is handled by the backend on each request
*/
function decodeJwtPayload(token: string): JwtPayload {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64).split('').map(c =>
'%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
).join('')
);
return JSON.parse(jsonPayload);
}
export const authService = { export const authService = {
async login(email: string, password: string): Promise<LoginResponse> { async login(email: string, password: string): Promise<LoginResponse> {
const config = useRuntimeConfig(); const config = useRuntimeConfig();
@ -71,22 +71,26 @@ export const authService = {
const loginData = response.data; const loginData = response.data;
// Decode JWT to get user info
const payload = decodeJwtPayload(loginData.token);
// Check if user role is STUDENT - block login // Check if user role is STUDENT - block login
if (loginData.user.role.code === 'STUDENT') { if (payload.roleCode === 'STUDENT') {
throw new Error('ไม่สามารถเข้าสู่ระบบได้ ระบบนี้สำหรับผู้สอนและผู้ดูแลระบบเท่านั้น'); throw new Error('ไม่สามารถเข้าสู่ระบบได้ ระบบนี้สำหรับผู้สอนและผู้ดูแลระบบเท่านั้น');
} }
// Transform API response to frontend format // Return basic user info from JWT payload
// Full profile will be fetched via fetchUserProfile() in the auth store
return { return {
token: loginData.token, token: loginData.token,
refreshToken: loginData.refreshToken, refreshToken: loginData.refreshToken,
user: { user: {
id: loginData.user.id.toString(), id: payload.id.toString(),
email: loginData.user.email, email: payload.email,
firstName: loginData.user.profile.first_name, firstName: '',
lastName: loginData.user.profile.last_name, lastName: '',
role: loginData.user.role.code, role: payload.roleCode,
avatarUrl: loginData.user.profile.avatar_url avatarUrl: null
}, },
message: response.message || 'เข้าสู่ระบบสำเร็จ' message: response.message || 'เข้าสู่ระบบสำเร็จ'
}; };

View file

@ -610,6 +610,19 @@ export const instructorService = {
{ method: 'DELETE' } { method: 'DELETE' }
); );
}, },
async getMyStudentsStats(): Promise<{ total_students: number; total_completed: number }> {
const response = await authRequest<{
code: number;
message: string;
total_students: number;
total_completed: number;
}>('/api/instructors/courses/my-students');
return {
total_students: response.total_students,
total_completed: response.total_completed
};
},
async getCourseApprovalHistory(courseId: number): Promise<ApprovalHistory[]> { async getCourseApprovalHistory(courseId: number): Promise<ApprovalHistory[]> {
const response = await authRequest<{ const response = await authRequest<{
code: number; code: number;

View file

@ -1,6 +1,6 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { authService } from '~/services/auth.service'; import { authService } from '~/services/auth.service';
import { userService } from '~/services/user.service'; import { userService, type UserProfileResponse } from '~/services/user.service';
interface User { interface User {
id: string; id: string;
@ -15,7 +15,8 @@ export const useAuthStore = defineStore('auth', {
state: () => ({ state: () => ({
user: null as User | null, user: null as User | null,
token: null as string | null, token: null as string | null,
isAuthenticated: false isAuthenticated: false,
fullProfile: null as UserProfileResponse | null
}), }),
getters: { getters: {
@ -61,6 +62,7 @@ export const useAuthStore = defineStore('auth', {
this.user = null; this.user = null;
this.token = null; this.token = null;
this.isAuthenticated = false; this.isAuthenticated = false;
this.fullProfile = null;
// Clear cookies // Clear cookies
const tokenCookie = useCookie('token'); const tokenCookie = useCookie('token');
@ -126,10 +128,16 @@ export const useAuthStore = defineStore('auth', {
} }
}, },
async fetchUserProfile() { async fetchUserProfile(force = false) {
// Skip if already cached (unless force refresh)
if (!force && this.fullProfile) return;
try { try {
const response = await userService.getProfile(); const response = await userService.getProfile();
// Cache raw API response
this.fullProfile = response;
// Update local user state // Update local user state
this.user = { this.user = {
id: response.id.toString(), id: response.id.toString(),

View file

@ -1,122 +0,0 @@
import { defineStore } from 'pinia';
import { instructorService } from '~/services/instructor.service';
interface Course {
id: number;
title: string;
students: number;
lessons: number;
icon: string;
thumbnail: string | null;
}
interface DashboardStats {
totalCourses: number;
totalStudents: number;
completedStudents: number;
}
interface CourseStatusCounts {
approved: number;
pending: number;
draft: number;
rejected: number;
}
export const useInstructorStore = defineStore('instructor', {
state: () => ({
stats: {
totalCourses: 0,
totalStudents: 0,
completedStudents: 0
} as DashboardStats,
courseStatusCounts: {
approved: 0,
pending: 0,
draft: 0,
rejected: 0
} as CourseStatusCounts,
recentCourses: [] as Course[],
loading: false
}),
getters: {
getDashboardStats: (state) => state.stats,
getRecentCourses: (state) => state.recentCourses
},
actions: {
async fetchDashboardData() {
this.loading = true;
try {
// Fetch real courses from API
const courses = await instructorService.getCourses();
// Fetch student counts for each course
let totalStudents = 0;
let completedStudents = 0;
const courseDetails: Course[] = [];
for (const course of courses.slice(0, 5)) {
try {
// Get student counts
const studentsResponse = await instructorService.getEnrolledStudents(course.id, 1, 1);
const courseStudents = studentsResponse.total || 0;
totalStudents += courseStudents;
// Get completed count from full list (if small) or estimate
if (courseStudents > 0 && courseStudents <= 100) {
const allStudents = await instructorService.getEnrolledStudents(course.id, 1, 100);
completedStudents += allStudents.data.filter(s => s.status === 'COMPLETED').length;
}
// Get lesson count from course detail
const courseDetail = await instructorService.getCourseById(course.id);
const lessonCount = courseDetail.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0);
courseDetails.push({
id: course.id,
title: course.title.th,
students: courseStudents,
lessons: lessonCount,
icon: 'book',
thumbnail: course.thumbnail_url || null
});
} catch (e) {
// Course might not have students endpoint
courseDetails.push({
id: course.id,
title: course.title.th,
students: 0,
lessons: 0,
icon: 'book',
thumbnail: course.thumbnail_url || null
});
}
}
// Update stats
this.stats.totalCourses = courses.length;
this.stats.totalStudents = totalStudents;
this.stats.completedStudents = completedStudents;
// Update course status counts
this.courseStatusCounts = {
approved: courses.filter(c => c.status === 'APPROVED').length,
pending: courses.filter(c => c.status === 'PENDING').length,
draft: courses.filter(c => c.status === 'DRAFT').length,
rejected: courses.filter(c => c.status === 'REJECTED').length
};
// Update recent courses (first 3)
this.recentCourses = courseDetails.slice(0, 3);
} catch (error) {
console.error('Failed to fetch dashboard data:', error);
} finally {
this.loading = false;
}
}
}
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

File diff suppressed because one or more lines are too long

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