speedrun-cli 2.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +96 -0
- package/LICENSE +21 -0
- package/README.md +620 -0
- package/bin/cli.js +224 -0
- package/index.js +12 -0
- package/package.json +74 -0
- package/src/constants.js +65 -0
- package/src/generator.js +271 -0
- package/src/index.js +13 -0
- package/src/moduleGenerator.js +586 -0
- package/src/postSetup.js +365 -0
- package/src/prompts.js +189 -0
- package/src/utils.js +112 -0
- package/templates/README.md +81 -0
- package/templates/base/eslint.config.mjs +34 -0
- package/templates/base/gitignore +78 -0
- package/templates/base/nest-cli.json +8 -0
- package/templates/base/src/common/constants/cookie.config.ts +20 -0
- package/templates/base/src/common/decorators/get-user.decorator.ts +13 -0
- package/templates/base/src/common/decorators/public.decorator.ts +4 -0
- package/templates/base/src/common/decorators/roles.decorator.ts +4 -0
- package/templates/base/src/common/dtos/pagination.dto.ts +29 -0
- package/templates/base/src/common/filters/http-exception.filter.ts +108 -0
- package/templates/base/src/common/guards/auth.guard.ts +51 -0
- package/templates/base/src/common/guards/refresh-token.guard.ts +39 -0
- package/templates/base/src/common/guards/roles.guard.ts +45 -0
- package/templates/base/src/common/interceptors/response.interceptor.ts +55 -0
- package/templates/base/src/common/interfaces/api-response.interface.ts +26 -0
- package/templates/base/src/common/middleware/correlation-id.middleware.ts +20 -0
- package/templates/base/src/common/validators/password.validator.ts +37 -0
- package/templates/base/src/config/config.module.ts +14 -0
- package/templates/base/src/config/logger.config.ts +109 -0
- package/templates/base/src/main.ts +84 -0
- package/templates/base/src/modules/auth/auth.controller.ts +133 -0
- package/templates/base/src/modules/auth/dtos/login.dto.ts +11 -0
- package/templates/base/src/modules/auth/dtos/signup.dto.ts +8 -0
- package/templates/base/src/modules/health/health.controller.ts +20 -0
- package/templates/base/src/modules/health/health.module.ts +7 -0
- package/templates/base/src/modules/users/dtos/update-profile.dto.ts +12 -0
- package/templates/base/src/modules/users/dtos/update-user.dto.ts +13 -0
- package/templates/base/src/modules/users/users.controller.ts +65 -0
- package/templates/base/test/app.e2e-spec.ts +24 -0
- package/templates/base/test/jest-e2e.json +9 -0
- package/templates/base/tsconfig.build.json +4 -0
- package/templates/base/tsconfig.json +24 -0
- package/templates/base-crud/CRUD_README.md +385 -0
- package/templates/base-crud/src/common/base/base.controller.ts +321 -0
- package/templates/base-crud/src/common/base/base.service.ts +192 -0
- package/templates/base-crud/src/common/base/index.ts +20 -0
- package/templates/base-crud/src/common/base/swagger/api-response.dto.ts +82 -0
- package/templates/base-crud/src/common/base/swagger/paginated.dto.ts +102 -0
- package/templates/base-crud/src/modules/products/dto/create-product.dto.ts +44 -0
- package/templates/base-crud/src/modules/products/dto/product.dto.ts +38 -0
- package/templates/base-crud/src/modules/products/dto/update-product.dto.ts +12 -0
- package/templates/base-crud/src/modules/products/products.controller.ts +94 -0
- package/templates/base-crud-drizzle/src/modules/products/products.controller.ts +79 -0
- package/templates/base-crud-drizzle/src/modules/products/products.module.ts +24 -0
- package/templates/base-crud-drizzle/src/modules/products/products.service.ts +140 -0
- package/templates/base-crud-drizzle/src/modules/products/schema/products.schema.ts +39 -0
- package/templates/base-crud-mongoose/src/modules/products/products.controller.ts +79 -0
- package/templates/base-crud-mongoose/src/modules/products/products.module.ts +26 -0
- package/templates/base-crud-mongoose/src/modules/products/products.service.ts +133 -0
- package/templates/base-crud-mongoose/src/modules/products/schemas/product.schema.ts +67 -0
- package/templates/base-crud-prisma/src/modules/products/products.module.ts +12 -0
- package/templates/base-crud-prisma/src/modules/products/products.service.ts +100 -0
- package/templates/base-crud-typeorm/src/modules/products/entities/product.entity.ts +58 -0
- package/templates/base-crud-typeorm/src/modules/products/products.controller.ts +79 -0
- package/templates/base-crud-typeorm/src/modules/products/products.module.ts +25 -0
- package/templates/base-crud-typeorm/src/modules/products/products.service.ts +102 -0
- package/templates/database/mongodb/.env.example +22 -0
- package/templates/database/mysql/.env.example +24 -0
- package/templates/database/mysql/drizzle.config.ts +13 -0
- package/templates/database/mysql/package.json +5 -0
- package/templates/database/mysql/prisma/schema.prisma +55 -0
- package/templates/database/mysql/src/database/drizzle.ts +13 -0
- package/templates/database/mysql/src/database/schema.ts +58 -0
- package/templates/database/postgres/.env.example +24 -0
- package/templates/database/postgres/drizzle.config.ts +13 -0
- package/templates/database/postgres/package.json +8 -0
- package/templates/database/postgres/prisma/schema.prisma +55 -0
- package/templates/database/sqlite/.env.example +24 -0
- package/templates/database/sqlite/drizzle.config.ts +13 -0
- package/templates/database/sqlite/package.json +8 -0
- package/templates/database/sqlite/prisma/schema.prisma +48 -0
- package/templates/database/sqlite/src/database/drizzle.ts +11 -0
- package/templates/database/sqlite/src/database/schema.ts +52 -0
- package/templates/orm/drizzle/drizzle.config.ts +13 -0
- package/templates/orm/drizzle/package.json +90 -0
- package/templates/orm/drizzle/src/app.module.ts +73 -0
- package/templates/orm/drizzle/src/config/env.validation.ts +55 -0
- package/templates/orm/drizzle/src/database/database.module.ts +25 -0
- package/templates/orm/drizzle/src/database/drizzle.ts +13 -0
- package/templates/orm/drizzle/src/database/schema.ts +60 -0
- package/templates/orm/drizzle/src/database/seed.ts +87 -0
- package/templates/orm/drizzle/src/modules/auth/auth.module.ts +12 -0
- package/templates/orm/drizzle/src/modules/auth/auth.service.ts +298 -0
- package/templates/orm/drizzle/src/modules/health/health.controller.ts +34 -0
- package/templates/orm/drizzle/src/modules/health/health.module.ts +7 -0
- package/templates/orm/drizzle/src/modules/users/users.module.ts +10 -0
- package/templates/orm/drizzle/src/modules/users/users.service.ts +152 -0
- package/templates/orm/mongoose/.env.example +22 -0
- package/templates/orm/mongoose/package.json +86 -0
- package/templates/orm/mongoose/src/app.module.ts +73 -0
- package/templates/orm/mongoose/src/config/env.validation.ts +55 -0
- package/templates/orm/mongoose/src/database/database.module.ts +19 -0
- package/templates/orm/mongoose/src/database/seed.ts +107 -0
- package/templates/orm/mongoose/src/modules/auth/auth.module.ts +21 -0
- package/templates/orm/mongoose/src/modules/auth/auth.service.ts +272 -0
- package/templates/orm/mongoose/src/modules/auth/dtos/login.dto.ts +10 -0
- package/templates/orm/mongoose/src/modules/auth/dtos/signup.dto.ts +8 -0
- package/templates/orm/mongoose/src/modules/health/health.controller.ts +26 -0
- package/templates/orm/mongoose/src/modules/health/health.module.ts +7 -0
- package/templates/orm/mongoose/src/modules/users/dtos/update-profile.dto.ts +24 -0
- package/templates/orm/mongoose/src/modules/users/dtos/update-user.dto.ts +13 -0
- package/templates/orm/mongoose/src/modules/users/users.module.ts +19 -0
- package/templates/orm/mongoose/src/modules/users/users.service.ts +179 -0
- package/templates/orm/mongoose/src/schemas/index.ts +2 -0
- package/templates/orm/mongoose/src/schemas/refresh-token.schema.ts +55 -0
- package/templates/orm/mongoose/src/schemas/user.schema.ts +53 -0
- package/templates/orm/prisma/package.json +102 -0
- package/templates/orm/prisma/prisma/schema.prisma +60 -0
- package/templates/orm/prisma/prisma/seed.ts +69 -0
- package/templates/orm/prisma/src/app.module.ts +55 -0
- package/templates/orm/prisma/src/config/env.validation.ts +56 -0
- package/templates/orm/prisma/src/modules/auth/auth.module.ts +17 -0
- package/templates/orm/prisma/src/modules/auth/auth.service.ts +308 -0
- package/templates/orm/prisma/src/modules/health/health.controller.ts +26 -0
- package/templates/orm/prisma/src/modules/health/health.module.ts +10 -0
- package/templates/orm/prisma/src/modules/users/users.module.ts +11 -0
- package/templates/orm/prisma/src/modules/users/users.service.ts +105 -0
- package/templates/orm/prisma/src/prisma/prisma.module.ts +9 -0
- package/templates/orm/prisma/src/prisma/prisma.service.ts +16 -0
- package/templates/orm/typeorm/package.json +100 -0
- package/templates/orm/typeorm/src/app.module.ts +55 -0
- package/templates/orm/typeorm/src/config/env.validation.ts +58 -0
- package/templates/orm/typeorm/src/database/data-source.ts +19 -0
- package/templates/orm/typeorm/src/database/database.module.ts +27 -0
- package/templates/orm/typeorm/src/database/seed.ts +76 -0
- package/templates/orm/typeorm/src/entities/index.ts +2 -0
- package/templates/orm/typeorm/src/entities/refresh-token.entity.ts +45 -0
- package/templates/orm/typeorm/src/entities/user.entity.ts +51 -0
- package/templates/orm/typeorm/src/modules/auth/auth.module.ts +19 -0
- package/templates/orm/typeorm/src/modules/auth/auth.service.ts +286 -0
- package/templates/orm/typeorm/src/modules/health/health.controller.ts +24 -0
- package/templates/orm/typeorm/src/modules/health/health.module.ts +9 -0
- package/templates/orm/typeorm/src/modules/users/users.module.ts +13 -0
- package/templates/orm/typeorm/src/modules/users/users.service.ts +108 -0
- package/templates/swagger/src/common/dtos/pagination.dto.ts +43 -0
- package/templates/swagger/src/main.ts +116 -0
- package/templates/swagger/src/modules/auth/auth.controller.ts +235 -0
- package/templates/swagger/src/modules/auth/dtos/login.dto.ts +20 -0
- package/templates/swagger/src/modules/auth/dtos/signup.dto.ts +14 -0
- package/templates/swagger/src/modules/users/dtos/update-profile.dto.ts +19 -0
- package/templates/swagger/src/modules/users/dtos/update-user.dto.ts +23 -0
- package/templates/swagger/src/modules/users/users.controller.ts +214 -0
- package/templates/swagger-mongoose/src/modules/auth/dtos/login.dto.ts +20 -0
- package/templates/swagger-mongoose/src/modules/auth/dtos/signup.dto.ts +14 -0
- package/templates/swagger-mongoose/src/modules/users/dtos/update-profile.dto.ts +40 -0
- package/templates/swagger-mongoose/src/modules/users/dtos/update-user.dto.ts +23 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const envSchema = z.object({
|
|
4
|
+
NODE_ENV: z
|
|
5
|
+
.enum(['development', 'production', 'test'])
|
|
6
|
+
.default('development'),
|
|
7
|
+
PORT: z.coerce.number().default(8080),
|
|
8
|
+
|
|
9
|
+
// Database
|
|
10
|
+
DATABASE_URL: z.string().min(1, 'Invalid DATABASE_URL'),
|
|
11
|
+
DB_TYPE: z
|
|
12
|
+
.enum(['postgres', 'mysql', 'sqlite', 'mariadb'])
|
|
13
|
+
.default('postgres'),
|
|
14
|
+
|
|
15
|
+
// JWT Secrets
|
|
16
|
+
JWT_ACCESS_SECRET: z
|
|
17
|
+
.string()
|
|
18
|
+
.min(32, 'JWT_ACCESS_SECRET must be at least 32 characters'),
|
|
19
|
+
JWT_REFRESH_SECRET: z
|
|
20
|
+
.string()
|
|
21
|
+
.min(32, 'JWT_REFRESH_SECRET must be at least 32 characters'),
|
|
22
|
+
JWT_ACCESS_EXPIRY: z
|
|
23
|
+
.string()
|
|
24
|
+
.regex(/^\d+[smhd]$/, 'JWT_ACCESS_EXPIRY must be in format: 60m, 1h, etc.')
|
|
25
|
+
.default('60m'),
|
|
26
|
+
JWT_REFRESH_EXPIRY: z
|
|
27
|
+
.string()
|
|
28
|
+
.regex(/^\d+[smhd]$/, 'JWT_REFRESH_EXPIRY must be in format: 7d, 30d, etc.')
|
|
29
|
+
.default('30d'),
|
|
30
|
+
|
|
31
|
+
// CORS
|
|
32
|
+
CORS_ORIGIN: z
|
|
33
|
+
.string()
|
|
34
|
+
.refine((val) => {
|
|
35
|
+
const origins = val.split(',').map((o) => o.trim());
|
|
36
|
+
const urlRegex = /^https?:\/\/.+/;
|
|
37
|
+
return origins.every((origin) => urlRegex.test(origin) || origin === '*');
|
|
38
|
+
}, 'CORS_ORIGIN must be valid URL(s) or "*"')
|
|
39
|
+
.default('http://localhost:3000'),
|
|
40
|
+
|
|
41
|
+
// Logging
|
|
42
|
+
LOG_LEVEL: z
|
|
43
|
+
.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace'])
|
|
44
|
+
.default('info'),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export type Env = z.infer<typeof envSchema>;
|
|
48
|
+
|
|
49
|
+
export function validate(config: Record<string, unknown>) {
|
|
50
|
+
const result = envSchema.safeParse(config);
|
|
51
|
+
if (!result.success) {
|
|
52
|
+
const errors = result.error.issues
|
|
53
|
+
.map((e) => `${e.path.join('.')}: ${e.message}`)
|
|
54
|
+
.join('\n');
|
|
55
|
+
throw new Error(`Environment validation failed:\n${errors}`);
|
|
56
|
+
}
|
|
57
|
+
return result.data;
|
|
58
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { DataSource } from 'typeorm';
|
|
2
|
+
import { config } from 'dotenv';
|
|
3
|
+
import { User } from '../entities/user.entity';
|
|
4
|
+
import { RefreshToken } from '../entities/refresh-token.entity';
|
|
5
|
+
|
|
6
|
+
config();
|
|
7
|
+
|
|
8
|
+
export const AppDataSource = new DataSource({
|
|
9
|
+
type: (process.env.DB_TYPE as any) || 'postgres',
|
|
10
|
+
url: process.env.DATABASE_URL,
|
|
11
|
+
entities: [User, RefreshToken],
|
|
12
|
+
migrations: ['src/database/migrations/*.ts'],
|
|
13
|
+
synchronize: false,
|
|
14
|
+
logging: process.env.NODE_ENV === 'development',
|
|
15
|
+
ssl:
|
|
16
|
+
process.env.NODE_ENV === 'production'
|
|
17
|
+
? { rejectUnauthorized: false }
|
|
18
|
+
: false,
|
|
19
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
3
|
+
import { ConfigModule, ConfigService } from '@nestjs/config';
|
|
4
|
+
import { User } from '../entities/user.entity';
|
|
5
|
+
import { RefreshToken } from '../entities/refresh-token.entity';
|
|
6
|
+
|
|
7
|
+
@Module({
|
|
8
|
+
imports: [
|
|
9
|
+
TypeOrmModule.forRootAsync({
|
|
10
|
+
imports: [ConfigModule],
|
|
11
|
+
useFactory: (configService: ConfigService) => ({
|
|
12
|
+
type: configService.get<string>('DB_TYPE') as any || 'postgres',
|
|
13
|
+
url: configService.get<string>('DATABASE_URL'),
|
|
14
|
+
entities: [User, RefreshToken],
|
|
15
|
+
synchronize: configService.get<string>('NODE_ENV') !== 'production',
|
|
16
|
+
logging: configService.get<string>('NODE_ENV') === 'development',
|
|
17
|
+
ssl: configService.get<string>('NODE_ENV') === 'production'
|
|
18
|
+
? { rejectUnauthorized: false }
|
|
19
|
+
: false,
|
|
20
|
+
}),
|
|
21
|
+
inject: [ConfigService],
|
|
22
|
+
}),
|
|
23
|
+
TypeOrmModule.forFeature([User, RefreshToken]),
|
|
24
|
+
],
|
|
25
|
+
exports: [TypeOrmModule],
|
|
26
|
+
})
|
|
27
|
+
export class DatabaseModule {}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { DataSource } from 'typeorm';
|
|
2
|
+
import { User, UserRole } from '../entities/user.entity';
|
|
3
|
+
import { RefreshToken } from '../entities/refresh-token.entity';
|
|
4
|
+
import * as bcrypt from 'bcrypt';
|
|
5
|
+
import * as dotenv from 'dotenv';
|
|
6
|
+
|
|
7
|
+
dotenv.config();
|
|
8
|
+
|
|
9
|
+
const dataSource = new DataSource({
|
|
10
|
+
type: (process.env.DB_TYPE as any) || 'postgres',
|
|
11
|
+
url: process.env.DATABASE_URL,
|
|
12
|
+
entities: [User, RefreshToken],
|
|
13
|
+
synchronize: false,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
async function seed() {
|
|
17
|
+
console.log('🌱 Seeding database...');
|
|
18
|
+
|
|
19
|
+
await dataSource.initialize();
|
|
20
|
+
|
|
21
|
+
const userRepository = dataSource.getRepository(User);
|
|
22
|
+
|
|
23
|
+
const adminPassword = await bcrypt.hash('Admin@123', 12);
|
|
24
|
+
const userPassword = await bcrypt.hash('User@123', 12);
|
|
25
|
+
|
|
26
|
+
// Create admin user
|
|
27
|
+
let admin = await userRepository.findOne({
|
|
28
|
+
where: { email: 'admin@example.com' },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (!admin) {
|
|
32
|
+
admin = userRepository.create({
|
|
33
|
+
email: 'admin@example.com',
|
|
34
|
+
passwordHash: adminPassword,
|
|
35
|
+
fullName: 'Admin User',
|
|
36
|
+
role: UserRole.ADMIN,
|
|
37
|
+
isActive: true,
|
|
38
|
+
});
|
|
39
|
+
await userRepository.save(admin);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Create regular user
|
|
43
|
+
let user = await userRepository.findOne({
|
|
44
|
+
where: { email: 'user@example.com' },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (!user) {
|
|
48
|
+
user = userRepository.create({
|
|
49
|
+
email: 'user@example.com',
|
|
50
|
+
passwordHash: userPassword,
|
|
51
|
+
fullName: 'Regular User',
|
|
52
|
+
role: UserRole.USER,
|
|
53
|
+
isActive: true,
|
|
54
|
+
});
|
|
55
|
+
await userRepository.save(user);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.log('✅ Seeding completed');
|
|
59
|
+
console.log('\n📋 Test Credentials:');
|
|
60
|
+
console.log('===================');
|
|
61
|
+
console.log('Admin User:');
|
|
62
|
+
console.log(' Email:', admin.email);
|
|
63
|
+
console.log(' Password: Admin@123');
|
|
64
|
+
console.log(' Role:', admin.role);
|
|
65
|
+
console.log('\nRegular User:');
|
|
66
|
+
console.log(' Email:', user.email);
|
|
67
|
+
console.log(' Password: User@123');
|
|
68
|
+
console.log(' Role:', user.role);
|
|
69
|
+
|
|
70
|
+
await dataSource.destroy();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
seed().catch((e) => {
|
|
74
|
+
console.error('❌ Seeding failed:', e);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Entity,
|
|
3
|
+
PrimaryGeneratedColumn,
|
|
4
|
+
Column,
|
|
5
|
+
CreateDateColumn,
|
|
6
|
+
UpdateDateColumn,
|
|
7
|
+
ManyToOne,
|
|
8
|
+
JoinColumn,
|
|
9
|
+
Index,
|
|
10
|
+
} from 'typeorm';
|
|
11
|
+
import { User } from './user.entity';
|
|
12
|
+
|
|
13
|
+
@Entity('refresh_tokens')
|
|
14
|
+
export class RefreshToken {
|
|
15
|
+
@PrimaryGeneratedColumn('uuid')
|
|
16
|
+
id: string;
|
|
17
|
+
|
|
18
|
+
// Note: No index on token column - TEXT columns can't be indexed in MySQL
|
|
19
|
+
// without specifying key length, which TypeORM doesn't support
|
|
20
|
+
@Column({ type: 'text' })
|
|
21
|
+
token: string;
|
|
22
|
+
|
|
23
|
+
@Index()
|
|
24
|
+
@Column({ name: 'user_id' })
|
|
25
|
+
userId: string;
|
|
26
|
+
|
|
27
|
+
@ManyToOne(() => User, (user) => user.refreshTokens, { onDelete: 'CASCADE' })
|
|
28
|
+
@JoinColumn({ name: 'user_id' })
|
|
29
|
+
user: User;
|
|
30
|
+
|
|
31
|
+
@Column({ name: 'device_info', type: 'varchar', length: 255, nullable: true })
|
|
32
|
+
deviceInfo: string | null;
|
|
33
|
+
|
|
34
|
+
@Column({ name: 'ip_address', type: 'varchar', length: 45, nullable: true })
|
|
35
|
+
ipAddress: string | null;
|
|
36
|
+
|
|
37
|
+
@Column({ name: 'expires_at', type: 'timestamp' })
|
|
38
|
+
expiresAt: Date;
|
|
39
|
+
|
|
40
|
+
@CreateDateColumn({ name: 'created_at' })
|
|
41
|
+
createdAt: Date;
|
|
42
|
+
|
|
43
|
+
@UpdateDateColumn({ name: 'updated_at' })
|
|
44
|
+
updatedAt: Date;
|
|
45
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Entity,
|
|
3
|
+
PrimaryGeneratedColumn,
|
|
4
|
+
Column,
|
|
5
|
+
CreateDateColumn,
|
|
6
|
+
UpdateDateColumn,
|
|
7
|
+
OneToMany,
|
|
8
|
+
} from 'typeorm';
|
|
9
|
+
import { RefreshToken } from './refresh-token.entity';
|
|
10
|
+
|
|
11
|
+
export enum UserRole {
|
|
12
|
+
USER = 'USER',
|
|
13
|
+
ADMIN = 'ADMIN',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@Entity('users')
|
|
17
|
+
export class User {
|
|
18
|
+
@PrimaryGeneratedColumn('uuid')
|
|
19
|
+
id: string;
|
|
20
|
+
|
|
21
|
+
@Column({ name: 'full_name', length: 100 })
|
|
22
|
+
fullName: string;
|
|
23
|
+
|
|
24
|
+
@Column({ unique: true, length: 100 })
|
|
25
|
+
email: string;
|
|
26
|
+
|
|
27
|
+
@Column({ name: 'password_hash', type: 'text' })
|
|
28
|
+
passwordHash: string;
|
|
29
|
+
|
|
30
|
+
@Column({
|
|
31
|
+
type: 'enum',
|
|
32
|
+
enum: UserRole,
|
|
33
|
+
default: UserRole.USER,
|
|
34
|
+
})
|
|
35
|
+
role: UserRole;
|
|
36
|
+
|
|
37
|
+
@Column({ name: 'refresh_token', type: 'text', nullable: true })
|
|
38
|
+
refreshToken: string | null;
|
|
39
|
+
|
|
40
|
+
@Column({ name: 'is_active', default: true })
|
|
41
|
+
isActive: boolean;
|
|
42
|
+
|
|
43
|
+
@OneToMany(() => RefreshToken, (refreshToken) => refreshToken.user)
|
|
44
|
+
refreshTokens: RefreshToken[];
|
|
45
|
+
|
|
46
|
+
@CreateDateColumn({ name: 'created_at' })
|
|
47
|
+
createdAt: Date;
|
|
48
|
+
|
|
49
|
+
@UpdateDateColumn({ name: 'updated_at' })
|
|
50
|
+
updatedAt: Date;
|
|
51
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
3
|
+
import { JwtModule } from '@nestjs/jwt';
|
|
4
|
+
import { AuthService } from './auth.service';
|
|
5
|
+
import { AuthController } from './auth.controller';
|
|
6
|
+
import { User } from 'src/entities/user.entity';
|
|
7
|
+
import { RefreshToken } from 'src/entities/refresh-token.entity';
|
|
8
|
+
|
|
9
|
+
@Module({
|
|
10
|
+
imports: [
|
|
11
|
+
TypeOrmModule.forFeature([User, RefreshToken]),
|
|
12
|
+
JwtModule.register({
|
|
13
|
+
global: true,
|
|
14
|
+
}),
|
|
15
|
+
],
|
|
16
|
+
controllers: [AuthController],
|
|
17
|
+
providers: [AuthService],
|
|
18
|
+
})
|
|
19
|
+
export class AuthModule {}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Injectable,
|
|
3
|
+
ConflictException,
|
|
4
|
+
ForbiddenException,
|
|
5
|
+
UnauthorizedException,
|
|
6
|
+
NotFoundException,
|
|
7
|
+
} from '@nestjs/common';
|
|
8
|
+
import { InjectRepository } from '@nestjs/typeorm';
|
|
9
|
+
import { Repository, MoreThanOrEqual, In } from 'typeorm';
|
|
10
|
+
import * as bcrypt from 'bcrypt';
|
|
11
|
+
import { JwtService } from '@nestjs/jwt';
|
|
12
|
+
import { ConfigService } from '@nestjs/config';
|
|
13
|
+
import { Logger } from 'nestjs-pino';
|
|
14
|
+
import { SignupDto } from './dtos/signup.dto';
|
|
15
|
+
import { LoginDto } from './dtos/login.dto';
|
|
16
|
+
import { User } from 'src/entities/user.entity';
|
|
17
|
+
import { RefreshToken } from 'src/entities/refresh-token.entity';
|
|
18
|
+
|
|
19
|
+
@Injectable()
|
|
20
|
+
export class AuthService {
|
|
21
|
+
constructor(
|
|
22
|
+
@InjectRepository(User)
|
|
23
|
+
private userRepository: Repository<User>,
|
|
24
|
+
@InjectRepository(RefreshToken)
|
|
25
|
+
private refreshTokenRepository: Repository<RefreshToken>,
|
|
26
|
+
private jwtService: JwtService,
|
|
27
|
+
private config: ConfigService,
|
|
28
|
+
private logger: Logger,
|
|
29
|
+
) {}
|
|
30
|
+
|
|
31
|
+
async signup(signupDto: SignupDto) {
|
|
32
|
+
const { email, password, fullName } = signupDto;
|
|
33
|
+
|
|
34
|
+
const existingUser = await this.userRepository.findOne({
|
|
35
|
+
where: { email },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (existingUser) {
|
|
39
|
+
throw new ConflictException('Email already in use');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const hashedPassword = await this.hashData(password);
|
|
43
|
+
|
|
44
|
+
const newUser = this.userRepository.create({
|
|
45
|
+
email,
|
|
46
|
+
passwordHash: hashedPassword,
|
|
47
|
+
fullName,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
await this.userRepository.save(newUser);
|
|
51
|
+
|
|
52
|
+
this.logger.log({
|
|
53
|
+
message: 'New user registered',
|
|
54
|
+
userId: newUser.id,
|
|
55
|
+
role: newUser.role,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
user: {
|
|
60
|
+
id: newUser.id,
|
|
61
|
+
email: newUser.email,
|
|
62
|
+
fullName: newUser.fullName,
|
|
63
|
+
role: newUser.role,
|
|
64
|
+
},
|
|
65
|
+
message: 'User registered successfully',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async login(loginDto: LoginDto, deviceInfo?: string, ipAddress?: string) {
|
|
70
|
+
const { email, password } = loginDto;
|
|
71
|
+
|
|
72
|
+
const user = await this.userRepository.findOne({
|
|
73
|
+
where: { email },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const passwordHash =
|
|
77
|
+
user?.passwordHash ||
|
|
78
|
+
(await this.hashData('dummy-password-to-prevent-timing-attack'));
|
|
79
|
+
const passwordMatches = await bcrypt.compare(password, passwordHash);
|
|
80
|
+
|
|
81
|
+
if (!user || !user.isActive || !passwordMatches) {
|
|
82
|
+
throw new UnauthorizedException('Invalid email or password');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const tokens = await this.generateTokens(user);
|
|
86
|
+
const hashedRt = await this.hashData(tokens.refreshToken);
|
|
87
|
+
|
|
88
|
+
const refreshExpiry =
|
|
89
|
+
this.config.get<string>('JWT_REFRESH_EXPIRY') || '30d';
|
|
90
|
+
const expiryMs = this.parseExpiryToMilliseconds(refreshExpiry);
|
|
91
|
+
const expiresAt = new Date(Date.now() + expiryMs);
|
|
92
|
+
|
|
93
|
+
const refreshToken = this.refreshTokenRepository.create({
|
|
94
|
+
token: hashedRt,
|
|
95
|
+
userId: user.id,
|
|
96
|
+
deviceInfo: deviceInfo || 'Unknown Device',
|
|
97
|
+
ipAddress: ipAddress || 'Unknown IP',
|
|
98
|
+
expiresAt,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await this.refreshTokenRepository.save(refreshToken);
|
|
102
|
+
await this.cleanupExpiredTokens(user.id);
|
|
103
|
+
|
|
104
|
+
this.logger.log({
|
|
105
|
+
message: 'User logged in',
|
|
106
|
+
userId: user.id,
|
|
107
|
+
role: user.role,
|
|
108
|
+
timestamp: new Date().toISOString(),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
user: {
|
|
113
|
+
id: user.id,
|
|
114
|
+
email: user.email,
|
|
115
|
+
fullName: user.fullName,
|
|
116
|
+
role: user.role,
|
|
117
|
+
},
|
|
118
|
+
...tokens,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async refreshToken(
|
|
123
|
+
userId: string,
|
|
124
|
+
rt: string,
|
|
125
|
+
deviceInfo?: string,
|
|
126
|
+
ipAddress?: string,
|
|
127
|
+
) {
|
|
128
|
+
const user = await this.userRepository.findOne({
|
|
129
|
+
where: { id: userId },
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (!user || !user.isActive) {
|
|
133
|
+
throw new ForbiddenException('Invalid refresh token');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const storedTokens = await this.refreshTokenRepository.find({
|
|
137
|
+
where: {
|
|
138
|
+
userId: user.id,
|
|
139
|
+
expiresAt: MoreThanOrEqual(new Date()),
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
let isValidToken = false;
|
|
144
|
+
let validTokenId: string | null = null;
|
|
145
|
+
|
|
146
|
+
for (const storedToken of storedTokens) {
|
|
147
|
+
const matches = await bcrypt.compare(rt, storedToken.token);
|
|
148
|
+
if (matches) {
|
|
149
|
+
isValidToken = true;
|
|
150
|
+
validTokenId = storedToken.id;
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!isValidToken || !validTokenId) {
|
|
156
|
+
throw new UnauthorizedException('Invalid refresh token');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const tokens = await this.generateTokens(user);
|
|
160
|
+
const hashedRt = await this.hashData(tokens.refreshToken);
|
|
161
|
+
|
|
162
|
+
const refreshExpiry =
|
|
163
|
+
this.config.get<string>('JWT_REFRESH_EXPIRY') || '30d';
|
|
164
|
+
const expiryMs = this.parseExpiryToMilliseconds(refreshExpiry);
|
|
165
|
+
const expiresAt = new Date(Date.now() + expiryMs);
|
|
166
|
+
|
|
167
|
+
await this.refreshTokenRepository.update(validTokenId, {
|
|
168
|
+
token: hashedRt,
|
|
169
|
+
deviceInfo: deviceInfo || 'Unknown Device',
|
|
170
|
+
ipAddress: ipAddress || 'Unknown IP',
|
|
171
|
+
expiresAt,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
return tokens;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async logout(userId: string, rt?: string) {
|
|
178
|
+
if (rt) {
|
|
179
|
+
const storedTokens = await this.refreshTokenRepository.find({
|
|
180
|
+
where: { userId },
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
for (const storedToken of storedTokens) {
|
|
184
|
+
const matches = await bcrypt.compare(rt, storedToken.token);
|
|
185
|
+
if (matches) {
|
|
186
|
+
await this.refreshTokenRepository.delete(storedToken.id);
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
await this.refreshTokenRepository.delete({ userId });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
message: 'Logged out successfully',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async getMe(userId: string) {
|
|
200
|
+
const user = await this.userRepository.findOne({
|
|
201
|
+
where: { id: userId },
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
if (!user) {
|
|
205
|
+
throw new NotFoundException('User not found');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
id: user.id,
|
|
210
|
+
email: user.email,
|
|
211
|
+
fullName: user.fullName,
|
|
212
|
+
role: user.role,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Helper Methods
|
|
217
|
+
|
|
218
|
+
async hashData(data: string): Promise<string> {
|
|
219
|
+
const salt = await bcrypt.genSalt(12);
|
|
220
|
+
return bcrypt.hash(data, salt);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async generateTokens(
|
|
224
|
+
user: User,
|
|
225
|
+
): Promise<{ accessToken: string; refreshToken: string }> {
|
|
226
|
+
const payload = { sub: user.id, role: user.role, email: user.email };
|
|
227
|
+
const accessExpiry = this.config.get<string>('JWT_ACCESS_EXPIRY') || '15m';
|
|
228
|
+
const refreshExpiry = this.config.get<string>('JWT_REFRESH_EXPIRY') || '7d';
|
|
229
|
+
|
|
230
|
+
const [accessToken, refreshToken] = await Promise.all([
|
|
231
|
+
// @ts-expect-error - JWT library type definition issue with expiresIn accepting string
|
|
232
|
+
this.jwtService.signAsync(payload, {
|
|
233
|
+
secret: this.config.get<string>('JWT_ACCESS_SECRET'),
|
|
234
|
+
expiresIn: accessExpiry,
|
|
235
|
+
}),
|
|
236
|
+
// @ts-expect-error - JWT library type definition issue with expiresIn accepting string
|
|
237
|
+
this.jwtService.signAsync(payload, {
|
|
238
|
+
secret: this.config.get<string>('JWT_REFRESH_SECRET'),
|
|
239
|
+
expiresIn: refreshExpiry,
|
|
240
|
+
}),
|
|
241
|
+
]);
|
|
242
|
+
|
|
243
|
+
return { accessToken, refreshToken };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private async cleanupExpiredTokens(userId: string) {
|
|
247
|
+
await this.refreshTokenRepository
|
|
248
|
+
.createQueryBuilder()
|
|
249
|
+
.delete()
|
|
250
|
+
.where('userId = :userId AND expiresAt < :now', {
|
|
251
|
+
userId,
|
|
252
|
+
now: new Date(),
|
|
253
|
+
})
|
|
254
|
+
.execute();
|
|
255
|
+
|
|
256
|
+
const tokens = await this.refreshTokenRepository.find({
|
|
257
|
+
where: { userId },
|
|
258
|
+
order: { createdAt: 'DESC' },
|
|
259
|
+
skip: 5,
|
|
260
|
+
take: 100,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
if (tokens.length > 0) {
|
|
264
|
+
await this.refreshTokenRepository.delete({
|
|
265
|
+
id: In(tokens.map((t) => t.id)),
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private parseExpiryToMilliseconds(expiry: string): number {
|
|
271
|
+
const match = expiry.match(/^(\d+)([smhd])$/);
|
|
272
|
+
if (!match) return 30 * 24 * 60 * 60 * 1000;
|
|
273
|
+
|
|
274
|
+
const value = parseInt(match[1], 10);
|
|
275
|
+
const unit = match[2];
|
|
276
|
+
|
|
277
|
+
const units: { [key: string]: number } = {
|
|
278
|
+
s: 1000,
|
|
279
|
+
m: 60 * 1000,
|
|
280
|
+
h: 60 * 60 * 1000,
|
|
281
|
+
d: 24 * 60 * 60 * 1000,
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
return value * units[unit];
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Controller, Get } from '@nestjs/common';
|
|
2
|
+
import {
|
|
3
|
+
HealthCheckService,
|
|
4
|
+
HealthCheck,
|
|
5
|
+
TypeOrmHealthIndicator,
|
|
6
|
+
} from '@nestjs/terminus';
|
|
7
|
+
import { Public } from 'src/common/decorators/public.decorator';
|
|
8
|
+
|
|
9
|
+
@Controller('health')
|
|
10
|
+
export class HealthController {
|
|
11
|
+
constructor(
|
|
12
|
+
private health: HealthCheckService,
|
|
13
|
+
private db: TypeOrmHealthIndicator,
|
|
14
|
+
) {}
|
|
15
|
+
|
|
16
|
+
@Get()
|
|
17
|
+
@Public()
|
|
18
|
+
@HealthCheck()
|
|
19
|
+
check() {
|
|
20
|
+
return this.health.check([
|
|
21
|
+
() => this.db.pingCheck('database'),
|
|
22
|
+
]);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { TerminusModule } from '@nestjs/terminus';
|
|
3
|
+
import { HealthController } from './health.controller';
|
|
4
|
+
|
|
5
|
+
@Module({
|
|
6
|
+
imports: [TerminusModule],
|
|
7
|
+
controllers: [HealthController],
|
|
8
|
+
})
|
|
9
|
+
export class HealthModule {}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
3
|
+
import { UsersService } from './users.service';
|
|
4
|
+
import { UsersController } from './users.controller';
|
|
5
|
+
import { User } from 'src/entities/user.entity';
|
|
6
|
+
import { RefreshToken } from 'src/entities/refresh-token.entity';
|
|
7
|
+
|
|
8
|
+
@Module({
|
|
9
|
+
imports: [TypeOrmModule.forFeature([User, RefreshToken])],
|
|
10
|
+
controllers: [UsersController],
|
|
11
|
+
providers: [UsersService],
|
|
12
|
+
})
|
|
13
|
+
export class UsersModule {}
|