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:
Debian
2026-01-11 08:42:54 +00:00
parent ce0e5f1769
commit 64b8e0d80c
90 changed files with 21021 additions and 2 deletions

View File

@@ -0,0 +1,25 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
},
};

View File

@@ -0,0 +1,6 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2
}

View File

@@ -0,0 +1,32 @@
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
FROM base AS builder
WORKDIR /app
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
COPY packages/shared-types/package.json ./packages/shared-types/
COPY packages/backend/package.json ./packages/backend/
RUN pnpm install --frozen-lockfile || pnpm install
COPY packages/shared-types ./packages/shared-types
COPY packages/backend ./packages/backend
RUN pnpm --filter @nick-tracker/shared-types build
RUN pnpm --filter @nick-tracker/backend build
FROM base AS runner
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/packages/shared-types/dist ./packages/shared-types/dist
COPY --from=builder /app/packages/shared-types/package.json ./packages/shared-types/
COPY --from=builder /app/packages/backend/dist ./packages/backend/dist
COPY --from=builder /app/packages/backend/package.json ./packages/backend/
COPY --from=builder /app/packages/backend/node_modules ./packages/backend/node_modules
WORKDIR /app/packages/backend
EXPOSE 3000
CMD ["node", "dist/main.js"]

View File

@@ -0,0 +1,14 @@
module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
collectCoverageFrom: ['**/*.(t|j)s'],
coverageDirectory: '../coverage',
testEnvironment: 'node',
moduleNameMapper: {
'^@nick-tracker/shared-types$': '<rootDir>/../../shared-types/src/index.ts',
},
};

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View File

@@ -0,0 +1,69 @@
{
"name": "@nick-tracker/backend",
"version": "1.0.0",
"description": "NestJS backend for nick-tracker",
"main": "dist/main.js",
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start": "node dist/main.js",
"start:prod": "node dist/main.js",
"lint": "eslint \"src/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:e2e": "jest --config ./test/jest-e2e.json",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist",
"migration:generate": "typeorm migration:generate -d src/config/typeorm.config.ts",
"migration:run": "typeorm migration:run -d src/config/typeorm.config.ts",
"migration:revert": "typeorm migration:revert -d src/config/typeorm.config.ts"
},
"dependencies": {
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/platform-socket.io": "^10.3.0",
"@nestjs/schedule": "^4.0.0",
"@nestjs/typeorm": "^10.0.1",
"@nestjs/websockets": "^10.3.0",
"@nick-tracker/shared-types": "*",
"bcrypt": "^5.1.1",
"bull": "^4.12.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"date-fns": "^3.2.0",
"helmet": "^7.1.0",
"ioredis": "^5.3.2",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.11.3",
"reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1",
"typeorm": "^0.3.19"
},
"devDependencies": {
"@nestjs/cli": "^10.3.0",
"@nestjs/schematics": "^10.1.0",
"@nestjs/testing": "^10.3.0",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.11",
"@types/node": "^20.10.6",
"@types/passport-jwt": "^4.0.0",
"@typescript-eslint/eslint-plugin": "^6.17.0",
"@typescript-eslint/parser": "^6.17.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.2",
"jest": "^29.7.0",
"prettier": "^3.1.1",
"supertest": "^6.3.3",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
}
}

View 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 {}

View 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,
});
}
}

View File

@@ -0,0 +1 @@
export * from './http-exception.filter';

View 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();

View 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' };
}
}

View 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 {}

View 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);
});
});
});

View 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 };
}
}

View File

@@ -0,0 +1,9 @@
import { IsEmail, IsString } from 'class-validator';
export class LoginDto {
@IsEmail()
email: string;
@IsString()
password: string;
}

View File

@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class RefreshTokenDto {
@IsString()
refreshToken: string;
}

View 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;
}

View 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;
}
}

View 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 };
}
}

View 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();
}
}

View 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 {}

View 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';
}
}
}

View File

@@ -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>;
}

View File

@@ -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;
}

View File

@@ -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;
}

View 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 };
}
}

View 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 {}

View 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);
});
});
});

View 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(),
};
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View 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 };
}
}

View 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 {}

View 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(),
};
}
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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);
}
}

View 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 {}

View 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(),
};
}
}

View File

@@ -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;
}

View 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[];
}

View 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);
}
}

View 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 {}

View 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,
};
}
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2022",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}