feat: implement Phase 1 Foundation for AutoScheduler GTD System
- Initialize npm workspaces monorepo (backend, frontend, shared-types) - Scaffold NestJS backend with modules: Auth, Users, Tasks, Projects, Inbox, Health - Create React frontend with Vite, TailwindCSS, Radix UI - Implement TypeORM entities: User, InboxItem, Task, Project - Add JWT authentication with Passport.js and bcrypt - Build Inbox capture API (POST /inbox, GET /inbox, POST /inbox/:id/process) - Create Inbox UI with quick-add form and GTD processing workflow modal - Configure Docker Compose stack (postgres, redis, backend, frontend) - Add health check endpoint with database/Redis status - Write unit tests for auth and inbox services Phase 1 features complete: - GTD Inbox Capture: Manual tasks via web form quick-add - GTD Processing Workflow: Interactive inbox processing interface Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
46
packages/backend/src/app.module.ts
Normal file
46
packages/backend/src/app.module.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
import { InboxModule } from './modules/inbox/inbox.module';
|
||||
import { TasksModule } from './modules/tasks/tasks.module';
|
||||
import { ProjectsModule } from './modules/projects/projects.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { User } from './modules/users/entities/user.entity';
|
||||
import { InboxItem } from './modules/inbox/entities/inbox-item.entity';
|
||||
import { Task } from './modules/tasks/entities/task.entity';
|
||||
import { Project } from './modules/projects/entities/project.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: ['.env.local', '.env'],
|
||||
}),
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
type: 'postgres',
|
||||
host: configService.get('DB_HOST', 'localhost'),
|
||||
port: configService.get('DB_PORT', 5432),
|
||||
username: configService.get('DB_USERNAME', 'postgres'),
|
||||
password: configService.get('DB_PASSWORD', 'postgres'),
|
||||
database: configService.get('DB_DATABASE', 'autoscheduler'),
|
||||
entities: [User, InboxItem, Task, Project],
|
||||
synchronize: configService.get('NODE_ENV') !== 'production',
|
||||
logging: configService.get('NODE_ENV') !== 'production',
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
InboxModule,
|
||||
TasksModule,
|
||||
ProjectsModule,
|
||||
HealthModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
38
packages/backend/src/common/filters/http-exception.filter.ts
Normal file
38
packages/backend/src/common/filters/http-exception.filter.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
|
||||
@Catch()
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(AllExceptionsFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest();
|
||||
|
||||
const status =
|
||||
exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
const message =
|
||||
exception instanceof HttpException ? exception.message : 'Internal server error';
|
||||
|
||||
this.logger.error(
|
||||
`${request.method} ${request.url} ${status} - ${message}`,
|
||||
exception instanceof Error ? exception.stack : undefined,
|
||||
);
|
||||
|
||||
response.status(status).json({
|
||||
statusCode: status,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
1
packages/backend/src/common/filters/index.ts
Normal file
1
packages/backend/src/common/filters/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './http-exception.filter';
|
||||
40
packages/backend/src/main.ts
Normal file
40
packages/backend/src/main.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe, Logger } from '@nestjs/common';
|
||||
import helmet from 'helmet';
|
||||
import { AppModule } from './app.module';
|
||||
import { AllExceptionsFilter } from './common/filters/http-exception.filter';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('Bootstrap');
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger:
|
||||
process.env.NODE_ENV === 'production'
|
||||
? ['log', 'error', 'warn']
|
||||
: ['log', 'error', 'warn', 'debug', 'verbose'],
|
||||
});
|
||||
|
||||
app.use(helmet());
|
||||
app.enableCors({
|
||||
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.useGlobalFilters(new AllExceptionsFilter());
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
transformOptions: {
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
await app.listen(port);
|
||||
logger.log(`Application is running on port ${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
36
packages/backend/src/modules/auth/auth.controller.ts
Normal file
36
packages/backend/src/modules/auth/auth.controller.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Controller, Post, Body, UseGuards, Request, HttpCode, HttpStatus } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
async register(@Body() registerDto: RegisterDto) {
|
||||
return this.authService.register(registerDto);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async login(@Body() loginDto: LoginDto) {
|
||||
return this.authService.login(loginDto);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async refresh(@Body() refreshDto: RefreshTokenDto) {
|
||||
return this.authService.refresh(refreshDto.refreshToken);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async logout(@Request() req: { user: { userId: string } }) {
|
||||
await this.authService.logout(req.user.userId);
|
||||
return { message: 'Logged out successfully' };
|
||||
}
|
||||
}
|
||||
29
packages/backend/src/modules/auth/auth.module.ts
Normal file
29
packages/backend/src/modules/auth/auth.module.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.get('JWT_SECRET', 'development-secret-change-me'),
|
||||
signOptions: {
|
||||
expiresIn: configService.get('JWT_EXPIRES_IN', '1h'),
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
128
packages/backend/src/modules/auth/auth.service.spec.ts
Normal file
128
packages/backend/src/modules/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ConflictException, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
let usersService: Partial<UsersService>;
|
||||
let jwtService: Partial<JwtService>;
|
||||
|
||||
const mockUser: Partial<User> = {
|
||||
id: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
password: 'hashedPassword',
|
||||
name: 'Test User',
|
||||
timezone: 'UTC',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
usersService = {
|
||||
findByEmail: jest.fn(),
|
||||
create: jest.fn(),
|
||||
validatePassword: jest.fn(),
|
||||
updateRefreshToken: jest.fn(),
|
||||
toResponseDto: jest.fn().mockReturnValue({
|
||||
id: mockUser.id,
|
||||
email: mockUser.email,
|
||||
name: mockUser.name,
|
||||
timezone: mockUser.timezone,
|
||||
createdAt: mockUser.createdAt?.toISOString(),
|
||||
updatedAt: mockUser.updatedAt?.toISOString(),
|
||||
}),
|
||||
};
|
||||
|
||||
jwtService = {
|
||||
sign: jest.fn().mockReturnValue('mock-token'),
|
||||
verify: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuthService,
|
||||
{ provide: UsersService, useValue: usersService },
|
||||
{ provide: JwtService, useValue: jwtService },
|
||||
{ provide: ConfigService, useValue: { get: jest.fn().mockReturnValue('secret') } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AuthService>(AuthService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('register', () => {
|
||||
it('should successfully register a new user', async () => {
|
||||
(usersService.findByEmail as jest.Mock).mockResolvedValue(null);
|
||||
(usersService.create as jest.Mock).mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.register({
|
||||
email: 'test@example.com',
|
||||
password: 'Password123!',
|
||||
name: 'Test User',
|
||||
timezone: 'UTC',
|
||||
});
|
||||
|
||||
expect(result.token).toBe('mock-token');
|
||||
expect(result.user.email).toBe('test@example.com');
|
||||
});
|
||||
|
||||
it('should throw ConflictException if email exists', async () => {
|
||||
(usersService.findByEmail as jest.Mock).mockResolvedValue(mockUser);
|
||||
|
||||
await expect(
|
||||
service.register({
|
||||
email: 'test@example.com',
|
||||
password: 'Password123!',
|
||||
name: 'Test User',
|
||||
timezone: 'UTC',
|
||||
}),
|
||||
).rejects.toThrow(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should successfully login a user', async () => {
|
||||
(usersService.findByEmail as jest.Mock).mockResolvedValue(mockUser);
|
||||
(usersService.validatePassword as jest.Mock).mockResolvedValue(true);
|
||||
|
||||
const result = await service.login({
|
||||
email: 'test@example.com',
|
||||
password: 'Password123!',
|
||||
});
|
||||
|
||||
expect(result.token).toBe('mock-token');
|
||||
expect(result.user.email).toBe('test@example.com');
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException for invalid email', async () => {
|
||||
(usersService.findByEmail as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.login({
|
||||
email: 'nonexistent@example.com',
|
||||
password: 'Password123!',
|
||||
}),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException for invalid password', async () => {
|
||||
(usersService.findByEmail as jest.Mock).mockResolvedValue(mockUser);
|
||||
(usersService.validatePassword as jest.Mock).mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
service.login({
|
||||
email: 'test@example.com',
|
||||
password: 'WrongPassword',
|
||||
}),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
});
|
||||
87
packages/backend/src/modules/auth/auth.service.ts
Normal file
87
packages/backend/src/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Injectable, UnauthorizedException, ConflictException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import type { RegisterDto, LoginDto, AuthResponseDto } from '@nick-tracker/shared-types';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async register(registerDto: RegisterDto): Promise<AuthResponseDto> {
|
||||
const existingUser = await this.usersService.findByEmail(registerDto.email);
|
||||
if (existingUser) {
|
||||
throw new ConflictException('Email already registered');
|
||||
}
|
||||
|
||||
const user = await this.usersService.create(registerDto);
|
||||
const tokens = await this.generateTokens(user.id, user.email);
|
||||
|
||||
await this.usersService.updateRefreshToken(user.id, tokens.refreshToken);
|
||||
|
||||
return {
|
||||
token: tokens.accessToken,
|
||||
user: this.usersService.toResponseDto(user),
|
||||
};
|
||||
}
|
||||
|
||||
async login(loginDto: LoginDto): Promise<AuthResponseDto> {
|
||||
const user = await this.usersService.findByEmail(loginDto.email);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const isPasswordValid = await this.usersService.validatePassword(user, loginDto.password);
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const tokens = await this.generateTokens(user.id, user.email);
|
||||
await this.usersService.updateRefreshToken(user.id, tokens.refreshToken);
|
||||
|
||||
return {
|
||||
token: tokens.accessToken,
|
||||
user: this.usersService.toResponseDto(user),
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string): Promise<{ token: string }> {
|
||||
try {
|
||||
const payload = this.jwtService.verify(refreshToken, {
|
||||
secret: this.configService.get('JWT_REFRESH_SECRET', 'refresh-secret-change-me'),
|
||||
});
|
||||
|
||||
const user = await this.usersService.findById(payload.sub);
|
||||
if (!user || user.refreshToken !== refreshToken) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
|
||||
const tokens = await this.generateTokens(user.id, user.email);
|
||||
await this.usersService.updateRefreshToken(user.id, tokens.refreshToken);
|
||||
|
||||
return { token: tokens.accessToken };
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
}
|
||||
|
||||
async logout(userId: string): Promise<void> {
|
||||
await this.usersService.updateRefreshToken(userId, null);
|
||||
}
|
||||
|
||||
private async generateTokens(userId: string, email: string) {
|
||||
const payload = { sub: userId, email };
|
||||
|
||||
const accessToken = this.jwtService.sign(payload);
|
||||
const refreshToken = this.jwtService.sign(payload, {
|
||||
secret: this.configService.get('JWT_REFRESH_SECRET', 'refresh-secret-change-me'),
|
||||
expiresIn: '7d',
|
||||
});
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
}
|
||||
9
packages/backend/src/modules/auth/dto/login.dto.ts
Normal file
9
packages/backend/src/modules/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { IsEmail, IsString } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@IsString()
|
||||
refreshToken: string;
|
||||
}
|
||||
23
packages/backend/src/modules/auth/dto/register.dto.ts
Normal file
23
packages/backend/src/modules/auth/dto/register.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { IsEmail, IsString, MinLength, MaxLength, Matches } from 'class-validator';
|
||||
|
||||
export class RegisterDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(100)
|
||||
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
|
||||
message:
|
||||
'Password must contain at least one uppercase letter, one lowercase letter, and one number',
|
||||
})
|
||||
password: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
timezone: string;
|
||||
}
|
||||
16
packages/backend/src/modules/auth/guards/jwt-auth.guard.ts
Normal file
16
packages/backend/src/modules/auth/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
canActivate(context: ExecutionContext) {
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
handleRequest<TUser>(err: Error | null, user: TUser): TUser {
|
||||
if (err || !user) {
|
||||
throw err || new UnauthorizedException();
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
32
packages/backend/src/modules/auth/strategies/jwt.strategy.ts
Normal file
32
packages/backend/src/modules/auth/strategies/jwt.strategy.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
|
||||
interface JwtPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly usersService: UsersService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get('JWT_SECRET', 'development-secret-change-me'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload) {
|
||||
const user = await this.usersService.findById(payload.sub);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return { userId: payload.sub, email: payload.email };
|
||||
}
|
||||
}
|
||||
12
packages/backend/src/modules/health/health.controller.ts
Normal file
12
packages/backend/src/modules/health/health.controller.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
@Controller()
|
||||
export class HealthController {
|
||||
constructor(private readonly healthService: HealthService) {}
|
||||
|
||||
@Get('health')
|
||||
async check() {
|
||||
return this.healthService.check();
|
||||
}
|
||||
}
|
||||
9
packages/backend/src/modules/health/health.module.ts
Normal file
9
packages/backend/src/modules/health/health.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
providers: [HealthService],
|
||||
})
|
||||
export class HealthModule {}
|
||||
67
packages/backend/src/modules/health/health.service.ts
Normal file
67
packages/backend/src/modules/health/health.service.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Redis from 'ioredis';
|
||||
import type { HealthCheckResponseDto } from '@nick-tracker/shared-types';
|
||||
|
||||
@Injectable()
|
||||
export class HealthService {
|
||||
private readonly logger = new Logger(HealthService.name);
|
||||
private redis: Redis | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.initRedis();
|
||||
}
|
||||
|
||||
private initRedis() {
|
||||
const redisUrl = this.configService.get<string>('REDIS_URL');
|
||||
if (redisUrl) {
|
||||
try {
|
||||
this.redis = new Redis(redisUrl);
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to connect to Redis:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async check(): Promise<HealthCheckResponseDto> {
|
||||
const dbStatus = await this.checkDatabase();
|
||||
const redisStatus = await this.checkRedis();
|
||||
|
||||
return {
|
||||
status: dbStatus === 'connected' && redisStatus === 'connected' ? 'ok' : 'error',
|
||||
database: dbStatus,
|
||||
redis: redisStatus,
|
||||
};
|
||||
}
|
||||
|
||||
private async checkDatabase(): Promise<'connected' | 'disconnected'> {
|
||||
try {
|
||||
if (this.dataSource.isInitialized) {
|
||||
await this.dataSource.query('SELECT 1');
|
||||
return 'connected';
|
||||
}
|
||||
return 'disconnected';
|
||||
} catch (error) {
|
||||
this.logger.warn('Database health check failed:', error);
|
||||
return 'disconnected';
|
||||
}
|
||||
}
|
||||
|
||||
private async checkRedis(): Promise<'connected' | 'disconnected'> {
|
||||
if (!this.redis) {
|
||||
return 'disconnected';
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.redis.ping();
|
||||
return result === 'PONG' ? 'connected' : 'disconnected';
|
||||
} catch (error) {
|
||||
this.logger.warn('Redis health check failed:', error);
|
||||
return 'disconnected';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsString, IsOptional, IsObject, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateInboxItemDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
content: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
sourceMetadata?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsNumber,
|
||||
IsUUID,
|
||||
IsDateString,
|
||||
Min,
|
||||
Max,
|
||||
} from 'class-validator';
|
||||
import { ProcessAction, TaskContext, TaskDomain } from '@nick-tracker/shared-types';
|
||||
|
||||
export class ProcessInboxItemDto {
|
||||
@IsEnum(ProcessAction)
|
||||
action: ProcessAction;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(TaskContext)
|
||||
context?: TaskContext;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(TaskDomain)
|
||||
domain?: TaskDomain;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
priority?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(5)
|
||||
estimatedDuration?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dueDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
projectId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
followUpDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
ticklerDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { InboxSource } from '@nick-tracker/shared-types';
|
||||
|
||||
@Entity('inbox_items')
|
||||
export class InboxItem {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column('text')
|
||||
content: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: InboxSource,
|
||||
default: InboxSource.MANUAL,
|
||||
})
|
||||
source: InboxSource;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
sourceMetadata: Record<string, unknown> | null;
|
||||
|
||||
@Column({ default: false })
|
||||
processed: boolean;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.inboxItems, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
46
packages/backend/src/modules/inbox/inbox.controller.ts
Normal file
46
packages/backend/src/modules/inbox/inbox.controller.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Controller, Get, Post, Delete, Body, Param, UseGuards, Request } from '@nestjs/common';
|
||||
import { InboxService } from './inbox.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CreateInboxItemDto } from './dto/create-inbox-item.dto';
|
||||
import { ProcessInboxItemDto } from './dto/process-inbox-item.dto';
|
||||
|
||||
@Controller('inbox')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class InboxController {
|
||||
constructor(private readonly inboxService: InboxService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@Request() req: { user: { userId: string } }) {
|
||||
const items = await this.inboxService.findUnprocessed(req.user.userId);
|
||||
return items.map((item) => this.inboxService.toResponseDto(item));
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@Request() req: { user: { userId: string } },
|
||||
@Body() createDto: CreateInboxItemDto,
|
||||
) {
|
||||
const item = await this.inboxService.create(
|
||||
req.user.userId,
|
||||
createDto.content,
|
||||
undefined,
|
||||
createDto.sourceMetadata,
|
||||
);
|
||||
return this.inboxService.toResponseDto(item);
|
||||
}
|
||||
|
||||
@Post(':id/process')
|
||||
async process(
|
||||
@Request() req: { user: { userId: string } },
|
||||
@Param('id') id: string,
|
||||
@Body() processDto: ProcessInboxItemDto,
|
||||
) {
|
||||
return this.inboxService.process(id, req.user.userId, processDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@Request() req: { user: { userId: string } }, @Param('id') id: string) {
|
||||
await this.inboxService.delete(id, req.user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
15
packages/backend/src/modules/inbox/inbox.module.ts
Normal file
15
packages/backend/src/modules/inbox/inbox.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { InboxItem } from './entities/inbox-item.entity';
|
||||
import { InboxService } from './inbox.service';
|
||||
import { InboxController } from './inbox.controller';
|
||||
import { TasksModule } from '../tasks/tasks.module';
|
||||
import { ProjectsModule } from '../projects/projects.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([InboxItem]), TasksModule, ProjectsModule],
|
||||
controllers: [InboxController],
|
||||
providers: [InboxService],
|
||||
exports: [InboxService],
|
||||
})
|
||||
export class InboxModule {}
|
||||
147
packages/backend/src/modules/inbox/inbox.service.spec.ts
Normal file
147
packages/backend/src/modules/inbox/inbox.service.spec.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InboxService } from './inbox.service';
|
||||
import { InboxItem } from './entities/inbox-item.entity';
|
||||
import { TasksService } from '../tasks/tasks.service';
|
||||
import { ProjectsService } from '../projects/projects.service';
|
||||
import { InboxSource, ProcessAction, TaskDomain, TaskContext } from '@nick-tracker/shared-types';
|
||||
|
||||
describe('InboxService', () => {
|
||||
let service: InboxService;
|
||||
let repository: Partial<Repository<InboxItem>>;
|
||||
let tasksService: Partial<TasksService>;
|
||||
let projectsService: Partial<ProjectsService>;
|
||||
|
||||
const mockInboxItem: Partial<InboxItem> = {
|
||||
id: 'test-inbox-id',
|
||||
content: 'Test inbox item',
|
||||
source: InboxSource.MANUAL,
|
||||
processed: false,
|
||||
userId: 'test-user-id',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
repository = {
|
||||
create: jest.fn().mockReturnValue({ ...mockInboxItem }),
|
||||
save: jest.fn().mockImplementation((item) => Promise.resolve({ ...item })),
|
||||
find: jest.fn().mockResolvedValue([{ ...mockInboxItem }]),
|
||||
findOne: jest.fn().mockResolvedValue({ ...mockInboxItem }),
|
||||
remove: jest.fn().mockResolvedValue({ ...mockInboxItem }),
|
||||
};
|
||||
|
||||
tasksService = {
|
||||
create: jest.fn().mockResolvedValue({ id: 'task-id' }),
|
||||
};
|
||||
|
||||
projectsService = {
|
||||
create: jest.fn().mockResolvedValue({ id: 'project-id' }),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
InboxService,
|
||||
{ provide: getRepositoryToken(InboxItem), useValue: repository },
|
||||
{ provide: TasksService, useValue: tasksService },
|
||||
{ provide: ProjectsService, useValue: projectsService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<InboxService>(InboxService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create an inbox item', async () => {
|
||||
const result = await service.create('test-user-id', 'Test content');
|
||||
|
||||
expect(repository.create).toHaveBeenCalledWith({
|
||||
userId: 'test-user-id',
|
||||
content: 'Test content',
|
||||
source: InboxSource.MANUAL,
|
||||
sourceMetadata: null,
|
||||
processed: false,
|
||||
});
|
||||
expect(repository.save).toHaveBeenCalled();
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findUnprocessed', () => {
|
||||
it('should return unprocessed items', async () => {
|
||||
const result = await service.findUnprocessed('test-user-id');
|
||||
|
||||
expect(repository.find).toHaveBeenCalledWith({
|
||||
where: { userId: 'test-user-id', processed: false },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('process', () => {
|
||||
it('should process inbox item to task', async () => {
|
||||
const result = await service.process('test-inbox-id', 'test-user-id', {
|
||||
action: ProcessAction.TASK,
|
||||
domain: TaskDomain.WORK,
|
||||
context: TaskContext.DESK,
|
||||
});
|
||||
|
||||
expect(tasksService.create).toHaveBeenCalled();
|
||||
expect(repository.save).toHaveBeenCalled();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.resultId).toBe('task-id');
|
||||
});
|
||||
|
||||
it('should process inbox item to project', async () => {
|
||||
const result = await service.process('test-inbox-id', 'test-user-id', {
|
||||
action: ProcessAction.PROJECT,
|
||||
domain: TaskDomain.WORK,
|
||||
});
|
||||
|
||||
expect(projectsService.create).toHaveBeenCalled();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.resultId).toBe('project-id');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent item', async () => {
|
||||
(repository.findOne as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.process('non-existent', 'test-user-id', {
|
||||
action: ProcessAction.TRASH,
|
||||
}),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException for already processed item', async () => {
|
||||
(repository.findOne as jest.Mock).mockResolvedValue({
|
||||
...mockInboxItem,
|
||||
processed: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.process('test-inbox-id', 'test-user-id', {
|
||||
action: ProcessAction.TRASH,
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toResponseDto', () => {
|
||||
it('should convert entity to response DTO', () => {
|
||||
const result = service.toResponseDto(mockInboxItem as InboxItem);
|
||||
|
||||
expect(result.id).toBe('test-inbox-id');
|
||||
expect(result.content).toBe('Test inbox item');
|
||||
expect(result.source).toBe(InboxSource.MANUAL);
|
||||
expect(result.processed).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
151
packages/backend/src/modules/inbox/inbox.service.ts
Normal file
151
packages/backend/src/modules/inbox/inbox.service.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { InboxItem } from './entities/inbox-item.entity';
|
||||
import { TasksService } from '../tasks/tasks.service';
|
||||
import { ProjectsService } from '../projects/projects.service';
|
||||
import {
|
||||
InboxSource,
|
||||
ProcessAction,
|
||||
TaskStatus,
|
||||
type InboxItemResponseDto,
|
||||
type ProcessInboxItemDto,
|
||||
} from '@nick-tracker/shared-types';
|
||||
|
||||
@Injectable()
|
||||
export class InboxService {
|
||||
constructor(
|
||||
@InjectRepository(InboxItem)
|
||||
private readonly inboxRepository: Repository<InboxItem>,
|
||||
private readonly tasksService: TasksService,
|
||||
private readonly projectsService: ProjectsService,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
userId: string,
|
||||
content: string,
|
||||
source: InboxSource = InboxSource.MANUAL,
|
||||
sourceMetadata?: Record<string, unknown>,
|
||||
): Promise<InboxItem> {
|
||||
const item = this.inboxRepository.create({
|
||||
userId,
|
||||
content,
|
||||
source,
|
||||
sourceMetadata: sourceMetadata ?? null,
|
||||
processed: false,
|
||||
});
|
||||
return this.inboxRepository.save(item);
|
||||
}
|
||||
|
||||
async findUnprocessed(userId: string): Promise<InboxItem[]> {
|
||||
return this.inboxRepository.find({
|
||||
where: { userId, processed: false },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<InboxItem | null> {
|
||||
return this.inboxRepository.findOne({
|
||||
where: { id, userId },
|
||||
});
|
||||
}
|
||||
|
||||
async process(
|
||||
id: string,
|
||||
userId: string,
|
||||
processDto: ProcessInboxItemDto,
|
||||
): Promise<{ success: boolean; resultId?: string }> {
|
||||
const item = await this.findById(id, userId);
|
||||
if (!item) {
|
||||
throw new NotFoundException('Inbox item not found');
|
||||
}
|
||||
|
||||
if (item.processed) {
|
||||
throw new BadRequestException('Inbox item already processed');
|
||||
}
|
||||
|
||||
let resultId: string | undefined;
|
||||
|
||||
switch (processDto.action) {
|
||||
case ProcessAction.TASK:
|
||||
case ProcessAction.WAITING_FOR:
|
||||
case ProcessAction.SOMEDAY_MAYBE:
|
||||
case ProcessAction.TICKLER: {
|
||||
const title = processDto.title || item.content;
|
||||
const status = this.getTaskStatusFromAction(processDto.action);
|
||||
const task = await this.tasksService.create(userId, {
|
||||
title,
|
||||
domain: processDto.domain!,
|
||||
context: processDto.context,
|
||||
priority: processDto.priority,
|
||||
estimatedDuration: processDto.estimatedDuration,
|
||||
dueDate: processDto.dueDate,
|
||||
projectId: processDto.projectId,
|
||||
notes: processDto.notes,
|
||||
status,
|
||||
followUpDate: processDto.followUpDate,
|
||||
ticklerDate: processDto.ticklerDate,
|
||||
});
|
||||
resultId = task.id;
|
||||
break;
|
||||
}
|
||||
|
||||
case ProcessAction.PROJECT: {
|
||||
const name = processDto.title || item.content;
|
||||
const project = await this.projectsService.create(userId, {
|
||||
name,
|
||||
domain: processDto.domain!,
|
||||
});
|
||||
resultId = project.id;
|
||||
break;
|
||||
}
|
||||
|
||||
case ProcessAction.REFERENCE:
|
||||
case ProcessAction.TRASH:
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new BadRequestException('Invalid action');
|
||||
}
|
||||
|
||||
item.processed = true;
|
||||
await this.inboxRepository.save(item);
|
||||
|
||||
return { success: true, resultId };
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
const item = await this.findById(id, userId);
|
||||
if (!item) {
|
||||
throw new NotFoundException('Inbox item not found');
|
||||
}
|
||||
await this.inboxRepository.remove(item);
|
||||
}
|
||||
|
||||
private getTaskStatusFromAction(action: ProcessAction): TaskStatus {
|
||||
switch (action) {
|
||||
case ProcessAction.TASK:
|
||||
return TaskStatus.NEXT_ACTION;
|
||||
case ProcessAction.WAITING_FOR:
|
||||
return TaskStatus.WAITING_FOR;
|
||||
case ProcessAction.SOMEDAY_MAYBE:
|
||||
return TaskStatus.SOMEDAY_MAYBE;
|
||||
case ProcessAction.TICKLER:
|
||||
return TaskStatus.TICKLER;
|
||||
default:
|
||||
return TaskStatus.NEXT_ACTION;
|
||||
}
|
||||
}
|
||||
|
||||
toResponseDto(item: InboxItem): InboxItemResponseDto {
|
||||
return {
|
||||
id: item.id,
|
||||
content: item.content,
|
||||
source: item.source,
|
||||
sourceMetadata: item.sourceMetadata ?? undefined,
|
||||
processed: item.processed,
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
updatedAt: item.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { IsString, IsEnum, IsOptional, MinLength } from 'class-validator';
|
||||
import { TaskDomain } from '@nick-tracker/shared-types';
|
||||
|
||||
export class CreateProjectDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name: string;
|
||||
|
||||
@IsEnum(TaskDomain)
|
||||
domain: TaskDomain;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
desiredOutcome?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
connectwiseProjectId?: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IsEnum, IsOptional } from 'class-validator';
|
||||
import { TaskDomain, ProjectStatus } from '@nick-tracker/shared-types';
|
||||
|
||||
export class ProjectFilterDto {
|
||||
@IsOptional()
|
||||
@IsEnum(ProjectStatus)
|
||||
status?: ProjectStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(TaskDomain)
|
||||
domain?: TaskDomain;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { IsString, IsEnum, IsOptional, MinLength } from 'class-validator';
|
||||
import { TaskDomain, ProjectStatus } from '@nick-tracker/shared-types';
|
||||
|
||||
export class UpdateProjectDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(TaskDomain)
|
||||
domain?: TaskDomain;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
desiredOutcome?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ProjectStatus)
|
||||
status?: ProjectStatus;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { Task } from '../../tasks/entities/task.entity';
|
||||
import { TaskDomain, ProjectStatus } from '@nick-tracker/shared-types';
|
||||
|
||||
@Entity('projects')
|
||||
export class Project {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
name: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: TaskDomain,
|
||||
})
|
||||
domain: TaskDomain;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
desiredOutcome: string | null;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ProjectStatus,
|
||||
default: ProjectStatus.ACTIVE,
|
||||
})
|
||||
status: ProjectStatus;
|
||||
|
||||
@Column({ nullable: true })
|
||||
connectwiseProjectId: string | null;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.projects, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@OneToMany(() => Task, (task) => task.project)
|
||||
tasks: Task[];
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
70
packages/backend/src/modules/projects/projects.controller.ts
Normal file
70
packages/backend/src/modules/projects/projects.controller.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { ProjectsService } from './projects.service';
|
||||
import { TasksService } from '../tasks/tasks.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CreateProjectDto } from './dto/create-project.dto';
|
||||
import { UpdateProjectDto } from './dto/update-project.dto';
|
||||
import { ProjectFilterDto } from './dto/project-filter.dto';
|
||||
|
||||
@Controller('projects')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ProjectsController {
|
||||
constructor(
|
||||
private readonly projectsService: ProjectsService,
|
||||
private readonly tasksService: TasksService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@Request() req: { user: { userId: string } }, @Query() filter: ProjectFilterDto) {
|
||||
const projects = await this.projectsService.findAll(req.user.userId, filter);
|
||||
return projects.map((project) => this.projectsService.toResponseDto(project));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Request() req: { user: { userId: string } }, @Param('id') id: string) {
|
||||
const project = await this.projectsService.findById(id, req.user.userId);
|
||||
if (!project) {
|
||||
throw new Error('Project not found');
|
||||
}
|
||||
return this.projectsService.toResponseDto(project);
|
||||
}
|
||||
|
||||
@Get(':id/tasks')
|
||||
async getTasks(@Request() req: { user: { userId: string } }, @Param('id') id: string) {
|
||||
const tasks = await this.projectsService.getProjectTasks(id, req.user.userId);
|
||||
return tasks.map((task) => this.tasksService.toResponseDto(task));
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@Request() req: { user: { userId: string } }, @Body() createDto: CreateProjectDto) {
|
||||
const project = await this.projectsService.create(req.user.userId, createDto);
|
||||
return this.projectsService.toResponseDto(project);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@Request() req: { user: { userId: string } },
|
||||
@Param('id') id: string,
|
||||
@Body() updateDto: UpdateProjectDto,
|
||||
) {
|
||||
const project = await this.projectsService.update(id, req.user.userId, updateDto);
|
||||
return this.projectsService.toResponseDto(project);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@Request() req: { user: { userId: string } }, @Param('id') id: string) {
|
||||
await this.projectsService.delete(id, req.user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
14
packages/backend/src/modules/projects/projects.module.ts
Normal file
14
packages/backend/src/modules/projects/projects.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Project } from './entities/project.entity';
|
||||
import { ProjectsService } from './projects.service';
|
||||
import { ProjectsController } from './projects.controller';
|
||||
import { TasksModule } from '../tasks/tasks.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Project]), forwardRef(() => TasksModule)],
|
||||
controllers: [ProjectsController],
|
||||
providers: [ProjectsService],
|
||||
exports: [ProjectsService],
|
||||
})
|
||||
export class ProjectsModule {}
|
||||
96
packages/backend/src/modules/projects/projects.service.ts
Normal file
96
packages/backend/src/modules/projects/projects.service.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Injectable, NotFoundException, Inject, forwardRef } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
import { Project } from './entities/project.entity';
|
||||
import { TasksService } from '../tasks/tasks.service';
|
||||
import {
|
||||
ProjectStatus,
|
||||
TaskDomain,
|
||||
type CreateProjectDto,
|
||||
type UpdateProjectDto,
|
||||
type ProjectResponseDto,
|
||||
} from '@nick-tracker/shared-types';
|
||||
|
||||
@Injectable()
|
||||
export class ProjectsService {
|
||||
constructor(
|
||||
@InjectRepository(Project)
|
||||
private readonly projectRepository: Repository<Project>,
|
||||
@Inject(forwardRef(() => TasksService))
|
||||
private readonly tasksService: TasksService,
|
||||
) {}
|
||||
|
||||
async create(userId: string, createDto: CreateProjectDto): Promise<Project> {
|
||||
const project = this.projectRepository.create({
|
||||
...createDto,
|
||||
userId,
|
||||
status: ProjectStatus.ACTIVE,
|
||||
});
|
||||
return this.projectRepository.save(project);
|
||||
}
|
||||
|
||||
async findAll(
|
||||
userId: string,
|
||||
filter?: { status?: ProjectStatus; domain?: TaskDomain },
|
||||
): Promise<Project[]> {
|
||||
const where: FindOptionsWhere<Project> = { userId };
|
||||
|
||||
if (filter?.status) {
|
||||
where.status = filter.status;
|
||||
}
|
||||
if (filter?.domain) {
|
||||
where.domain = filter.domain;
|
||||
}
|
||||
|
||||
return this.projectRepository.find({
|
||||
where,
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<Project | null> {
|
||||
return this.projectRepository.findOne({
|
||||
where: { id, userId },
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, updateDto: UpdateProjectDto): Promise<Project> {
|
||||
const project = await this.findById(id, userId);
|
||||
if (!project) {
|
||||
throw new NotFoundException('Project not found');
|
||||
}
|
||||
|
||||
Object.assign(project, updateDto);
|
||||
return this.projectRepository.save(project);
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
const project = await this.findById(id, userId);
|
||||
if (!project) {
|
||||
throw new NotFoundException('Project not found');
|
||||
}
|
||||
await this.projectRepository.remove(project);
|
||||
}
|
||||
|
||||
async getProjectTasks(id: string, userId: string) {
|
||||
const project = await this.findById(id, userId);
|
||||
if (!project) {
|
||||
throw new NotFoundException('Project not found');
|
||||
}
|
||||
return this.tasksService.findByProject(id, userId);
|
||||
}
|
||||
|
||||
toResponseDto(project: Project): ProjectResponseDto {
|
||||
return {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
domain: project.domain,
|
||||
description: project.description ?? undefined,
|
||||
desiredOutcome: project.desiredOutcome ?? undefined,
|
||||
status: project.status,
|
||||
connectwiseProjectId: project.connectwiseProjectId ?? undefined,
|
||||
createdAt: project.createdAt.toISOString(),
|
||||
updatedAt: project.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
60
packages/backend/src/modules/tasks/dto/create-task.dto.ts
Normal file
60
packages/backend/src/modules/tasks/dto/create-task.dto.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
IsString,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
IsUUID,
|
||||
IsDateString,
|
||||
Min,
|
||||
Max,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { TaskStatus, TaskContext, TaskDomain } from '@nick-tracker/shared-types';
|
||||
|
||||
export class CreateTaskDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
title: string;
|
||||
|
||||
@IsEnum(TaskDomain)
|
||||
domain: TaskDomain;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(TaskContext)
|
||||
context?: TaskContext;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
priority?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(5)
|
||||
estimatedDuration?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dueDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
projectId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(TaskStatus)
|
||||
status?: TaskStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
followUpDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
ticklerDate?: string;
|
||||
}
|
||||
20
packages/backend/src/modules/tasks/dto/task-filter.dto.ts
Normal file
20
packages/backend/src/modules/tasks/dto/task-filter.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { IsEnum, IsOptional, IsUUID } from 'class-validator';
|
||||
import { TaskStatus, TaskContext, TaskDomain } from '@nick-tracker/shared-types';
|
||||
|
||||
export class TaskFilterDto {
|
||||
@IsOptional()
|
||||
@IsEnum(TaskStatus)
|
||||
status?: TaskStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(TaskContext)
|
||||
context?: TaskContext;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(TaskDomain)
|
||||
domain?: TaskDomain;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
projectId?: string;
|
||||
}
|
||||
75
packages/backend/src/modules/tasks/dto/update-task.dto.ts
Normal file
75
packages/backend/src/modules/tasks/dto/update-task.dto.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
IsString,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
IsUUID,
|
||||
IsDateString,
|
||||
IsBoolean,
|
||||
Min,
|
||||
Max,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { TaskStatus, TaskContext, TaskDomain } from '@nick-tracker/shared-types';
|
||||
|
||||
export class UpdateTaskDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(TaskDomain)
|
||||
domain?: TaskDomain;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(TaskContext)
|
||||
context?: TaskContext;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
priority?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(5)
|
||||
estimatedDuration?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dueDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
projectId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(TaskStatus)
|
||||
status?: TaskStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
followUpDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
ticklerDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
scheduledStart?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
scheduledEnd?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isLocked?: boolean;
|
||||
}
|
||||
94
packages/backend/src/modules/tasks/entities/task.entity.ts
Normal file
94
packages/backend/src/modules/tasks/entities/task.entity.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { Project } from '../../projects/entities/project.entity';
|
||||
import { TaskStatus, TaskContext, TaskDomain } from '@nick-tracker/shared-types';
|
||||
|
||||
@Entity('tasks')
|
||||
export class Task {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
title: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: TaskDomain,
|
||||
})
|
||||
domain: TaskDomain;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: TaskContext,
|
||||
nullable: true,
|
||||
})
|
||||
context: TaskContext | null;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: TaskStatus,
|
||||
default: TaskStatus.NEXT_ACTION,
|
||||
})
|
||||
status: TaskStatus;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
priority: number | null;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
estimatedDuration: number | null;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
dueDate: Date | null;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
scheduledStart: Date | null;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
scheduledEnd: Date | null;
|
||||
|
||||
@Column({ default: false })
|
||||
isLocked: boolean;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string | null;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
followUpDate: Date | null;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
ticklerDate: Date | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
connectwisePriority: string | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
connectwiseSLA: string | null;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.tasks, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
projectId: string | null;
|
||||
|
||||
@ManyToOne(() => Project, (project) => project.tasks, { onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'projectId' })
|
||||
project: Project | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
72
packages/backend/src/modules/tasks/tasks.controller.ts
Normal file
72
packages/backend/src/modules/tasks/tasks.controller.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { TasksService } from './tasks.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CreateTaskDto } from './dto/create-task.dto';
|
||||
import { UpdateTaskDto } from './dto/update-task.dto';
|
||||
import { TaskFilterDto } from './dto/task-filter.dto';
|
||||
|
||||
@Controller('tasks')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TasksController {
|
||||
constructor(private readonly tasksService: TasksService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@Request() req: { user: { userId: string } }, @Query() filter: TaskFilterDto) {
|
||||
const tasks = await this.tasksService.findAll(req.user.userId, filter);
|
||||
return tasks.map((task) => this.tasksService.toResponseDto(task));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Request() req: { user: { userId: string } }, @Param('id') id: string) {
|
||||
const task = await this.tasksService.findById(id, req.user.userId);
|
||||
if (!task) {
|
||||
throw new Error('Task not found');
|
||||
}
|
||||
return this.tasksService.toResponseDto(task);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@Request() req: { user: { userId: string } }, @Body() createDto: CreateTaskDto) {
|
||||
const task = await this.tasksService.create(req.user.userId, createDto);
|
||||
return this.tasksService.toResponseDto(task);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@Request() req: { user: { userId: string } },
|
||||
@Param('id') id: string,
|
||||
@Body() updateDto: UpdateTaskDto,
|
||||
) {
|
||||
const task = await this.tasksService.update(id, req.user.userId, updateDto);
|
||||
return this.tasksService.toResponseDto(task);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@Request() req: { user: { userId: string } }, @Param('id') id: string) {
|
||||
await this.tasksService.delete(id, req.user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post(':id/lock')
|
||||
async lock(@Request() req: { user: { userId: string } }, @Param('id') id: string) {
|
||||
const task = await this.tasksService.lock(id, req.user.userId);
|
||||
return this.tasksService.toResponseDto(task);
|
||||
}
|
||||
|
||||
@Post(':id/unlock')
|
||||
async unlock(@Request() req: { user: { userId: string } }, @Param('id') id: string) {
|
||||
const task = await this.tasksService.unlock(id, req.user.userId);
|
||||
return this.tasksService.toResponseDto(task);
|
||||
}
|
||||
}
|
||||
14
packages/backend/src/modules/tasks/tasks.module.ts
Normal file
14
packages/backend/src/modules/tasks/tasks.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Task } from './entities/task.entity';
|
||||
import { TasksService } from './tasks.service';
|
||||
import { TasksController } from './tasks.controller';
|
||||
import { ProjectsModule } from '../projects/projects.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Task]), forwardRef(() => ProjectsModule)],
|
||||
controllers: [TasksController],
|
||||
providers: [TasksService],
|
||||
exports: [TasksService],
|
||||
})
|
||||
export class TasksModule {}
|
||||
149
packages/backend/src/modules/tasks/tasks.service.ts
Normal file
149
packages/backend/src/modules/tasks/tasks.service.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
import { Task } from './entities/task.entity';
|
||||
import {
|
||||
TaskStatus,
|
||||
type CreateTaskDto,
|
||||
type UpdateTaskDto,
|
||||
type TaskResponseDto,
|
||||
type TaskFilterDto,
|
||||
} from '@nick-tracker/shared-types';
|
||||
|
||||
@Injectable()
|
||||
export class TasksService {
|
||||
constructor(
|
||||
@InjectRepository(Task)
|
||||
private readonly taskRepository: Repository<Task>,
|
||||
) {}
|
||||
|
||||
async create(userId: string, createDto: CreateTaskDto): Promise<Task> {
|
||||
const task = this.taskRepository.create({
|
||||
...createDto,
|
||||
userId,
|
||||
dueDate: createDto.dueDate ? new Date(createDto.dueDate) : null,
|
||||
followUpDate: createDto.followUpDate ? new Date(createDto.followUpDate) : null,
|
||||
ticklerDate: createDto.ticklerDate ? new Date(createDto.ticklerDate) : null,
|
||||
status: createDto.status || TaskStatus.NEXT_ACTION,
|
||||
});
|
||||
return this.taskRepository.save(task);
|
||||
}
|
||||
|
||||
async findAll(userId: string, filter?: TaskFilterDto): Promise<Task[]> {
|
||||
const where: FindOptionsWhere<Task> = { userId };
|
||||
|
||||
if (filter?.status) {
|
||||
where.status = filter.status;
|
||||
}
|
||||
if (filter?.context) {
|
||||
where.context = filter.context;
|
||||
}
|
||||
if (filter?.domain) {
|
||||
where.domain = filter.domain;
|
||||
}
|
||||
if (filter?.projectId) {
|
||||
where.projectId = filter.projectId;
|
||||
}
|
||||
|
||||
return this.taskRepository.find({
|
||||
where,
|
||||
order: { priority: 'ASC', createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<Task | null> {
|
||||
return this.taskRepository.findOne({
|
||||
where: { id, userId },
|
||||
});
|
||||
}
|
||||
|
||||
async findByProject(projectId: string, userId: string): Promise<Task[]> {
|
||||
return this.taskRepository.find({
|
||||
where: { projectId, userId },
|
||||
order: { priority: 'ASC', createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, updateDto: UpdateTaskDto): Promise<Task> {
|
||||
const task = await this.findById(id, userId);
|
||||
if (!task) {
|
||||
throw new NotFoundException('Task not found');
|
||||
}
|
||||
|
||||
Object.assign(task, {
|
||||
...updateDto,
|
||||
dueDate:
|
||||
updateDto.dueDate !== undefined
|
||||
? updateDto.dueDate
|
||||
? new Date(updateDto.dueDate)
|
||||
: null
|
||||
: task.dueDate,
|
||||
followUpDate:
|
||||
updateDto.followUpDate !== undefined
|
||||
? updateDto.followUpDate
|
||||
? new Date(updateDto.followUpDate)
|
||||
: null
|
||||
: task.followUpDate,
|
||||
ticklerDate:
|
||||
updateDto.ticklerDate !== undefined
|
||||
? updateDto.ticklerDate
|
||||
? new Date(updateDto.ticklerDate)
|
||||
: null
|
||||
: task.ticklerDate,
|
||||
scheduledStart:
|
||||
updateDto.scheduledStart !== undefined
|
||||
? updateDto.scheduledStart
|
||||
? new Date(updateDto.scheduledStart)
|
||||
: null
|
||||
: task.scheduledStart,
|
||||
scheduledEnd:
|
||||
updateDto.scheduledEnd !== undefined
|
||||
? updateDto.scheduledEnd
|
||||
? new Date(updateDto.scheduledEnd)
|
||||
: null
|
||||
: task.scheduledEnd,
|
||||
});
|
||||
|
||||
return this.taskRepository.save(task);
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
const task = await this.findById(id, userId);
|
||||
if (!task) {
|
||||
throw new NotFoundException('Task not found');
|
||||
}
|
||||
await this.taskRepository.remove(task);
|
||||
}
|
||||
|
||||
async lock(id: string, userId: string): Promise<Task> {
|
||||
return this.update(id, userId, { isLocked: true });
|
||||
}
|
||||
|
||||
async unlock(id: string, userId: string): Promise<Task> {
|
||||
return this.update(id, userId, { isLocked: false });
|
||||
}
|
||||
|
||||
toResponseDto(task: Task): TaskResponseDto {
|
||||
return {
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
domain: task.domain,
|
||||
context: task.context ?? undefined,
|
||||
status: task.status,
|
||||
priority: task.priority ?? undefined,
|
||||
estimatedDuration: task.estimatedDuration ?? undefined,
|
||||
dueDate: task.dueDate?.toISOString(),
|
||||
scheduledStart: task.scheduledStart?.toISOString(),
|
||||
scheduledEnd: task.scheduledEnd?.toISOString(),
|
||||
isLocked: task.isLocked,
|
||||
projectId: task.projectId ?? undefined,
|
||||
notes: task.notes ?? undefined,
|
||||
followUpDate: task.followUpDate?.toISOString(),
|
||||
ticklerDate: task.ticklerDate?.toISOString(),
|
||||
connectwisePriority: task.connectwisePriority ?? undefined,
|
||||
connectwiseSLA: task.connectwiseSLA ?? undefined,
|
||||
createdAt: task.createdAt.toISOString(),
|
||||
updatedAt: task.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsNumber,
|
||||
IsBoolean,
|
||||
ValidateNested,
|
||||
Min,
|
||||
Max,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
class DayHoursDto {
|
||||
@IsString()
|
||||
start: string;
|
||||
|
||||
@IsString()
|
||||
end: string;
|
||||
}
|
||||
|
||||
class WorkingHoursDto {
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => DayHoursDto)
|
||||
monday?: DayHoursDto;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => DayHoursDto)
|
||||
tuesday?: DayHoursDto;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => DayHoursDto)
|
||||
wednesday?: DayHoursDto;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => DayHoursDto)
|
||||
thursday?: DayHoursDto;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => DayHoursDto)
|
||||
friday?: DayHoursDto;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => DayHoursDto)
|
||||
saturday?: DayHoursDto;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => DayHoursDto)
|
||||
sunday?: DayHoursDto;
|
||||
}
|
||||
|
||||
class NotificationPreferencesDto {
|
||||
@IsBoolean()
|
||||
email: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
webhook: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
webhookUrl?: string;
|
||||
}
|
||||
|
||||
export class UpdateUserPreferencesDto {
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => WorkingHoursDto)
|
||||
workingHours?: WorkingHoursDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
timezone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => NotificationPreferencesDto)
|
||||
notificationPreferences?: NotificationPreferencesDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(6)
|
||||
weeklyReviewDay?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
weeklyReviewTime?: string;
|
||||
}
|
||||
63
packages/backend/src/modules/users/entities/user.entity.ts
Normal file
63
packages/backend/src/modules/users/entities/user.entity.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { InboxItem } from '../../inbox/entities/inbox-item.entity';
|
||||
import { Task } from '../../tasks/entities/task.entity';
|
||||
import { Project } from '../../projects/entities/project.entity';
|
||||
import type { WorkingHours, NotificationPreferences } from '@nick-tracker/shared-types';
|
||||
|
||||
@Entity('users')
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
email: string;
|
||||
|
||||
@Column()
|
||||
password: string;
|
||||
|
||||
@Column()
|
||||
name: string;
|
||||
|
||||
@Column({ default: 'UTC' })
|
||||
timezone: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
workingHours: WorkingHours | null;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
notificationPreferences: NotificationPreferences | null;
|
||||
|
||||
@Column({ type: 'int', default: 5 })
|
||||
weeklyReviewDay: number;
|
||||
|
||||
@Column({ default: '16:00' })
|
||||
weeklyReviewTime: string;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
lastWeeklyReview: Date | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
refreshToken: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
@OneToMany(() => InboxItem, (inbox) => inbox.user)
|
||||
inboxItems: InboxItem[];
|
||||
|
||||
@OneToMany(() => Task, (task) => task.user)
|
||||
tasks: Task[];
|
||||
|
||||
@OneToMany(() => Project, (project) => project.user)
|
||||
projects: Project[];
|
||||
}
|
||||
37
packages/backend/src/modules/users/users.controller.ts
Normal file
37
packages/backend/src/modules/users/users.controller.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Controller, Get, Patch, Body, UseGuards, Request } from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { UpdateUserPreferencesDto } from './dto/update-preferences.dto';
|
||||
|
||||
@Controller('users')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
@Get('me')
|
||||
async getMe(@Request() req: { user: { userId: string } }) {
|
||||
const user = await this.usersService.findById(req.user.userId);
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
return this.usersService.toResponseDto(user);
|
||||
}
|
||||
|
||||
@Get('preferences')
|
||||
async getPreferences(@Request() req: { user: { userId: string } }) {
|
||||
const user = await this.usersService.findById(req.user.userId);
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
return this.usersService.toPreferencesDto(user);
|
||||
}
|
||||
|
||||
@Patch('preferences')
|
||||
async updatePreferences(
|
||||
@Request() req: { user: { userId: string } },
|
||||
@Body() updateDto: UpdateUserPreferencesDto,
|
||||
) {
|
||||
const user = await this.usersService.updatePreferences(req.user.userId, updateDto);
|
||||
return this.usersService.toPreferencesDto(user);
|
||||
}
|
||||
}
|
||||
13
packages/backend/src/modules/users/users.module.ts
Normal file
13
packages/backend/src/modules/users/users.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { User } from './entities/user.entity';
|
||||
import { UsersService } from './users.service';
|
||||
import { UsersController } from './users.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User])],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
97
packages/backend/src/modules/users/users.service.ts
Normal file
97
packages/backend/src/modules/users/users.service.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { User } from './entities/user.entity';
|
||||
import type { RegisterDto, UpdateUserPreferencesDto } from '@nick-tracker/shared-types';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
async create(registerDto: RegisterDto): Promise<User> {
|
||||
const hashedPassword = await bcrypt.hash(registerDto.password, 12);
|
||||
const user = this.userRepository.create({
|
||||
...registerDto,
|
||||
password: hashedPassword,
|
||||
workingHours: {
|
||||
monday: { start: '09:00', end: '17:00' },
|
||||
tuesday: { start: '09:00', end: '17:00' },
|
||||
wednesday: { start: '09:00', end: '17:00' },
|
||||
thursday: { start: '09:00', end: '17:00' },
|
||||
friday: { start: '09:00', end: '17:00' },
|
||||
},
|
||||
notificationPreferences: {
|
||||
email: false,
|
||||
webhook: false,
|
||||
},
|
||||
});
|
||||
return this.userRepository.save(user);
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
return this.userRepository.findOne({ where: { email } });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<User | null> {
|
||||
return this.userRepository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async updateRefreshToken(userId: string, refreshToken: string | null): Promise<void> {
|
||||
await this.userRepository.update(userId, { refreshToken });
|
||||
}
|
||||
|
||||
async updatePreferences(userId: string, preferences: UpdateUserPreferencesDto): Promise<User> {
|
||||
const user = await this.findById(userId);
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
if (preferences.workingHours !== undefined) {
|
||||
user.workingHours = preferences.workingHours;
|
||||
}
|
||||
if (preferences.timezone !== undefined) {
|
||||
user.timezone = preferences.timezone;
|
||||
}
|
||||
if (preferences.notificationPreferences !== undefined) {
|
||||
user.notificationPreferences = preferences.notificationPreferences;
|
||||
}
|
||||
if (preferences.weeklyReviewDay !== undefined) {
|
||||
user.weeklyReviewDay = preferences.weeklyReviewDay;
|
||||
}
|
||||
if (preferences.weeklyReviewTime !== undefined) {
|
||||
user.weeklyReviewTime = preferences.weeklyReviewTime;
|
||||
}
|
||||
|
||||
return this.userRepository.save(user);
|
||||
}
|
||||
|
||||
async validatePassword(user: User, password: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, user.password);
|
||||
}
|
||||
|
||||
toResponseDto(user: User) {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
timezone: user.timezone,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
updatedAt: user.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
toPreferencesDto(user: User) {
|
||||
return {
|
||||
workingHours: user.workingHours,
|
||||
timezone: user.timezone,
|
||||
notificationPreferences: user.notificationPreferences,
|
||||
weeklyReviewDay: user.weeklyReviewDay,
|
||||
weeklyReviewTime: user.weeklyReviewTime,
|
||||
lastWeeklyReview: user.lastWeeklyReview?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user