init project starter from nest.js
This commit is contained in:
parent
db23df0fd1
commit
57872f9f1c
58
.gitignore
vendored
Normal file
58
.gitignore
vendored
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
# compiled output
|
||||||
|
/dist
|
||||||
|
/node_modules
|
||||||
|
/build
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
/coverage
|
||||||
|
/.nyc_output
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
/.idea
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# IDE - VSCode
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# temp directory
|
||||||
|
.temp
|
||||||
|
.tmp
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
/generated/prisma
|
||||||
4
.prettierrc
Normal file
4
.prettierrc
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"postman.settings.dotenv-detection-notification-visibility": false
|
||||||
|
}
|
||||||
35
eslint.config.mjs
Normal file
35
eslint.config.mjs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
// @ts-check
|
||||||
|
import eslint from '@eslint/js';
|
||||||
|
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['eslint.config.mjs'],
|
||||||
|
},
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
eslintPluginPrettierRecommended,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
...globals.jest,
|
||||||
|
},
|
||||||
|
sourceType: 'commonjs',
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-floating-promises': 'warn',
|
||||||
|
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||||
|
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
8
nest-cli.json
Normal file
8
nest-cli.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
11196
package-lock.json
generated
Normal file
11196
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
86
package.json
Normal file
86
package.json
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"name": "hadirapp-backend",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"author": "",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^11.0.1",
|
||||||
|
"@nestjs/config": "^4.0.2",
|
||||||
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/jwt": "^11.0.1",
|
||||||
|
"@nestjs/passport": "^11.0.5",
|
||||||
|
"@nestjs/platform-express": "^11.1.8",
|
||||||
|
"@nestjs/swagger": "^11.2.1",
|
||||||
|
"@prisma/client": "^6.18.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.2",
|
||||||
|
"multer": "^2.0.2",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"swagger-ui-express": "^5.0.1",
|
||||||
|
"uuid": "^13.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
"@eslint/js": "^9.18.0",
|
||||||
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
"@nestjs/schematics": "^11.0.0",
|
||||||
|
"@nestjs/testing": "^11.0.1",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/node": "^22.10.7",
|
||||||
|
"@types/supertest": "^6.0.2",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"eslint": "^9.18.0",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"eslint-plugin-prettier": "^5.2.2",
|
||||||
|
"globals": "^16.0.0",
|
||||||
|
"jest": "^30.0.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"prisma": "^6.18.0",
|
||||||
|
"rxjs": "^7.8.2",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
|
"ts-loader": "^9.5.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"typescript-eslint": "^8.20.0"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
prisma.config.ts
Normal file
12
prisma.config.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig, env } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
},
|
||||||
|
engine: "classic",
|
||||||
|
datasource: {
|
||||||
|
url: env("DATABASE_URL"),
|
||||||
|
},
|
||||||
|
});
|
||||||
256
prisma/schema.prisma
Normal file
256
prisma/schema.prisma
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "mysql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model admins {
|
||||||
|
id String @id
|
||||||
|
userId String @unique
|
||||||
|
name String
|
||||||
|
phone String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime
|
||||||
|
users users @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model attendance_sessions {
|
||||||
|
id String @id
|
||||||
|
scheduleId String
|
||||||
|
date DateTime @db.Date
|
||||||
|
startTime DateTime
|
||||||
|
endTime DateTime
|
||||||
|
qrCode String? @unique
|
||||||
|
qrExpiredAt DateTime?
|
||||||
|
topic String?
|
||||||
|
notes String? @db.Text
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime
|
||||||
|
schedules schedules @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
|
||||||
|
attendances attendances[]
|
||||||
|
|
||||||
|
@@unique([scheduleId, date])
|
||||||
|
}
|
||||||
|
|
||||||
|
model attendances {
|
||||||
|
id String @id
|
||||||
|
sessionId String
|
||||||
|
studentId String
|
||||||
|
status attendances_status @default(PRESENT)
|
||||||
|
checkInTime DateTime @default(now())
|
||||||
|
ipAddress String?
|
||||||
|
latitude Float?
|
||||||
|
longitude Float?
|
||||||
|
deviceInfo String? @db.Text
|
||||||
|
notes String? @db.Text
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime
|
||||||
|
attendance_sessions attendance_sessions @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||||
|
students students @relation(fields: [studentId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([sessionId, studentId])
|
||||||
|
@@index([studentId], map: "attendances_studentId_fkey")
|
||||||
|
}
|
||||||
|
|
||||||
|
model audit_logs {
|
||||||
|
id String @id
|
||||||
|
userId String
|
||||||
|
action String
|
||||||
|
entity String
|
||||||
|
entityId String
|
||||||
|
oldValue String? @db.Text
|
||||||
|
newValue String? @db.Text
|
||||||
|
ipAddress String?
|
||||||
|
userAgent String? @db.Text
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model classes {
|
||||||
|
id String @id
|
||||||
|
name String @unique
|
||||||
|
grade String
|
||||||
|
major String?
|
||||||
|
capacity Int @default(40)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime
|
||||||
|
schedules schedules[]
|
||||||
|
students students[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model courses {
|
||||||
|
id String @id
|
||||||
|
code String @unique
|
||||||
|
name String
|
||||||
|
description String? @db.Text
|
||||||
|
teacherId String
|
||||||
|
credits Int @default(2)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime
|
||||||
|
teachers teachers @relation(fields: [teacherId], references: [id], onDelete: Cascade)
|
||||||
|
schedules schedules[]
|
||||||
|
|
||||||
|
@@index([teacherId], map: "courses_teacherId_fkey")
|
||||||
|
}
|
||||||
|
|
||||||
|
model leave_requests {
|
||||||
|
id String @id
|
||||||
|
studentId String
|
||||||
|
startDate DateTime @db.Date
|
||||||
|
endDate DateTime @db.Date
|
||||||
|
type leave_requests_type
|
||||||
|
reason String @db.Text
|
||||||
|
attachment String?
|
||||||
|
status leave_requests_status @default(PENDING)
|
||||||
|
reviewedBy String?
|
||||||
|
reviewedAt DateTime?
|
||||||
|
reviewNotes String? @db.Text
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime
|
||||||
|
students students @relation(fields: [studentId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([studentId], map: "leave_requests_studentId_fkey")
|
||||||
|
}
|
||||||
|
|
||||||
|
model notifications {
|
||||||
|
id String @id
|
||||||
|
userId String
|
||||||
|
type notifications_type
|
||||||
|
title String
|
||||||
|
message String @db.Text
|
||||||
|
isRead Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model schedules {
|
||||||
|
id String @id
|
||||||
|
courseId String
|
||||||
|
classId String
|
||||||
|
teacherId String
|
||||||
|
dayOfWeek schedules_dayOfWeek
|
||||||
|
startTime String
|
||||||
|
endTime String
|
||||||
|
room String?
|
||||||
|
wifiNetworkId String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime
|
||||||
|
attendance_sessions attendance_sessions[]
|
||||||
|
classes classes @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||||
|
courses courses @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||||
|
teachers teachers @relation(fields: [teacherId], references: [id], onDelete: Cascade)
|
||||||
|
wifi_networks wifi_networks? @relation(fields: [wifiNetworkId], references: [id])
|
||||||
|
|
||||||
|
@@index([classId], map: "schedules_classId_fkey")
|
||||||
|
@@index([courseId], map: "schedules_courseId_fkey")
|
||||||
|
@@index([teacherId], map: "schedules_teacherId_fkey")
|
||||||
|
@@index([wifiNetworkId], map: "schedules_wifiNetworkId_fkey")
|
||||||
|
}
|
||||||
|
|
||||||
|
model students {
|
||||||
|
id String @id
|
||||||
|
userId String @unique
|
||||||
|
nis String @unique
|
||||||
|
name String
|
||||||
|
phone String?
|
||||||
|
address String? @db.Text
|
||||||
|
photo String?
|
||||||
|
classId String?
|
||||||
|
parentPhone String?
|
||||||
|
parentEmail String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime
|
||||||
|
attendances attendances[]
|
||||||
|
leave_requests leave_requests[]
|
||||||
|
classes classes? @relation(fields: [classId], references: [id])
|
||||||
|
users users @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([classId], map: "students_classId_fkey")
|
||||||
|
}
|
||||||
|
|
||||||
|
model teachers {
|
||||||
|
id String @id
|
||||||
|
userId String @unique
|
||||||
|
nip String @unique
|
||||||
|
name String
|
||||||
|
phone String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime
|
||||||
|
courses courses[]
|
||||||
|
schedules schedules[]
|
||||||
|
users users @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model users {
|
||||||
|
id String @id
|
||||||
|
email String @unique
|
||||||
|
password String
|
||||||
|
role users_role @default(STUDENT)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime
|
||||||
|
admins admins?
|
||||||
|
students students?
|
||||||
|
teachers teachers?
|
||||||
|
}
|
||||||
|
|
||||||
|
model wifi_networks {
|
||||||
|
id String @id
|
||||||
|
ssid String
|
||||||
|
description String?
|
||||||
|
ipRange String
|
||||||
|
latitude Float?
|
||||||
|
longitude Float?
|
||||||
|
radius Int @default(50)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime
|
||||||
|
schedules schedules[]
|
||||||
|
}
|
||||||
|
|
||||||
|
enum notifications_type {
|
||||||
|
ATTENDANCE_REMINDER
|
||||||
|
ABSENCE_ALERT
|
||||||
|
LEAVE_APPROVED
|
||||||
|
LEAVE_REJECTED
|
||||||
|
SYSTEM
|
||||||
|
}
|
||||||
|
|
||||||
|
enum attendances_status {
|
||||||
|
PRESENT
|
||||||
|
LATE
|
||||||
|
EXCUSED
|
||||||
|
SICK
|
||||||
|
ABSENT
|
||||||
|
}
|
||||||
|
|
||||||
|
enum users_role {
|
||||||
|
ADMIN
|
||||||
|
STUDENT
|
||||||
|
TEACHER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum leave_requests_type {
|
||||||
|
SICK
|
||||||
|
EXCUSED
|
||||||
|
OTHER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum schedules_dayOfWeek {
|
||||||
|
MONDAY
|
||||||
|
TUESDAY
|
||||||
|
WEDNESDAY
|
||||||
|
THURSDAY
|
||||||
|
FRIDAY
|
||||||
|
SATURDAY
|
||||||
|
SUNDAY
|
||||||
|
}
|
||||||
|
|
||||||
|
enum leave_requests_status {
|
||||||
|
PENDING
|
||||||
|
APPROVED
|
||||||
|
REJECTED
|
||||||
|
}
|
||||||
22
src/app.controller.spec.ts
Normal file
22
src/app.controller.spec.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AppController } from './app.controller';
|
||||||
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
|
describe('AppController', () => {
|
||||||
|
let appController: AppController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const app: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [AppController],
|
||||||
|
providers: [AppService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
appController = app.get<AppController>(AppController);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('root', () => {
|
||||||
|
it('should return "Hello World!"', () => {
|
||||||
|
expect(appController.getRoot()).toBe('{ message: \'HadirApp Backend is running 🚀\' }');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
9
src/app.controller.ts
Normal file
9
src/app.controller.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class AppController {
|
||||||
|
@Get()
|
||||||
|
getRoot() {
|
||||||
|
return { message: 'HadirApp Backend is running 🚀' };
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/app.module.ts
Normal file
17
src/app.module.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AppController } from './app.controller';
|
||||||
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
|
import { UsersModule } from './modules/users/users.module';
|
||||||
|
import { PrismaModule } from './modules/prisma/prisma.module';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
|
PrismaModule,
|
||||||
|
AuthModule,
|
||||||
|
UsersModule,
|
||||||
|
],
|
||||||
|
controllers: [AppController],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
8
src/app.service.ts
Normal file
8
src/app.service.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AppService {
|
||||||
|
getHello(): string {
|
||||||
|
return 'Hello World!';
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/common/decorators/roles.decorator.ts
Normal file
4
src/common/decorators/roles.decorator.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
5
src/common/guards/jwt-auth.guard.ts
Normal file
5
src/common/guards/jwt-auth.guard.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||||
20
src/common/guards/roles.guard.ts
Normal file
20
src/common/guards/roles.guard.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RolesGuard implements CanActivate {
|
||||||
|
constructor(private reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!requiredRoles) return true;
|
||||||
|
|
||||||
|
const { user } = context.switchToHttp().getRequest();
|
||||||
|
return requiredRoles.includes(user.role);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/main.ts
Normal file
22
src/main.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
|
||||||
|
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle('HadirApp API')
|
||||||
|
.setDescription('API documentation for attendance system')
|
||||||
|
.setVersion('1.0')
|
||||||
|
.addBearerAuth()
|
||||||
|
.build();
|
||||||
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
|
SwaggerModule.setup('docs', app, document);
|
||||||
|
|
||||||
|
await app.listen(process.env.PORT || 3000);
|
||||||
|
}
|
||||||
|
bootstrap();
|
||||||
18
src/modules/auth/auth.controller.spec.ts
Normal file
18
src/modules/auth/auth.controller.spec.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
|
||||||
|
describe('AuthController', () => {
|
||||||
|
let controller: AuthController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [AuthController],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<AuthController>(AuthController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
44
src/modules/auth/auth.controller.ts
Normal file
44
src/modules/auth/auth.controller.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { Body, Controller, Post } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiBody, ApiResponse, ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
class RegisterDto {
|
||||||
|
@ApiProperty({ example: 'user@example.com', description: 'User email' })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'strongPassword123', description: 'Plain text password' })
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'STUDENT', description: 'Role: ADMIN | STUDENT | TEACHER', required: false })
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoginDto {
|
||||||
|
@ApiProperty({ example: 'user@example.com' })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'strongPassword123' })
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('auth')
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private authService: AuthService) {}
|
||||||
|
|
||||||
|
@Post('register')
|
||||||
|
@ApiOperation({ summary: 'Register a new user' })
|
||||||
|
@ApiBody({ type: RegisterDto })
|
||||||
|
@ApiResponse({ status: 201, description: 'User registered successfully' })
|
||||||
|
async register(@Body() body: RegisterDto) {
|
||||||
|
return this.authService.register(body.email, body.password, body.role || 'STUDENT');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('login')
|
||||||
|
@ApiOperation({ summary: 'Authenticate user and return JWT' })
|
||||||
|
@ApiBody({ type: LoginDto })
|
||||||
|
@ApiResponse({ status: 200, description: 'Login successful, returns access_token' })
|
||||||
|
async login(@Body() body: LoginDto) {
|
||||||
|
return this.authService.login(body.email, body.password);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/modules/auth/auth.module.ts
Normal file
23
src/modules/auth/auth.module.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { UsersModule } from '../users/users.module';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
UsersModule,
|
||||||
|
PrismaModule,
|
||||||
|
PassportModule,
|
||||||
|
JwtModule.register({
|
||||||
|
secret: process.env.JWT_SECRET || 'supersecretkey',
|
||||||
|
signOptions: { expiresIn: '1d' },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, JwtStrategy],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
18
src/modules/auth/auth.service.spec.ts
Normal file
18
src/modules/auth/auth.service.spec.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
describe('AuthService', () => {
|
||||||
|
let service: AuthService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [AuthService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<AuthService>(AuthService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
55
src/modules/auth/auth.service.ts
Normal file
55
src/modules/auth/auth.service.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { Prisma, users_role } from '@prisma/client';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(private prisma: PrismaService, private jwt: JwtService) {}
|
||||||
|
|
||||||
|
async register(email: string, password: string, role?: string) {
|
||||||
|
// validate inputs to avoid passing undefined to bcrypt/prisma
|
||||||
|
if (!email || !password) {
|
||||||
|
throw new BadRequestException('Email and password are required');
|
||||||
|
}
|
||||||
|
if (typeof password !== 'string' || password.length < 6) {
|
||||||
|
throw new BadRequestException('Password must be a string with at least 6 characters');
|
||||||
|
}
|
||||||
|
|
||||||
|
// pastikan role sesuai enum di database (uppercase)
|
||||||
|
const allowedRoles = ['ADMIN', 'STUDENT', 'TEACHER'];
|
||||||
|
const normalizedRole = (role || 'STUDENT').toUpperCase();
|
||||||
|
|
||||||
|
if (!allowedRoles.includes(normalizedRole)) {
|
||||||
|
throw new BadRequestException(`Invalid role. Allowed: ${allowedRoles.join(', ')}`);
|
||||||
|
}
|
||||||
|
const hashed = await bcrypt.hash(password, 10);
|
||||||
|
const user = await this.prisma.users.create({
|
||||||
|
data: { id: uuidv4(), email, password: hashed, role: normalizedRole as users_role, updatedAt: new Date() },
|
||||||
|
});
|
||||||
|
return { message: 'User registered', user };
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(email: string, password: string) {
|
||||||
|
// validate inputs to avoid calling Prisma with undefined values
|
||||||
|
if (!email || !password) {
|
||||||
|
throw new BadRequestException('Email and password are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.prisma.users.findUnique({ where: { email } });
|
||||||
|
if (!user) throw new UnauthorizedException('User not found');
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(password, user.password);
|
||||||
|
if (!valid) throw new UnauthorizedException('Invalid credentials');
|
||||||
|
|
||||||
|
const token = await this.jwt.signAsync({
|
||||||
|
sub: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { message: 'Login success', access_token: token };
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/modules/auth/strategies/jwt.strategy.ts
Normal file
18
src/modules/auth/strategies/jwt.strategy.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: process.env.JWT_SECRET || 'supersecretkey',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: any) {
|
||||||
|
return { userId: payload.sub, email: payload.email, role: payload.role };
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/modules/prisma/prisma.module.ts
Normal file
9
src/modules/prisma/prisma.module.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [PrismaService],
|
||||||
|
exports: [PrismaService],
|
||||||
|
})
|
||||||
|
export class PrismaModule {}
|
||||||
13
src/modules/prisma/prisma.service.ts
Normal file
13
src/modules/prisma/prisma.service.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.$connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/modules/users/users.controller.spec.ts
Normal file
18
src/modules/users/users.controller.spec.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { UsersController } from './users.controller';
|
||||||
|
|
||||||
|
describe('UsersController', () => {
|
||||||
|
let controller: UsersController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [UsersController],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<UsersController>(UsersController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
23
src/modules/users/users.controller.ts
Normal file
23
src/modules/users/users.controller.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||||
|
import { Roles } from '../../common/decorators/roles.decorator';
|
||||||
|
|
||||||
|
@Controller('users')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
export class UsersController {
|
||||||
|
constructor(private usersService: UsersService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Roles('ADMIN')
|
||||||
|
async getAll() {
|
||||||
|
return this.usersService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@Roles('ADMIN', 'TEACHER')
|
||||||
|
async getOne(@Param('id') id: string) {
|
||||||
|
return this.usersService.findOne(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/modules/users/users.module.ts
Normal file
12
src/modules/users/users.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
import { UsersController } from './users.controller';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
providers: [UsersService],
|
||||||
|
controllers: [UsersController],
|
||||||
|
exports: [UsersService],
|
||||||
|
})
|
||||||
|
export class UsersModule {}
|
||||||
18
src/modules/users/users.service.spec.ts
Normal file
18
src/modules/users/users.service.spec.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
|
describe('UsersService', () => {
|
||||||
|
let service: UsersService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [UsersService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<UsersService>(UsersService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
15
src/modules/users/users.service.ts
Normal file
15
src/modules/users/users.service.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsersService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async findAll() {
|
||||||
|
return this.prisma.users.findMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string) {
|
||||||
|
return this.prisma.users.findUnique({ where: { id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
25
test/app.e2e-spec.ts
Normal file
25
test/app.e2e-spec.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { App } from 'supertest/types';
|
||||||
|
import { AppModule } from './../src/app.module';
|
||||||
|
|
||||||
|
describe('AppController (e2e)', () => {
|
||||||
|
let app: INestApplication<App>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/ (GET)', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/')
|
||||||
|
.expect(200)
|
||||||
|
.expect('Hello World!');
|
||||||
|
});
|
||||||
|
});
|
||||||
9
test/jest-e2e.json
Normal file
9
test/jest-e2e.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": ".",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"testRegex": ".e2e-spec.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
tsconfig.build.json
Normal file
4
tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "nodenext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"resolvePackageJsonExports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2023",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./src",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"strictBindCallApply": false,
|
||||||
|
"noFallthroughCasesInSwitch": false
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user