chore: migrate to pnpm workspaces monorepo with Turborepo

- Restructure directories: apps/api, apps/admin, apps/website
- Add root pnpm-workspace.yaml, turbo.json, .prettierrc, .gitignore
- Rename packages to @inkreach/api, @inkreach/admin, @inkreach/website
- Add shared packages: packages/tsconfig, packages/shared-types
- Add pnpm.onlyBuiltDependencies for native builds
- Update docs: README.md, structs.md
- All three projects build successfully
This commit is contained in:
yeuimu
2026-07-11 16:54:05 +08:00
parent 69945b8749
commit 7e04877bb6
155 changed files with 20134 additions and 14393 deletions
+37
View File
@@ -0,0 +1,37 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import {
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import {
LoginResponseDto,
UserPublicDto,
} from './dto/auth-response.dto';
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Register a new admin user' })
@ApiResponse({ status: 201, type: UserPublicDto })
@ApiResponse({ status: 409, description: 'Username already exists' })
register(@Body() dto: RegisterDto): Promise<UserPublicDto> {
return this.authService.register(dto) as unknown as Promise<UserPublicDto>;
}
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Login and obtain a JWT' })
@ApiResponse({ status: 200, type: LoginResponseDto })
@ApiResponse({ status: 401, description: 'Invalid credentials' })
login(@Body() dto: LoginDto): Promise<LoginResponseDto> {
return this.authService.login(dto) as unknown as Promise<LoginResponseDto>;
}
}
+31
View File
@@ -0,0 +1,31 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => {
const secret = config.get<string>('JWT_SECRET');
if (!secret) {
throw new Error('JWT_SECRET must be configured');
}
return {
secret,
signOptions: { expiresIn: '7d' },
};
},
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtModule],
})
export class AuthModule {}
+102
View File
@@ -0,0 +1,102 @@
import { Test } from '@nestjs/testing';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule } from '@nestjs/config';
import { ConflictException, UnauthorizedException } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { AuthService } from './auth.service';
import { PrismaService } from '../prisma/prisma.service';
describe('AuthService', () => {
let service: AuthService;
let prisma: PrismaService;
const createdUsernames: string[] = [];
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
JwtModule.register({
secret: 'test-secret',
signOptions: { expiresIn: '1h' },
}),
],
providers: [AuthService, PrismaService],
}).compile();
service = moduleRef.get(AuthService);
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
});
afterAll(async () => {
// Cleanup created test users
if (createdUsernames.length) {
await prisma.user.deleteMany({
where: { username: { in: createdUsernames } },
});
}
await prisma.onModuleDestroy();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('register', () => {
it('creates a new user and stores a hashed password', async () => {
const username = `test_reg_${Date.now()}`;
createdUsernames.push(username);
const user = await service.register({ username, password: 'plain-pwd' });
expect(user.username).toBe(username);
expect(user.id).toBeTruthy();
const stored = await prisma.user.findUnique({ where: { username } });
expect(stored).not.toBeNull();
expect(stored?.passwordHash).not.toBe('plain-pwd');
const matches = await bcrypt.compare('plain-pwd', stored!.passwordHash);
expect(matches).toBe(true);
});
it('throws ConflictException for duplicate usernames', async () => {
const username = `test_dup_${Date.now()}`;
createdUsernames.push(username);
await service.register({ username, password: 'pwd1234' });
await expect(
service.register({ username, password: 'pwd5678' }),
).rejects.toBeInstanceOf(ConflictException);
});
});
describe('login', () => {
it('returns an access token for valid credentials', async () => {
const username = `test_login_${Date.now()}`;
createdUsernames.push(username);
await service.register({ username, password: 'correct-pwd' });
const result = await service.login({ username, password: 'correct-pwd' });
expect(result.accessToken).toEqual(expect.any(String));
const parts = result.accessToken.split('.');
expect(parts.length).toBe(3);
expect(result.user.username).toBe(username);
});
it('throws UnauthorizedException for wrong password', async () => {
const username = `test_wrong_${Date.now()}`;
createdUsernames.push(username);
await service.register({ username, password: 'right-pwd' });
await expect(
service.login({ username, password: 'wrong-pwd' }),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('throws UnauthorizedException for unknown user', async () => {
await expect(
service.login({ username: 'no-such-user-xyz', password: 'whatever' }),
).rejects.toBeInstanceOf(UnauthorizedException);
});
});
});
+87
View File
@@ -0,0 +1,87 @@
import {
ConflictException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../prisma/prisma.service';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import type { JwtPayload } from './strategies/jwt.strategy';
export interface PublicUser {
id: string;
username: string;
createdAt: string;
}
export interface LoginResult {
accessToken: string;
user: PublicUser;
}
const BCRYPT_ROUNDS = 10;
const TOKEN_EXPIRES_IN = '7d';
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
) {}
/**
* Registers a brand-new admin user. Throws {@link ConflictException}
* if the username is already taken.
*/
async register(dto: RegisterDto): Promise<PublicUser> {
const existing = await this.prisma.user.findUnique({
where: { username: dto.username },
});
if (existing) {
throw new ConflictException('Username already exists');
}
const passwordHash = await bcrypt.hash(dto.password, BCRYPT_ROUNDS);
const created = await this.prisma.user.create({
data: { username: dto.username, passwordHash },
});
return this.toPublic(created);
}
/**
* Verifies credentials and returns a signed JWT.
*/
async login(dto: LoginDto): Promise<LoginResult> {
const user = await this.prisma.user.findUnique({
where: { username: dto.username },
});
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
const ok = await bcrypt.compare(dto.password, user.passwordHash);
if (!ok) {
throw new UnauthorizedException('Invalid credentials');
}
const payload: JwtPayload = {
sub: user.id.toString(),
username: user.username,
};
const accessToken = await this.jwt.signAsync(payload, {
expiresIn: TOKEN_EXPIRES_IN,
});
return { accessToken, user: this.toPublic(user) };
}
private toPublic(user: {
id: bigint;
username: string;
createdAt: Date;
}): PublicUser {
return {
id: user.id.toString(),
username: user.username,
createdAt: user.createdAt.toISOString(),
};
}
}
@@ -0,0 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
export class UserPublicDto {
@ApiProperty({ description: 'User ID (bigint serialized as string)' })
id!: string;
@ApiProperty()
username!: string;
@ApiProperty()
createdAt!: string;
}
export class LoginResponseDto {
@ApiProperty()
accessToken!: string;
@ApiProperty({ type: UserPublicDto })
user!: UserPublicDto;
}
+14
View File
@@ -0,0 +1,14 @@
import { IsNotEmpty, IsString } from 'class-validator';
/**
* Payload accepted by `POST /auth/login`.
*/
export class LoginDto {
@IsString()
@IsNotEmpty()
username!: string;
@IsString()
@IsNotEmpty()
password!: string;
}
+16
View File
@@ -0,0 +1,16 @@
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
/**
* Payload accepted by `POST /auth/register`.
*
* Username must be unique (enforced by DB); password is hashed with bcrypt.
*/
export class RegisterDto {
@IsString()
@IsNotEmpty()
username!: string;
@IsString()
@MinLength(6)
password!: string;
}
@@ -0,0 +1,10 @@
import { AuthGuard } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
/**
* Default guard for every admin route. Delegates to passport-jwt.
*
* Apply with `@UseGuards(JwtAuthGuard)`.
*/
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
@@ -0,0 +1,40 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
/**
* Shape of the JWT we issue.
*
* `sub` is the user ID as a string (bigints are serialized to strings in JSON).
*/
export interface JwtPayload {
sub: string;
username: string;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
const secret = config.get<string>('JWT_SECRET');
if (!secret) {
throw new Error('JWT_SECRET is not configured');
}
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: secret,
});
}
/**
* Runs on every authenticated request. The returned object becomes
* `request.user` for downstream controllers.
*/
validate(payload: JwtPayload): { id: bigint; username: string } {
if (!payload?.sub || !payload.username) {
throw new UnauthorizedException('Invalid token payload');
}
return { id: BigInt(payload.sub), username: payload.username };
}
}