update first - 84

This commit is contained in:
thanhnv
2026-06-30 02:21:39 +09:00
commit 07ac1bdcdd
561 changed files with 88164 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
DATABASE_URL="file:./dev.db"
JWT_SECRET="dev-secret-change-me"
+32
View File
@@ -0,0 +1,32 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { AuthModule } from './auth/auth.module.js';
import { KeyResultsModule } from './key-results/key-results.module.js';
import { ObjectivesModule } from './objectives/objectives.module.js';
import { PrismaModule } from './prisma/prisma.module.js';
import { UsersModule } from './users/users.module.js';
let AppModule = class AppModule {
};
AppModule = __decorate([
Module({
imports: [
JwtModule.register({
global: true,
secret: process.env.JWT_SECRET ?? 'dev-secret-change-me',
signOptions: { expiresIn: '2h' },
}),
PrismaModule,
AuthModule,
UsersModule,
ObjectivesModule,
KeyResultsModule,
],
})
], AppModule);
export { AppModule };
@@ -0,0 +1,46 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
import { Body, Controller, Inject, Post, Res, ValidationPipe } from '@nestjs/common';
import { ok } from '../common/api-response.js';
import { AuthService } from './auth.service.js';
import { LoginDto } from './dto/login.dto.js';
let AuthController = class AuthController {
authService;
constructor(authService) {
this.authService = authService;
}
async login(dto, response) {
const result = await this.authService.login(dto.username, dto.password);
response.cookie('okr_token', result.token, {
httpOnly: true,
sameSite: 'lax',
secure: false,
maxAge: 2 * 60 * 60 * 1000,
});
return ok(result);
}
};
__decorate([
Post('login'),
__param(0, Body(new ValidationPipe({ expectedType: LoginDto, whitelist: true, forbidNonWhitelisted: true }))),
__param(1, Res({ passthrough: true })),
__metadata("design:type", Function),
__metadata("design:paramtypes", [LoginDto, Object]),
__metadata("design:returntype", Promise)
], AuthController.prototype, "login", null);
AuthController = __decorate([
Controller('auth'),
__param(0, Inject(AuthService)),
__metadata("design:paramtypes", [AuthService])
], AuthController);
export { AuthController };
+20
View File
@@ -0,0 +1,20 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module.js';
import { AuthController } from './auth.controller.js';
import { AuthService } from './auth.service.js';
let AuthModule = class AuthModule {
};
AuthModule = __decorate([
Module({
imports: [PrismaModule],
controllers: [AuthController],
providers: [AuthService],
})
], AuthModule);
export { AuthModule };
+62
View File
@@ -0,0 +1,62 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import bcrypt from 'bcrypt';
import { PrismaService } from '../prisma/prisma.service.js';
let AuthService = class AuthService {
prisma;
jwtService;
constructor(prisma, jwtService) {
this.prisma = prisma;
this.jwtService = jwtService;
}
async login(usernameOrEmail, password) {
const user = await this.prisma.user.findFirst({
where: {
OR: [{ username: usernameOrEmail }, { email: usernameOrEmail }],
},
});
if (user === null) {
throw new UnauthorizedException('Invalid credentials');
}
const validPassword = await bcrypt.compare(password, user.passwordHash);
if (!validPassword) {
throw new UnauthorizedException('Invalid credentials');
}
const token = await this.jwtService.signAsync({
sub: user.id,
email: user.email,
role: user.role,
name: user.name,
});
return {
token,
user: {
id: user.id,
name: user.name,
username: user.username,
email: user.email,
role: user.role,
},
};
}
};
AuthService = __decorate([
Injectable(),
__param(0, Inject(PrismaService)),
__param(1, Inject(JwtService)),
__metadata("design:paramtypes", [PrismaService,
JwtService])
], AuthService);
export { AuthService };
+23
View File
@@ -0,0 +1,23 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
import { IsString, MinLength } from 'class-validator';
export class LoginDto {
username;
password;
}
__decorate([
IsString(),
__metadata("design:type", String)
], LoginDto.prototype, "username", void 0);
__decorate([
IsString(),
MinLength(8),
__metadata("design:type", String)
], LoginDto.prototype, "password", void 0);
@@ -0,0 +1,3 @@
export function ok(data, meta) {
return meta === undefined ? { success: true, data } : { success: true, data, meta };
}
+1
View File
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,5 @@
import { createParamDecorator } from '@nestjs/common';
export const CurrentUser = createParamDecorator((_data, context) => {
const request = context.switchToHttp().getRequest();
return request.user;
});
@@ -0,0 +1,53 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
function readCookieToken(request) {
const header = request.headers.cookie;
if (header === undefined) {
return undefined;
}
const tokenPair = header
.split(';')
.map((part) => part.trim())
.find((part) => part.startsWith('okr_token='));
return tokenPair?.slice('okr_token='.length);
}
let JwtAuthGuard = class JwtAuthGuard {
jwtService;
constructor(jwtService) {
this.jwtService = jwtService;
}
canActivate(context) {
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
const bearer = authHeader?.startsWith('Bearer ') === true ? authHeader.slice(7) : undefined;
const token = bearer ?? readCookieToken(request);
if (token === undefined || token.length === 0) {
throw new UnauthorizedException('Authentication required');
}
try {
request.user = this.jwtService.verify(token);
return true;
}
catch {
throw new UnauthorizedException('Invalid or expired token');
}
}
};
JwtAuthGuard = __decorate([
Injectable(),
__param(0, Inject(JwtService)),
__metadata("design:paramtypes", [JwtService])
], JwtAuthGuard);
export { JwtAuthGuard };
@@ -0,0 +1,3 @@
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles) => SetMetadata(ROLES_KEY, roles);
+41
View File
@@ -0,0 +1,41 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from './roles.decorator.js';
let RolesGuard = class RolesGuard {
reflector;
constructor(reflector) {
this.reflector = reflector;
}
canActivate(context) {
const roles = this.reflector.getAllAndOverride(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (roles === undefined || roles.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest();
if (!roles.includes(request.user.role)) {
throw new ForbiddenException('Insufficient role');
}
return true;
}
};
RolesGuard = __decorate([
Injectable(),
__param(0, Inject(Reflector)),
__metadata("design:paramtypes", [Reflector])
], RolesGuard);
export { RolesGuard };
@@ -0,0 +1,47 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
import { IsDateString, IsInt, IsNotEmpty, IsPositive, IsString, Max, Min } from 'class-validator';
export class CreateKeyResultDto {
objectiveId;
title;
startValue;
targetValue;
progress;
deadline;
}
__decorate([
IsInt(),
IsPositive(),
__metadata("design:type", Number)
], CreateKeyResultDto.prototype, "objectiveId", void 0);
__decorate([
IsString(),
IsNotEmpty(),
__metadata("design:type", String)
], CreateKeyResultDto.prototype, "title", void 0);
__decorate([
IsInt(),
__metadata("design:type", Number)
], CreateKeyResultDto.prototype, "startValue", void 0);
__decorate([
IsInt(),
IsPositive(),
__metadata("design:type", Number)
], CreateKeyResultDto.prototype, "targetValue", void 0);
__decorate([
IsInt(),
Min(0),
Max(100),
__metadata("design:type", Number)
], CreateKeyResultDto.prototype, "progress", void 0);
__decorate([
IsDateString(),
__metadata("design:type", String)
], CreateKeyResultDto.prototype, "deadline", void 0);
@@ -0,0 +1,25 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class UpdateProgressDto {
progress;
comment;
}
__decorate([
IsInt(),
Min(0),
Max(100),
__metadata("design:type", Number)
], UpdateProgressDto.prototype, "progress", void 0);
__decorate([
IsString(),
IsOptional(),
__metadata("design:type", String)
], UpdateProgressDto.prototype, "comment", void 0);
@@ -0,0 +1,67 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
import { Body, Controller, Get, Inject, Param, ParseIntPipe, Patch, Post, UseGuards, ValidationPipe } from '@nestjs/common';
import { ok } from '../common/api-response.js';
import { CurrentUser } from '../common/current-user.decorator.js';
import { JwtAuthGuard } from '../common/jwt-auth.guard.js';
import { RolesGuard } from '../common/roles.guard.js';
import { CreateKeyResultDto } from './dto/create-key-result.dto.js';
import { UpdateProgressDto } from './dto/update-progress.dto.js';
import { KeyResultsService } from './key-results.service.js';
let KeyResultsController = class KeyResultsController {
keyResultsService;
constructor(keyResultsService) {
this.keyResultsService = keyResultsService;
}
async get(id, user) {
return ok(await this.keyResultsService.getById(id, user));
}
async create(dto, user) {
return ok(await this.keyResultsService.create(dto, user));
}
async updateProgress(id, dto, user) {
return ok(await this.keyResultsService.updateProgress(id, dto, user));
}
};
__decorate([
Get(':id'),
__param(0, Param('id', ParseIntPipe)),
__param(1, CurrentUser()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [Number, Object]),
__metadata("design:returntype", Promise)
], KeyResultsController.prototype, "get", null);
__decorate([
Post(),
__param(0, Body(new ValidationPipe({ expectedType: CreateKeyResultDto, whitelist: true, forbidNonWhitelisted: true, transform: true }))),
__param(1, CurrentUser()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [CreateKeyResultDto, Object]),
__metadata("design:returntype", Promise)
], KeyResultsController.prototype, "create", null);
__decorate([
Patch(':id/progress'),
__param(0, Param('id', ParseIntPipe)),
__param(1, Body(new ValidationPipe({ expectedType: UpdateProgressDto, whitelist: true, forbidNonWhitelisted: true, transform: true }))),
__param(2, CurrentUser()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [Number, UpdateProgressDto, Object]),
__metadata("design:returntype", Promise)
], KeyResultsController.prototype, "updateProgress", null);
KeyResultsController = __decorate([
Controller('key-results'),
UseGuards(JwtAuthGuard, RolesGuard),
__param(0, Inject(KeyResultsService)),
__metadata("design:paramtypes", [KeyResultsService])
], KeyResultsController);
export { KeyResultsController };
@@ -0,0 +1,20 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module.js';
import { KeyResultsController } from './key-results.controller.js';
import { KeyResultsService } from './key-results.service.js';
let KeyResultsModule = class KeyResultsModule {
};
KeyResultsModule = __decorate([
Module({
imports: [PrismaModule],
controllers: [KeyResultsController],
providers: [KeyResultsService],
})
], KeyResultsModule);
export { KeyResultsModule };
@@ -0,0 +1,104 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
import { ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service.js';
const keyResultInclude = {
objective: {
include: {
owner: { select: { id: true, name: true, username: true, email: true, role: true } },
},
},
};
let KeyResultsService = class KeyResultsService {
prisma;
constructor(prisma) {
this.prisma = prisma;
}
async getById(id, user) {
const keyResult = await this.prisma.keyResult.findUnique({ where: { id }, include: keyResultInclude });
if (keyResult === null) {
throw new NotFoundException('Key result not found');
}
this.assertCanRead(keyResult.objective.ownerId, user);
return keyResult;
}
async create(dto, user) {
const objective = await this.prisma.objective.findUnique({ where: { id: dto.objectiveId } });
if (objective === null) {
throw new NotFoundException('Objective not found');
}
this.assertCanWrite(objective.ownerId, user);
const keyResult = await this.prisma.keyResult.create({
data: {
objectiveId: dto.objectiveId,
title: dto.title,
progress: dto.progress,
startValue: dto.startValue,
targetValue: dto.targetValue,
deadline: new Date(dto.deadline),
},
include: keyResultInclude,
});
await this.recalculateObjectiveStatus(dto.objectiveId);
return keyResult;
}
async updateProgress(id, dto, user) {
const existing = await this.prisma.keyResult.findUnique({ where: { id }, include: keyResultInclude });
if (existing === null) {
throw new NotFoundException('Key result not found');
}
this.assertCanWrite(existing.objective.ownerId, user);
const updated = await this.prisma.$transaction(async (tx) => {
const keyResult = await tx.keyResult.update({
where: { id },
data: { progress: dto.progress },
include: keyResultInclude,
});
await tx.progressUpdate.create({
data: {
keyResultId: id,
progress: dto.progress,
comment: dto.comment,
createdById: user.sub,
},
});
return keyResult;
});
await this.recalculateObjectiveStatus(existing.objectiveId);
return updated;
}
assertCanRead(ownerId, user) {
if (user.role === 'EMPLOYEE' && ownerId !== user.sub) {
throw new ForbiddenException('Key result belongs to another owner');
}
}
assertCanWrite(ownerId, user) {
if (user.role === 'EMPLOYEE' && ownerId !== user.sub) {
throw new ForbiddenException('Only the owner can update this key result');
}
}
async recalculateObjectiveStatus(objectiveId) {
const keyResults = await this.prisma.keyResult.findMany({ where: { objectiveId } });
const average = keyResults.length === 0
? 0
: Math.round(keyResults.reduce((total, keyResult) => total + keyResult.progress, 0) / keyResults.length);
const status = average === 0 ? 'NOT_STARTED' : average >= 100 ? 'COMPLETED' : 'IN_PROGRESS';
await this.prisma.objective.update({ where: { id: objectiveId }, data: { status } });
}
};
KeyResultsService = __decorate([
Injectable(),
__param(0, Inject(PrismaService)),
__metadata("design:paramtypes", [PrismaService])
], KeyResultsService);
export { KeyResultsService };
+23
View File
@@ -0,0 +1,23 @@
import 'reflect-metadata';
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api/v1');
const allowedOrigins = (process.env.FRONTEND_ORIGIN ?? 'http://localhost:5173,http://127.0.0.1:5173')
.split(',')
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0);
app.enableCors({
origin: allowedOrigins,
credentials: true,
});
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}));
await app.listen(Number(process.env.PORT ?? 3000));
}
void bootstrap();
@@ -0,0 +1,36 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
import { IsInt, IsNotEmpty, IsOptional, IsPositive, IsString, Matches } from 'class-validator';
export class CreateObjectiveDto {
title;
description;
ownerId;
quarter;
}
__decorate([
IsString(),
IsNotEmpty(),
__metadata("design:type", String)
], CreateObjectiveDto.prototype, "title", void 0);
__decorate([
IsString(),
IsOptional(),
__metadata("design:type", String)
], CreateObjectiveDto.prototype, "description", void 0);
__decorate([
IsInt(),
IsPositive(),
__metadata("design:type", Number)
], CreateObjectiveDto.prototype, "ownerId", void 0);
__decorate([
IsString(),
Matches(/^Q[1-4]\/\d{4}$/),
__metadata("design:type", String)
], CreateObjectiveDto.prototype, "quarter", void 0);
@@ -0,0 +1,66 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
import { Body, Controller, Get, Inject, Param, ParseIntPipe, Post, Query, UseGuards, ValidationPipe } from '@nestjs/common';
import { ok } from '../common/api-response.js';
import { CurrentUser } from '../common/current-user.decorator.js';
import { JwtAuthGuard } from '../common/jwt-auth.guard.js';
import { RolesGuard } from '../common/roles.guard.js';
import { CreateObjectiveDto } from './dto/create-objective.dto.js';
import { ObjectivesService } from './objectives.service.js';
let ObjectivesController = class ObjectivesController {
objectivesService;
constructor(objectivesService) {
this.objectivesService = objectivesService;
}
async list(user, quarter) {
const objectives = await this.objectivesService.list(user, quarter);
return ok(objectives, { total: objectives.length });
}
async get(id, user) {
return ok(await this.objectivesService.getById(id, user));
}
async create(dto, user) {
return ok(await this.objectivesService.create(dto, user));
}
};
__decorate([
Get(),
__param(0, CurrentUser()),
__param(1, Query('quarter')),
__metadata("design:type", Function),
__metadata("design:paramtypes", [Object, String]),
__metadata("design:returntype", Promise)
], ObjectivesController.prototype, "list", null);
__decorate([
Get(':id'),
__param(0, Param('id', ParseIntPipe)),
__param(1, CurrentUser()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [Number, Object]),
__metadata("design:returntype", Promise)
], ObjectivesController.prototype, "get", null);
__decorate([
Post(),
__param(0, Body(new ValidationPipe({ expectedType: CreateObjectiveDto, whitelist: true, forbidNonWhitelisted: true, transform: true }))),
__param(1, CurrentUser()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [CreateObjectiveDto, Object]),
__metadata("design:returntype", Promise)
], ObjectivesController.prototype, "create", null);
ObjectivesController = __decorate([
Controller('objectives'),
UseGuards(JwtAuthGuard, RolesGuard),
__param(0, Inject(ObjectivesService)),
__metadata("design:paramtypes", [ObjectivesService])
], ObjectivesController);
export { ObjectivesController };
@@ -0,0 +1,21 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module.js';
import { ObjectivesController } from './objectives.controller.js';
import { ObjectivesService } from './objectives.service.js';
let ObjectivesModule = class ObjectivesModule {
};
ObjectivesModule = __decorate([
Module({
imports: [PrismaModule],
controllers: [ObjectivesController],
providers: [ObjectivesService],
exports: [ObjectivesService],
})
], ObjectivesModule);
export { ObjectivesModule };
@@ -0,0 +1,77 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
import { ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service.js';
const objectiveInclude = {
owner: { select: { id: true, name: true, username: true, email: true, role: true } },
keyResults: { orderBy: { id: 'asc' } },
};
function averageProgress(keyResults) {
if (keyResults.length === 0) {
return 0;
}
const total = keyResults.reduce((sum, keyResult) => sum + keyResult.progress, 0);
return Math.round(total / keyResults.length);
}
let ObjectivesService = class ObjectivesService {
prisma;
constructor(prisma) {
this.prisma = prisma;
}
async list(user, quarter) {
const where = {
...(quarter === undefined ? {} : { quarter }),
...(user.role === 'EMPLOYEE' ? { ownerId: user.sub } : {}),
};
return this.prisma.objective.findMany({
where,
include: objectiveInclude,
orderBy: { id: 'asc' },
});
}
async getById(id, user) {
const objective = await this.prisma.objective.findUnique({ where: { id }, include: objectiveInclude });
if (objective === null) {
throw new NotFoundException('Objective not found');
}
if (user.role === 'EMPLOYEE' && objective.ownerId !== user.sub) {
throw new ForbiddenException('Objective belongs to another owner');
}
return { ...objective, computedProgress: averageProgress(objective.keyResults) };
}
async create(dto, user) {
if (user.role === 'EMPLOYEE' && dto.ownerId !== user.sub) {
throw new ForbiddenException('Employees can create only their own objectives');
}
const owner = await this.prisma.user.findUnique({ where: { id: dto.ownerId } });
if (owner === null) {
throw new NotFoundException('Owner not found');
}
return this.prisma.objective.create({
data: {
title: dto.title,
description: dto.description,
ownerId: dto.ownerId,
quarter: dto.quarter,
status: 'NOT_STARTED',
},
include: objectiveInclude,
});
}
};
ObjectivesService = __decorate([
Injectable(),
__param(0, Inject(PrismaService)),
__metadata("design:paramtypes", [PrismaService])
], ObjectivesService);
export { ObjectivesService };
@@ -0,0 +1,17 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service.js';
let PrismaModule = class PrismaModule {
};
PrismaModule = __decorate([
Module({
providers: [PrismaService],
exports: [PrismaService],
})
], PrismaModule);
export { PrismaModule };
@@ -0,0 +1,20 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
let PrismaService = class PrismaService extends PrismaClient {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
};
PrismaService = __decorate([
Injectable()
], PrismaService);
export { PrismaService };
@@ -0,0 +1,41 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { ok } from '../common/api-response.js';
import { JwtAuthGuard } from '../common/jwt-auth.guard.js';
import { Roles } from '../common/roles.decorator.js';
import { RolesGuard } from '../common/roles.guard.js';
import { UsersService } from './users.service.js';
let UsersController = class UsersController {
usersService;
constructor(usersService) {
this.usersService = usersService;
}
async list() {
return ok(await this.usersService.listUsers());
}
};
__decorate([
Get(),
Roles('ADMIN', 'MANAGER'),
__metadata("design:type", Function),
__metadata("design:paramtypes", []),
__metadata("design:returntype", Promise)
], UsersController.prototype, "list", null);
UsersController = __decorate([
Controller('users'),
UseGuards(JwtAuthGuard, RolesGuard),
__param(0, Inject(UsersService)),
__metadata("design:paramtypes", [UsersService])
], UsersController);
export { UsersController };
+21
View File
@@ -0,0 +1,21 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module.js';
import { UsersController } from './users.controller.js';
import { UsersService } from './users.service.js';
let UsersModule = class UsersModule {
};
UsersModule = __decorate([
Module({
imports: [PrismaModule],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
], UsersModule);
export { UsersModule };
+32
View File
@@ -0,0 +1,32 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
import { Inject, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service.js';
let UsersService = class UsersService {
prisma;
constructor(prisma) {
this.prisma = prisma;
}
async listUsers() {
return this.prisma.user.findMany({
orderBy: { id: 'asc' },
select: { id: true, name: true, username: true, email: true, role: true },
});
}
};
UsersService = __decorate([
Injectable(),
__param(0, Inject(PrismaService)),
__metadata("design:paramtypes", [PrismaService])
], UsersService);
export { UsersService };
+39
View File
@@ -0,0 +1,39 @@
{
"name": "@ainative-okr/backend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "prisma generate && tsc -p tsconfig.build.json",
"db:setup": "prisma generate && node scripts/setup-sqlite.mjs",
"dev": "prisma generate && tsx watch src/main.ts",
"seed": "prisma db seed",
"test": "export DATABASE_URL='file:./test.db' JWT_SECRET='test-secret'; npm run db:setup && prisma db seed && node --import tsx --test test/**/*.test.ts"
},
"dependencies": {
"@nestjs/common": "^10.4.20",
"@nestjs/core": "^10.4.20",
"@nestjs/jwt": "^10.2.0",
"@nestjs/platform-express": "^10.4.20",
"@prisma/client": "^6.19.3",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@nestjs/testing": "^10.4.22",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.21",
"@types/node": "^24.0.8",
"@types/supertest": "^6.0.3",
"prisma": "^6.19.3",
"supertest": "^7.1.1",
"tsx": "^4.20.3",
"typescript": "^5.8.3"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}
Binary file not shown.
@@ -0,0 +1,53 @@
-- Initial OKR SQLite schema generated from prisma/schema.prisma via prisma migrate diff.
CREATE TABLE "User" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"role" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
CREATE TABLE "Objective" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"title" TEXT NOT NULL,
"description" TEXT,
"ownerId" INTEGER NOT NULL,
"quarter" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'NOT_STARTED',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Objective_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE TABLE "KeyResult" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"objectiveId" INTEGER NOT NULL,
"title" TEXT NOT NULL,
"progress" INTEGER NOT NULL DEFAULT 0,
"startValue" INTEGER NOT NULL,
"targetValue" INTEGER NOT NULL,
"deadline" DATETIME NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "KeyResult_objectiveId_fkey" FOREIGN KEY ("objectiveId") REFERENCES "Objective" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE "ProgressUpdate" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"keyResultId" INTEGER NOT NULL,
"progress" INTEGER NOT NULL,
"comment" TEXT,
"createdById" INTEGER NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ProgressUpdate_keyResultId_fkey" FOREIGN KEY ("keyResultId") REFERENCES "KeyResult" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
CREATE INDEX "Objective_ownerId_idx" ON "Objective"("ownerId");
CREATE INDEX "Objective_quarter_idx" ON "Objective"("quarter");
CREATE INDEX "Objective_status_idx" ON "Objective"("status");
CREATE INDEX "KeyResult_objectiveId_idx" ON "KeyResult"("objectiveId");
@@ -0,0 +1,63 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
name String
username String @unique
email String @unique
passwordHash String
role String
objectives Objective[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Objective {
id Int @id @default(autoincrement())
title String
description String?
ownerId Int
owner User @relation(fields: [ownerId], references: [id])
quarter String
status String @default("NOT_STARTED")
keyResults KeyResult[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([ownerId])
@@index([quarter])
@@index([status])
}
model KeyResult {
id Int @id @default(autoincrement())
objectiveId Int
objective Objective @relation(fields: [objectiveId], references: [id], onDelete: Cascade)
title String
progress Int @default(0)
startValue Int
targetValue Int
deadline DateTime
updates ProgressUpdate[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([objectiveId])
}
model ProgressUpdate {
id Int @id @default(autoincrement())
keyResultId Int
keyResult KeyResult @relation(fields: [keyResultId], references: [id], onDelete: Cascade)
progress Int
comment String?
createdById Int
createdAt DateTime @default(now())
}
+205
View File
@@ -0,0 +1,205 @@
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcrypt';
const prisma = new PrismaClient();
async function main(): Promise<void> {
const passwordHash = await bcrypt.hash('Password@123', 12);
const admin = await prisma.user.upsert({
where: { email: 'admin@okr.local' },
update: { passwordHash },
create: {
name: 'System Admin',
username: 'admin',
email: 'admin@okr.local',
passwordHash,
role: 'ADMIN',
},
});
const manager = await prisma.user.upsert({
where: { email: 'manager@okr.local' },
update: { passwordHash },
create: {
name: 'Nguyen Van Manager',
username: 'manager',
email: 'manager@okr.local',
passwordHash,
role: 'MANAGER',
},
});
const employee = await prisma.user.upsert({
where: { email: 'employee@okr.local' },
update: { passwordHash },
create: {
name: 'Nguyen Van A',
username: 'employee',
email: 'employee@okr.local',
passwordHash,
role: 'EMPLOYEE',
},
});
const employeeTwo = await prisma.user.upsert({
where: { email: 'employee2@okr.local' },
update: { passwordHash },
create: {
name: 'Tran Thi B',
username: 'employee2',
email: 'employee2@okr.local',
passwordHash,
role: 'EMPLOYEE',
},
});
const securityObjective = await prisma.objective.upsert({
where: { id: 1 },
update: {
ownerId: employee.id,
title: 'POC AI for SQL Injection prevention',
description: 'Evaluate AI tools for automated SQL injection detection.',
quarter: 'Q2/2026',
status: 'IN_PROGRESS',
},
create: {
id: 1,
ownerId: employee.id,
title: 'POC AI for SQL Injection prevention',
description: 'Evaluate AI tools for automated SQL injection detection.',
quarter: 'Q2/2026',
status: 'IN_PROGRESS',
},
});
const adoptionObjective = await prisma.objective.upsert({
where: { id: 2 },
update: {
ownerId: employeeTwo.id,
title: 'AI for All enablement across department',
description: 'Train and certify department members on practical AI workflows.',
quarter: 'Q2/2026',
status: 'NOT_STARTED',
},
create: {
id: 2,
ownerId: employeeTwo.id,
title: 'AI for All enablement across department',
description: 'Train and certify department members on practical AI workflows.',
quarter: 'Q2/2026',
status: 'NOT_STARTED',
},
});
const managerObjective = await prisma.objective.upsert({
where: { id: 3 },
update: {
ownerId: manager.id,
title: 'Improve OKR operating cadence',
description: 'Create weekly progress rhythm and reduce stale OKRs.',
quarter: 'Q2/2026',
status: 'IN_PROGRESS',
},
create: {
id: 3,
ownerId: manager.id,
title: 'Improve OKR operating cadence',
description: 'Create weekly progress rhythm and reduce stale OKRs.',
quarter: 'Q2/2026',
status: 'IN_PROGRESS',
},
});
await prisma.keyResult.upsert({
where: { id: 1 },
update: { objectiveId: securityObjective.id, progress: 33 },
create: {
id: 1,
objectiveId: securityObjective.id,
title: 'Complete 3 POC sessions with security team',
progress: 33,
startValue: 0,
targetValue: 3,
deadline: new Date('2026-06-30T00:00:00.000Z'),
},
});
await prisma.keyResult.upsert({
where: { id: 2 },
update: { objectiveId: securityObjective.id, progress: 60 },
create: {
id: 2,
objectiveId: securityObjective.id,
title: 'Reduce manual SQL injection review effort by 30%',
progress: 60,
startValue: 0,
targetValue: 30,
deadline: new Date('2026-06-30T00:00:00.000Z'),
},
});
await prisma.keyResult.upsert({
where: { id: 3 },
update: { objectiveId: adoptionObjective.id, progress: 0 },
create: {
id: 3,
objectiveId: adoptionObjective.id,
title: 'Certify 100 department members on AI for All',
progress: 0,
startValue: 0,
targetValue: 100,
deadline: new Date('2026-06-30T00:00:00.000Z'),
},
});
await prisma.keyResult.upsert({
where: { id: 4 },
update: { objectiveId: managerObjective.id, progress: 75 },
create: {
id: 4,
objectiveId: managerObjective.id,
title: 'Reach 90% weekly OKR update compliance',
progress: 75,
startValue: 40,
targetValue: 90,
deadline: new Date('2026-06-30T00:00:00.000Z'),
},
});
await prisma.keyResult.upsert({
where: { id: 5 },
update: { objectiveId: managerObjective.id, progress: 45 },
create: {
id: 5,
objectiveId: managerObjective.id,
title: 'Resolve stale OKR reports within two business days',
progress: 45,
startValue: 0,
targetValue: 10,
deadline: new Date('2026-06-30T00:00:00.000Z'),
},
});
await prisma.progressUpdate.deleteMany({ where: { keyResultId: { in: [1, 2, 3, 4, 5] } } });
await prisma.progressUpdate.create({
data: {
keyResultId: 2,
progress: 60,
comment: 'Two review workflows automated; third is in validation.',
createdById: employee.id,
},
});
void admin;
console.log('Seed completed successfully.');
}
main()
.catch((error: unknown) => {
console.error(error);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
Binary file not shown.
@@ -0,0 +1,27 @@
import { mkdirSync, readFileSync, rmSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
function databasePathFromUrl(url) {
if (!url?.startsWith('file:')) {
throw new Error('DATABASE_URL must use SQLite file: URL');
}
const rawPath = url.slice('file:'.length);
if (rawPath.startsWith('/')) {
return rawPath;
}
return resolve('prisma', rawPath);
}
const databasePath = databasePathFromUrl(process.env.DATABASE_URL ?? 'file:./dev.db');
mkdirSync(dirname(databasePath), { recursive: true });
rmSync(databasePath, { force: true });
rmSync(`${databasePath}-journal`, { force: true });
const sql = readFileSync(resolve('prisma/migrations/202606280001_init/migration.sql'), 'utf8');
const db = new DatabaseSync(databasePath);
db.exec('PRAGMA foreign_keys = ON;');
db.exec(sql);
db.close();
console.log(`SQLite schema applied to ${databasePath}`);
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { AuthModule } from './auth/auth.module.js';
import { KeyResultsModule } from './key-results/key-results.module.js';
import { ObjectivesModule } from './objectives/objectives.module.js';
import { PrismaModule } from './prisma/prisma.module.js';
import { UsersModule } from './users/users.module.js';
@Module({
imports: [
JwtModule.register({
global: true,
secret: process.env.JWT_SECRET ?? 'dev-secret-change-me',
signOptions: { expiresIn: '2h' },
}),
PrismaModule,
AuthModule,
UsersModule,
ObjectivesModule,
KeyResultsModule,
],
})
export class AppModule {}
@@ -0,0 +1,25 @@
import { Body, Controller, Inject, Post, Res, ValidationPipe } from '@nestjs/common';
import type { Response } from 'express';
import { ok } from '../common/api-response.js';
import { AuthService } from './auth.service.js';
import { LoginDto } from './dto/login.dto.js';
@Controller('auth')
export class AuthController {
constructor(@Inject(AuthService) private readonly authService: AuthService) {}
@Post('login')
async login(
@Body(new ValidationPipe({ expectedType: LoginDto, whitelist: true, forbidNonWhitelisted: true })) dto: LoginDto,
@Res({ passthrough: true }) response: Response,
) {
const result = await this.authService.login(dto.username, dto.password);
response.cookie('okr_token', result.token, {
httpOnly: true,
sameSite: 'lax',
secure: false,
maxAge: 2 * 60 * 60 * 1000,
});
return ok(result);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module.js';
import { AuthController } from './auth.controller.js';
import { AuthService } from './auth.service.js';
@Module({
imports: [PrismaModule],
controllers: [AuthController],
providers: [AuthService],
})
export class AuthModule {}
@@ -0,0 +1,57 @@
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import bcrypt from 'bcrypt';
import { PrismaService } from '../prisma/prisma.service.js';
export interface LoginResult {
token: string;
user: {
id: number;
name: string;
email: string;
username: string;
role: string;
};
}
@Injectable()
export class AuthService {
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(JwtService) private readonly jwtService: JwtService,
) {}
async login(usernameOrEmail: string, password: string): Promise<LoginResult> {
const user = await this.prisma.user.findFirst({
where: {
OR: [{ username: usernameOrEmail }, { email: usernameOrEmail }],
},
});
if (user === null) {
throw new UnauthorizedException('Invalid credentials');
}
const validPassword = await bcrypt.compare(password, user.passwordHash);
if (!validPassword) {
throw new UnauthorizedException('Invalid credentials');
}
const token = await this.jwtService.signAsync({
sub: user.id,
email: user.email,
role: user.role,
name: user.name,
});
return {
token,
user: {
id: user.id,
name: user.name,
username: user.username,
email: user.email,
role: user.role,
},
};
}
}
@@ -0,0 +1,10 @@
import { IsString, MinLength } from 'class-validator';
export class LoginDto {
@IsString()
username!: string;
@IsString()
@MinLength(8)
password!: string;
}
@@ -0,0 +1,9 @@
export interface ApiResponse<T> {
success: true;
data: T;
meta?: Record<string, unknown>;
}
export function ok<T>(data: T, meta?: Record<string, unknown>): ApiResponse<T> {
return meta === undefined ? { success: true, data } : { success: true, data, meta };
}
@@ -0,0 +1,14 @@
import type { Request } from 'express';
export type Role = 'ADMIN' | 'MANAGER' | 'EMPLOYEE';
export interface JwtUser {
sub: number;
email: string;
role: Role;
name: string;
}
export interface AuthenticatedRequest extends Request {
user: JwtUser;
}
@@ -0,0 +1,7 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { AuthenticatedRequest, JwtUser } from './auth.types.js';
export const CurrentUser = createParamDecorator((_data: unknown, context: ExecutionContext): JwtUser => {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
return request.user;
});
@@ -0,0 +1,38 @@
import { CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import type { Request } from 'express';
import type { AuthenticatedRequest, JwtUser } from './auth.types.js';
function readCookieToken(request: Request): string | undefined {
const header = request.headers.cookie;
if (header === undefined) {
return undefined;
}
const tokenPair = header
.split(';')
.map((part) => part.trim())
.find((part) => part.startsWith('okr_token='));
return tokenPair?.slice('okr_token='.length);
}
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(@Inject(JwtService) private readonly jwtService: JwtService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const authHeader = request.headers.authorization;
const bearer = authHeader?.startsWith('Bearer ') === true ? authHeader.slice(7) : undefined;
const token = bearer ?? readCookieToken(request);
if (token === undefined || token.length === 0) {
throw new UnauthorizedException('Authentication required');
}
try {
request.user = this.jwtService.verify<JwtUser>(token);
return true;
} catch {
throw new UnauthorizedException('Invalid or expired token');
}
}
}
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import type { Role } from './auth.types.js';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]): ReturnType<typeof SetMetadata> => SetMetadata(ROLES_KEY, roles);
@@ -0,0 +1,25 @@
import { CanActivate, ExecutionContext, ForbiddenException, Inject, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { AuthenticatedRequest, Role } from './auth.types.js';
import { ROLES_KEY } from './roles.decorator.js';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(@Inject(Reflector) private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const roles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (roles === undefined || roles.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
if (!roles.includes(request.user.role)) {
throw new ForbiddenException('Insufficient role');
}
return true;
}
}
@@ -0,0 +1,26 @@
import { IsDateString, IsInt, IsNotEmpty, IsPositive, IsString, Max, Min } from 'class-validator';
export class CreateKeyResultDto {
@IsInt()
@IsPositive()
objectiveId!: number;
@IsString()
@IsNotEmpty()
title!: string;
@IsInt()
startValue!: number;
@IsInt()
@IsPositive()
targetValue!: number;
@IsInt()
@Min(0)
@Max(100)
progress!: number;
@IsDateString()
deadline!: string;
}
@@ -0,0 +1,12 @@
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class UpdateProgressDto {
@IsInt()
@Min(0)
@Max(100)
progress!: number;
@IsString()
@IsOptional()
comment?: string;
}
@@ -0,0 +1,39 @@
import { Body, Controller, Get, Inject, Param, ParseIntPipe, Patch, Post, UseGuards, ValidationPipe } from '@nestjs/common';
import { ok } from '../common/api-response.js';
import type { JwtUser } from '../common/auth.types.js';
import { CurrentUser } from '../common/current-user.decorator.js';
import { JwtAuthGuard } from '../common/jwt-auth.guard.js';
import { RolesGuard } from '../common/roles.guard.js';
import { CreateKeyResultDto } from './dto/create-key-result.dto.js';
import { UpdateProgressDto } from './dto/update-progress.dto.js';
import { KeyResultsService } from './key-results.service.js';
@Controller('key-results')
@UseGuards(JwtAuthGuard, RolesGuard)
export class KeyResultsController {
constructor(@Inject(KeyResultsService) private readonly keyResultsService: KeyResultsService) {}
@Get(':id')
async get(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: JwtUser) {
return ok(await this.keyResultsService.getById(id, user));
}
@Post()
async create(
@Body(new ValidationPipe({ expectedType: CreateKeyResultDto, whitelist: true, forbidNonWhitelisted: true, transform: true }))
dto: CreateKeyResultDto,
@CurrentUser() user: JwtUser,
) {
return ok(await this.keyResultsService.create(dto, user));
}
@Patch(':id/progress')
async updateProgress(
@Param('id', ParseIntPipe) id: number,
@Body(new ValidationPipe({ expectedType: UpdateProgressDto, whitelist: true, forbidNonWhitelisted: true, transform: true }))
dto: UpdateProgressDto,
@CurrentUser() user: JwtUser,
) {
return ok(await this.keyResultsService.updateProgress(id, dto, user));
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module.js';
import { KeyResultsController } from './key-results.controller.js';
import { KeyResultsService } from './key-results.service.js';
@Module({
imports: [PrismaModule],
controllers: [KeyResultsController],
providers: [KeyResultsService],
})
export class KeyResultsModule {}
@@ -0,0 +1,101 @@
import { ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type { JwtUser } from '../common/auth.types.js';
import { PrismaService } from '../prisma/prisma.service.js';
import type { CreateKeyResultDto } from './dto/create-key-result.dto.js';
import type { UpdateProgressDto } from './dto/update-progress.dto.js';
const keyResultInclude = {
objective: {
include: {
owner: { select: { id: true, name: true, username: true, email: true, role: true } },
},
},
} satisfies Prisma.KeyResultInclude;
export type KeyResultWithObjective = Prisma.KeyResultGetPayload<{ include: typeof keyResultInclude }>;
@Injectable()
export class KeyResultsService {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async getById(id: number, user: JwtUser): Promise<KeyResultWithObjective> {
const keyResult = await this.prisma.keyResult.findUnique({ where: { id }, include: keyResultInclude });
if (keyResult === null) {
throw new NotFoundException('Key result not found');
}
this.assertCanRead(keyResult.objective.ownerId, user);
return keyResult;
}
async create(dto: CreateKeyResultDto, user: JwtUser): Promise<KeyResultWithObjective> {
const objective = await this.prisma.objective.findUnique({ where: { id: dto.objectiveId } });
if (objective === null) {
throw new NotFoundException('Objective not found');
}
this.assertCanWrite(objective.ownerId, user);
const keyResult = await this.prisma.keyResult.create({
data: {
objectiveId: dto.objectiveId,
title: dto.title,
progress: dto.progress,
startValue: dto.startValue,
targetValue: dto.targetValue,
deadline: new Date(dto.deadline),
},
include: keyResultInclude,
});
await this.recalculateObjectiveStatus(dto.objectiveId);
return keyResult;
}
async updateProgress(id: number, dto: UpdateProgressDto, user: JwtUser): Promise<KeyResultWithObjective> {
const existing = await this.prisma.keyResult.findUnique({ where: { id }, include: keyResultInclude });
if (existing === null) {
throw new NotFoundException('Key result not found');
}
this.assertCanWrite(existing.objective.ownerId, user);
const updated = await this.prisma.$transaction(async (tx) => {
const keyResult = await tx.keyResult.update({
where: { id },
data: { progress: dto.progress },
include: keyResultInclude,
});
await tx.progressUpdate.create({
data: {
keyResultId: id,
progress: dto.progress,
comment: dto.comment,
createdById: user.sub,
},
});
return keyResult;
});
await this.recalculateObjectiveStatus(existing.objectiveId);
return updated;
}
private assertCanRead(ownerId: number, user: JwtUser): void {
if (user.role === 'EMPLOYEE' && ownerId !== user.sub) {
throw new ForbiddenException('Key result belongs to another owner');
}
}
private assertCanWrite(ownerId: number, user: JwtUser): void {
if (user.role === 'EMPLOYEE' && ownerId !== user.sub) {
throw new ForbiddenException('Only the owner can update this key result');
}
}
private async recalculateObjectiveStatus(objectiveId: number): Promise<void> {
const keyResults = await this.prisma.keyResult.findMany({ where: { objectiveId } });
const average =
keyResults.length === 0
? 0
: Math.round(keyResults.reduce((total, keyResult) => total + keyResult.progress, 0) / keyResults.length);
const status =
average === 0 ? 'NOT_STARTED' : average >= 100 ? 'COMPLETED' : 'IN_PROGRESS';
await this.prisma.objective.update({ where: { id: objectiveId }, data: { status } });
}
}
+27
View File
@@ -0,0 +1,27 @@
import 'reflect-metadata';
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api/v1');
const allowedOrigins = (process.env.FRONTEND_ORIGIN ?? 'http://localhost:5173,http://127.0.0.1:5173')
.split(',')
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0);
app.enableCors({
origin: allowedOrigins,
credentials: true,
});
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.listen(Number(process.env.PORT ?? 3000));
}
void bootstrap();
@@ -0,0 +1,19 @@
import { IsInt, IsNotEmpty, IsOptional, IsPositive, IsString, Matches } from 'class-validator';
export class CreateObjectiveDto {
@IsString()
@IsNotEmpty()
title!: string;
@IsString()
@IsOptional()
description?: string;
@IsInt()
@IsPositive()
ownerId!: number;
@IsString()
@Matches(/^Q[1-4]\/\d{4}$/)
quarter!: string;
}
@@ -0,0 +1,34 @@
import { Body, Controller, Get, Inject, Param, ParseIntPipe, Post, Query, UseGuards, ValidationPipe } from '@nestjs/common';
import { ok } from '../common/api-response.js';
import { CurrentUser } from '../common/current-user.decorator.js';
import type { JwtUser } from '../common/auth.types.js';
import { JwtAuthGuard } from '../common/jwt-auth.guard.js';
import { RolesGuard } from '../common/roles.guard.js';
import { CreateObjectiveDto } from './dto/create-objective.dto.js';
import { ObjectivesService } from './objectives.service.js';
@Controller('objectives')
@UseGuards(JwtAuthGuard, RolesGuard)
export class ObjectivesController {
constructor(@Inject(ObjectivesService) private readonly objectivesService: ObjectivesService) {}
@Get()
async list(@CurrentUser() user: JwtUser, @Query('quarter') quarter?: string) {
const objectives = await this.objectivesService.list(user, quarter);
return ok(objectives, { total: objectives.length });
}
@Get(':id')
async get(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: JwtUser) {
return ok(await this.objectivesService.getById(id, user));
}
@Post()
async create(
@Body(new ValidationPipe({ expectedType: CreateObjectiveDto, whitelist: true, forbidNonWhitelisted: true, transform: true }))
dto: CreateObjectiveDto,
@CurrentUser() user: JwtUser,
) {
return ok(await this.objectivesService.create(dto, user));
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module.js';
import { ObjectivesController } from './objectives.controller.js';
import { ObjectivesService } from './objectives.service.js';
@Module({
imports: [PrismaModule],
controllers: [ObjectivesController],
providers: [ObjectivesService],
exports: [ObjectivesService],
})
export class ObjectivesModule {}
@@ -0,0 +1,69 @@
import { ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type { JwtUser } from '../common/auth.types.js';
import { PrismaService } from '../prisma/prisma.service.js';
import type { CreateObjectiveDto } from './dto/create-objective.dto.js';
const objectiveInclude = {
owner: { select: { id: true, name: true, username: true, email: true, role: true } },
keyResults: { orderBy: { id: 'asc' as const } },
} satisfies Prisma.ObjectiveInclude;
export type ObjectiveWithRelations = Prisma.ObjectiveGetPayload<{ include: typeof objectiveInclude }>;
function averageProgress(keyResults: { progress: number }[]): number {
if (keyResults.length === 0) {
return 0;
}
const total = keyResults.reduce((sum, keyResult) => sum + keyResult.progress, 0);
return Math.round(total / keyResults.length);
}
@Injectable()
export class ObjectivesService {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async list(user: JwtUser, quarter?: string): Promise<ObjectiveWithRelations[]> {
const where: Prisma.ObjectiveWhereInput = {
...(quarter === undefined ? {} : { quarter }),
...(user.role === 'EMPLOYEE' ? { ownerId: user.sub } : {}),
};
return this.prisma.objective.findMany({
where,
include: objectiveInclude,
orderBy: { id: 'asc' },
});
}
async getById(id: number, user: JwtUser): Promise<ObjectiveWithRelations & { computedProgress: number }> {
const objective = await this.prisma.objective.findUnique({ where: { id }, include: objectiveInclude });
if (objective === null) {
throw new NotFoundException('Objective not found');
}
if (user.role === 'EMPLOYEE' && objective.ownerId !== user.sub) {
throw new ForbiddenException('Objective belongs to another owner');
}
return { ...objective, computedProgress: averageProgress(objective.keyResults) };
}
async create(dto: CreateObjectiveDto, user: JwtUser): Promise<ObjectiveWithRelations> {
if (user.role === 'EMPLOYEE' && dto.ownerId !== user.sub) {
throw new ForbiddenException('Employees can create only their own objectives');
}
const owner = await this.prisma.user.findUnique({ where: { id: dto.ownerId } });
if (owner === null) {
throw new NotFoundException('Owner not found');
}
return this.prisma.objective.create({
data: {
title: dto.title,
description: dto.description,
ownerId: dto.ownerId,
quarter: dto.quarter,
status: 'NOT_STARTED',
},
include: objectiveInclude,
});
}
}
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service.js';
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
@@ -0,0 +1,13 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit(): Promise<void> {
await this.$connect();
}
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
}
}
@@ -0,0 +1,18 @@
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { ok } from '../common/api-response.js';
import { JwtAuthGuard } from '../common/jwt-auth.guard.js';
import { Roles } from '../common/roles.decorator.js';
import { RolesGuard } from '../common/roles.guard.js';
import { UsersService } from './users.service.js';
@Controller('users')
@UseGuards(JwtAuthGuard, RolesGuard)
export class UsersController {
constructor(@Inject(UsersService) private readonly usersService: UsersService) {}
@Get()
@Roles('ADMIN', 'MANAGER')
async list() {
return ok(await this.usersService.listUsers());
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module.js';
import { UsersController } from './users.controller.js';
import { UsersService } from './users.service.js';
@Module({
imports: [PrismaModule],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
@@ -0,0 +1,22 @@
import { Inject, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service.js';
export interface PublicUser {
id: number;
name: string;
username: string;
email: string;
role: string;
}
@Injectable()
export class UsersService {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async listUsers(): Promise<PublicUser[]> {
return this.prisma.user.findMany({
orderBy: { id: 'asc' },
select: { id: true, name: true, username: true, email: true, role: true },
});
}
}
@@ -0,0 +1,83 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import request from 'supertest';
import { createTestApp, loginToken } from './helpers.js';
test('HTTP auth, role filtering, validation, and progress update are real', async () => {
const { app, prisma } = await createTestApp();
try {
await request(app.getHttpServer()).get('/api/v1/objectives').expect(401);
const employeeToken = await loginToken(app, 'employee');
const managerToken = await loginToken(app, 'manager');
const employeeObjectives = await request(app.getHttpServer())
.get('/api/v1/objectives')
.set('Authorization', `Bearer ${employeeToken}`)
.expect(200);
assert.equal(employeeObjectives.body.success, true);
assert.deepEqual(
employeeObjectives.body.data.map((objective: { ownerId: number }) => objective.ownerId),
[3],
);
const managerObjectives = await request(app.getHttpServer())
.get('/api/v1/objectives')
.set('Authorization', `Bearer ${managerToken}`)
.expect(200);
assert.equal(managerObjectives.body.data.length, 3);
await request(app.getHttpServer())
.post('/api/v1/objectives')
.set('Authorization', `Bearer ${employeeToken}`)
.send({ title: '', ownerId: 3, quarter: '2026-Q2' })
.expect(400);
const progressResponse = await request(app.getHttpServer())
.patch('/api/v1/key-results/2/progress')
.set('Authorization', `Bearer ${employeeToken}`)
.send({ progress: 70, comment: 'Updated in e2e test' })
.expect(200);
assert.equal(progressResponse.body.data.progress, 70);
await prisma.keyResult.update({ where: { id: 2 }, data: { progress: 60 } });
await prisma.progressUpdate.deleteMany({ where: { keyResultId: 2, progress: 70 } });
} finally {
await app.close();
}
});
test('golden objective list response does not drift', async () => {
const { app } = await createTestApp();
try {
const token = await loginToken(app, 'manager');
const response = await request(app.getHttpServer())
.get('/api/v1/objectives')
.set('Authorization', `Bearer ${token}`)
.expect(200);
const canonical = JSON.stringify(
{
success: response.body.success,
data: response.body.data.map((objective: { id: number; title: string; quarter: string; status: string; keyResults: { id: number; title: string; progress: number }[] }) => ({
id: objective.id,
title: objective.title,
quarter: objective.quarter,
status: objective.status,
keyResults: objective.keyResults.map((keyResult) => ({
id: keyResult.id,
title: keyResult.title,
progress: keyResult.progress,
})),
})),
meta: response.body.meta,
},
null,
2,
);
const fixture = await fs.readFile(path.resolve('test/golden/objectives.manager.json'), 'utf8');
assert.equal(`${canonical}\n`, fixture);
} finally {
await app.close();
}
});
@@ -0,0 +1,57 @@
{
"success": true,
"data": [
{
"id": 1,
"title": "POC AI for SQL Injection prevention",
"quarter": "Q2/2026",
"status": "IN_PROGRESS",
"keyResults": [
{
"id": 1,
"title": "Complete 3 POC sessions with security team",
"progress": 33
},
{
"id": 2,
"title": "Reduce manual SQL injection review effort by 30%",
"progress": 60
}
]
},
{
"id": 2,
"title": "AI for All enablement across department",
"quarter": "Q2/2026",
"status": "NOT_STARTED",
"keyResults": [
{
"id": 3,
"title": "Certify 100 department members on AI for All",
"progress": 0
}
]
},
{
"id": 3,
"title": "Improve OKR operating cadence",
"quarter": "Q2/2026",
"status": "IN_PROGRESS",
"keyResults": [
{
"id": 4,
"title": "Reach 90% weekly OKR update compliance",
"progress": 75
},
{
"id": 5,
"title": "Resolve stale OKR reports within two business days",
"progress": 45
}
]
}
],
"meta": {
"total": 3
}
}
@@ -0,0 +1,30 @@
import { ValidationPipe } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import type { INestApplication } from '@nestjs/common';
import { PrismaService } from '../src/prisma/prisma.service.js';
import { AppModule } from '../src/app.module.js';
export async function createTestApp(): Promise<{ app: INestApplication; prisma: PrismaService }> {
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
const app = moduleRef.createNestApplication();
app.setGlobalPrefix('api/v1');
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.init();
return { app, prisma: app.get(PrismaService) };
}
export async function loginToken(app: INestApplication, username = 'employee'): Promise<string> {
const request = await import('supertest');
const response = await request
.default(app.getHttpServer())
.post('/api/v1/auth/login')
.send({ username, password: 'Password@123' })
.expect(201);
return response.body.data.token as string;
}
@@ -0,0 +1,62 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { JwtService } from '@nestjs/jwt';
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { AuthService } from '../src/auth/auth.service.js';
import type { JwtUser } from '../src/common/auth.types.js';
import { KeyResultsService } from '../src/key-results/key-results.service.js';
import { ObjectivesService } from '../src/objectives/objectives.service.js';
import { PrismaService } from '../src/prisma/prisma.service.js';
const employeeUser: JwtUser = {
sub: 3,
email: 'employee@okr.local',
role: 'EMPLOYEE',
name: 'Nguyen Van A',
};
const managerUser: JwtUser = {
sub: 2,
email: 'manager@okr.local',
role: 'MANAGER',
name: 'Nguyen Van Manager',
};
test('AuthService rejects invalid passwords and signs valid users', async () => {
const prisma = new PrismaService();
await prisma.$connect();
const service = new AuthService(prisma, new JwtService({ secret: 'test-secret' }));
await assert.rejects(() => service.login('employee', 'wrong-password'), UnauthorizedException);
const result = await service.login('employee', 'Password@123');
assert.equal(result.user.email, 'employee@okr.local');
assert.ok(result.token.length > 20);
await prisma.$disconnect();
});
test('ObjectivesService applies employee role filtering', async () => {
const prisma = new PrismaService();
await prisma.$connect();
const service = new ObjectivesService(prisma);
const employeeObjectives = await service.list(employeeUser);
const managerObjectives = await service.list(managerUser);
assert.deepEqual(employeeObjectives.map((objective) => objective.ownerId), [employeeUser.sub]);
assert.ok(managerObjectives.length > employeeObjectives.length);
await prisma.$disconnect();
});
test('KeyResultsService blocks employee updates to another owner and recalculates owned progress', async () => {
const prisma = new PrismaService();
await prisma.$connect();
const service = new KeyResultsService(prisma);
await assert.rejects(
() => service.updateProgress(3, { progress: 50, comment: 'Not mine' }, employeeUser),
ForbiddenException,
);
const updated = await service.updateProgress(1, { progress: 100, comment: 'Completed' }, employeeUser);
assert.equal(updated.progress, 100);
const objective = await prisma.objective.findUniqueOrThrow({ where: { id: updated.objectiveId } });
assert.equal(objective.status, 'IN_PROGRESS');
await prisma.keyResult.update({ where: { id: 1 }, data: { progress: 33 } });
await prisma.progressUpdate.deleteMany({ where: { keyResultId: 1 } });
await prisma.$disconnect();
});
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"noEmit": false
},
"include": ["src/**/*.ts"],
"exclude": ["test/**/*.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": ".",
"types": ["node"]
},
"include": ["src/**/*.ts", "test/**/*.ts", "prisma/**/*.ts"]
}