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:
66
.claude/ralph-loop.local.md
Normal file
66
.claude/ralph-loop.local.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
active: true
|
||||||
|
iteration: 1
|
||||||
|
max_iterations: 30
|
||||||
|
completion_promise: "PHASE_1_COMPLETE"
|
||||||
|
started_at: "2026-01-11T08:23:00Z"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 1: Foundation
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Read PROMPT.md for full project requirements and context.
|
||||||
|
This prompt focuses ONLY on Phase 1: Foundation.
|
||||||
|
|
||||||
|
## Phase Objective
|
||||||
|
|
||||||
|
Project setup, core infrastructure, and initial configuration
|
||||||
|
|
||||||
|
## Phase 1 Tasks
|
||||||
|
|
||||||
|
- [ ] GTD Inbox Capture: Multi-source task capture system that ingests tasks from manual web form, REST API, email (IMAP/Microsoft Graph), and ConnectWise Manage sync into an unprocessed inbox for later GTD clarification
|
||||||
|
- Acceptance: Manual tasks can be submitted via web form quick-add and appear in inbox
|
||||||
|
- [ ] GTD Processing Workflow: Interactive inbox processing interface that guides users through GTD clarification: converting raw inbox items into Next Actions with context tags, Projects, Waiting For items, Someday/Maybe, Reference Material, Tickler items, or Trash
|
||||||
|
- Acceptance: Inbox view displays unprocessed items with processing workflow controls
|
||||||
|
|
||||||
|
## Working Instructions
|
||||||
|
|
||||||
|
1. Read PROMPT.md to understand the full project context
|
||||||
|
2. Focus ONLY on the tasks listed above for this phase
|
||||||
|
3. For each task:
|
||||||
|
- Implement the feature
|
||||||
|
- Write tests
|
||||||
|
- Run: npm run build && npm run test && npm run lint
|
||||||
|
- Update prd.json to set passes: true for completed features
|
||||||
|
- Append progress to progress.txt
|
||||||
|
- Commit with conventional commit message
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Always run tests before committing
|
||||||
|
- Never commit failing code
|
||||||
|
- Do not implement features from other phases
|
||||||
|
- Make reasonable decisions - do not ask questions
|
||||||
|
- Update prd.json when features complete
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After completing all Phase 1 tasks:
|
||||||
|
```bash
|
||||||
|
npm run build && npm run test && npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
All commands must pass with zero errors.
|
||||||
|
|
||||||
|
## Completion
|
||||||
|
|
||||||
|
When ALL Phase 1 tasks are complete and verified:
|
||||||
|
- All features for this phase pass their acceptance criteria
|
||||||
|
- prd.json shows passes: true for all Phase 1 features
|
||||||
|
- Build, test, and lint all pass
|
||||||
|
|
||||||
|
Output: <promise>PHASE_1_COMPLETE</promise>
|
||||||
|
|
||||||
|
If blocked and cannot proceed:
|
||||||
|
Output: <promise>ABORT_BLOCKED</promise>
|
||||||
23
.env.example
Normal file
23
.env.example
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Database
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USERNAME=postgres
|
||||||
|
DB_PASSWORD=postgres
|
||||||
|
DB_DATABASE=autoscheduler
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET=your-secret-key-change-in-production
|
||||||
|
JWT_REFRESH_SECRET=your-refresh-secret-key-change-in-production
|
||||||
|
JWT_EXPIRES_IN=1h
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
|
||||||
|
# Encryption (for credential storage)
|
||||||
|
ENCRYPTION_KEY=your-32-byte-encryption-key-here
|
||||||
78
docker-compose.yml
Normal file
78
docker-compose.yml
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: nick-tracker-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${DB_USERNAME:-postgres}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
|
||||||
|
POSTGRES_DB: ${DB_DATABASE:-autoscheduler}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-postgres}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: nick-tracker-redis
|
||||||
|
command: redis-server --appendonly yes
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: packages/backend/Dockerfile
|
||||||
|
container_name: nick-tracker-backend
|
||||||
|
environment:
|
||||||
|
NODE_ENV: ${NODE_ENV:-development}
|
||||||
|
PORT: 3000
|
||||||
|
DB_HOST: postgres
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_USERNAME: ${DB_USERNAME:-postgres}
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD:-postgres}
|
||||||
|
DB_DATABASE: ${DB_DATABASE:-autoscheduler}
|
||||||
|
REDIS_URL: redis://redis:6379
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-development-secret-change-me}
|
||||||
|
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-refresh-secret-change-me}
|
||||||
|
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:5173}
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:3000/api/v1/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: packages/frontend/Dockerfile
|
||||||
|
container_name: nick-tracker-frontend
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
16730
package-lock.json
generated
Normal file
16730
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "nick-tracker",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "AutoScheduler GTD System - Self-hosted web application implementing GTD methodology with automatic calendar scheduling",
|
||||||
|
"private": true,
|
||||||
|
"workspaces": [
|
||||||
|
"packages/*"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "npm run build -w @nick-tracker/shared-types && npm run dev --workspaces --if-present",
|
||||||
|
"build": "npm run build -w @nick-tracker/shared-types && npm run build -w @nick-tracker/backend && npm run build -w @nick-tracker/frontend",
|
||||||
|
"test": "npm run test --workspaces --if-present",
|
||||||
|
"lint": "npm run lint --workspaces --if-present",
|
||||||
|
"clean": "npm run clean --workspaces --if-present",
|
||||||
|
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||||
|
"migration:generate": "npm run migration:generate -w @nick-tracker/backend",
|
||||||
|
"migration:run": "npm run migration:run -w @nick-tracker/backend",
|
||||||
|
"migration:revert": "npm run migration:revert -w @nick-tracker/backend"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
25
packages/backend/.eslintrc.js
Normal file
25
packages/backend/.eslintrc.js
Normal 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
6
packages/backend/.prettierrc
Normal file
6
packages/backend/.prettierrc
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
32
packages/backend/Dockerfile
Normal file
32
packages/backend/Dockerfile
Normal 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"]
|
||||||
14
packages/backend/jest.config.js
Normal file
14
packages/backend/jest.config.js
Normal 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
8
packages/backend/nest-cli.json
Normal file
8
packages/backend/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
69
packages/backend/package.json
Normal file
69
packages/backend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
25
packages/backend/tsconfig.json
Normal file
25
packages/backend/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
19
packages/frontend/.eslintrc.cjs
Normal file
19
packages/frontend/.eslintrc.cjs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: { browser: true, es2020: true },
|
||||||
|
extends: [
|
||||||
|
'eslint:recommended',
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'plugin:react-hooks/recommended',
|
||||||
|
],
|
||||||
|
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
plugins: ['react-refresh'],
|
||||||
|
rules: {
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||||
|
},
|
||||||
|
};
|
||||||
24
packages/frontend/Dockerfile
Normal file
24
packages/frontend/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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/frontend/package.json ./packages/frontend/
|
||||||
|
|
||||||
|
RUN pnpm install --frozen-lockfile || pnpm install
|
||||||
|
|
||||||
|
COPY packages/shared-types ./packages/shared-types
|
||||||
|
COPY packages/frontend ./packages/frontend
|
||||||
|
|
||||||
|
RUN pnpm --filter @nick-tracker/shared-types build
|
||||||
|
RUN pnpm --filter @nick-tracker/frontend build
|
||||||
|
|
||||||
|
FROM nginx:alpine AS runner
|
||||||
|
COPY --from=builder /app/packages/frontend/dist /usr/share/nginx/html
|
||||||
|
COPY packages/frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
13
packages/frontend/index.html
Normal file
13
packages/frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AutoScheduler GTD</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
22
packages/frontend/nginx.conf
Normal file
22
packages/frontend/nginx.conf
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://backend:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||||
|
}
|
||||||
58
packages/frontend/package.json
Normal file
58
packages/frontend/package.json
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"name": "@nick-tracker/frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint \"src/**/*.{ts,tsx}\" --fix",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"clean": "rm -rf dist"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nick-tracker/shared-types": "*",
|
||||||
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||||
|
"@radix-ui/react-label": "^2.0.2",
|
||||||
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
|
"@radix-ui/react-toast": "^1.1.5",
|
||||||
|
"@tanstack/react-query": "^5.17.0",
|
||||||
|
"axios": "^1.6.5",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.1.0",
|
||||||
|
"date-fns": "^3.2.0",
|
||||||
|
"lucide-react": "^0.309.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-hook-form": "^7.49.3",
|
||||||
|
"react-router-dom": "^6.21.2",
|
||||||
|
"socket.io-client": "^4.6.1",
|
||||||
|
"tailwind-merge": "^2.2.0",
|
||||||
|
"zod": "^3.22.4",
|
||||||
|
"zustand": "^4.4.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^6.2.0",
|
||||||
|
"@testing-library/react": "^14.1.2",
|
||||||
|
"@types/react": "^18.2.47",
|
||||||
|
"@types/react-dom": "^18.2.18",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
||||||
|
"@typescript-eslint/parser": "^6.17.0",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"eslint-plugin-react": "^7.33.2",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.5",
|
||||||
|
"jsdom": "^23.2.0",
|
||||||
|
"postcss": "^8.4.33",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"vite": "^5.0.11",
|
||||||
|
"vitest": "^1.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
packages/frontend/postcss.config.js
Normal file
6
packages/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
46
packages/frontend/src/App.tsx
Normal file
46
packages/frontend/src/App.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from './store/auth';
|
||||||
|
import { Layout } from './components/layout/Layout';
|
||||||
|
import { LoginPage } from './pages/LoginPage';
|
||||||
|
import { RegisterPage } from './pages/RegisterPage';
|
||||||
|
import { InboxPage } from './pages/InboxPage';
|
||||||
|
import { CalendarPage } from './pages/CalendarPage';
|
||||||
|
import { ProjectsPage } from './pages/ProjectsPage';
|
||||||
|
import { SettingsPage } from './pages/SettingsPage';
|
||||||
|
import { Toaster } from './components/ui/Toaster';
|
||||||
|
|
||||||
|
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const token = useAuthStore((state) => state.token);
|
||||||
|
if (!token) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<Layout />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<Navigate to="/inbox" replace />} />
|
||||||
|
<Route path="inbox" element={<InboxPage />} />
|
||||||
|
<Route path="calendar" element={<CalendarPage />} />
|
||||||
|
<Route path="projects" element={<ProjectsPage />} />
|
||||||
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
<Toaster />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
211
packages/frontend/src/components/inbox/ProcessModal.tsx
Normal file
211
packages/frontend/src/components/inbox/ProcessModal.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import * as Dialog from '@radix-ui/react-dialog';
|
||||||
|
import * as Select from '@radix-ui/react-select';
|
||||||
|
import { X, ChevronDown, Check } from 'lucide-react';
|
||||||
|
import { inboxApi } from '../../lib/api';
|
||||||
|
import { Button } from '../ui/Button';
|
||||||
|
import { Input } from '../ui/Input';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import {
|
||||||
|
ProcessAction,
|
||||||
|
TaskContext,
|
||||||
|
TaskDomain,
|
||||||
|
type InboxItemResponseDto,
|
||||||
|
} from '@nick-tracker/shared-types';
|
||||||
|
|
||||||
|
interface ProcessModalProps {
|
||||||
|
item: InboxItemResponseDto;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProcessModal({ item, onClose }: ProcessModalProps) {
|
||||||
|
const [action, setAction] = useState<ProcessAction | ''>('');
|
||||||
|
const [title, setTitle] = useState(item.content);
|
||||||
|
const [context, setContext] = useState<TaskContext | ''>('');
|
||||||
|
const [domain, setDomain] = useState<TaskDomain | ''>('');
|
||||||
|
const [priority, setPriority] = useState('');
|
||||||
|
|
||||||
|
const processMutation = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
if (!action) throw new Error('Please select an action');
|
||||||
|
|
||||||
|
return inboxApi.process(item.id, {
|
||||||
|
action,
|
||||||
|
title: title !== item.content ? title : undefined,
|
||||||
|
context: context || undefined,
|
||||||
|
domain: domain || undefined,
|
||||||
|
priority: priority ? parseInt(priority) : undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
processMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const needsDetails = action === ProcessAction.TASK ||
|
||||||
|
action === ProcessAction.WAITING_FOR ||
|
||||||
|
action === ProcessAction.SOMEDAY_MAYBE ||
|
||||||
|
action === ProcessAction.TICKLER ||
|
||||||
|
action === ProcessAction.PROJECT;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Root open onOpenChange={() => onClose()}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
|
||||||
|
<Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-background rounded-lg shadow-lg w-full max-w-lg p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<Dialog.Title className="text-lg font-semibold">
|
||||||
|
Process Inbox Item
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Close asChild>
|
||||||
|
<button className="text-muted-foreground hover:text-foreground">
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</Dialog.Close>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 p-3 bg-muted rounded-md">
|
||||||
|
<p className="text-sm">{item.content}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">What is this?</label>
|
||||||
|
<Select.Root value={action} onValueChange={(v) => setAction(v as ProcessAction)}>
|
||||||
|
<Select.Trigger className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring">
|
||||||
|
<Select.Value placeholder="Select an action..." />
|
||||||
|
<Select.Icon>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</Select.Icon>
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Portal>
|
||||||
|
<Select.Content className="overflow-hidden bg-background rounded-md border shadow-md">
|
||||||
|
<Select.Viewport className="p-1">
|
||||||
|
<SelectItem value={ProcessAction.TASK}>Next Action</SelectItem>
|
||||||
|
<SelectItem value={ProcessAction.PROJECT}>Project (multi-step)</SelectItem>
|
||||||
|
<SelectItem value={ProcessAction.WAITING_FOR}>Waiting For</SelectItem>
|
||||||
|
<SelectItem value={ProcessAction.SOMEDAY_MAYBE}>Someday/Maybe</SelectItem>
|
||||||
|
<SelectItem value={ProcessAction.TICKLER}>Tickler (future date)</SelectItem>
|
||||||
|
<SelectItem value={ProcessAction.REFERENCE}>Reference Material</SelectItem>
|
||||||
|
<SelectItem value={ProcessAction.TRASH}>Trash</SelectItem>
|
||||||
|
</Select.Viewport>
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Portal>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{needsDetails && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Title</label>
|
||||||
|
<Input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Enter a title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Domain</label>
|
||||||
|
<Select.Root value={domain} onValueChange={(v) => setDomain(v as TaskDomain)}>
|
||||||
|
<Select.Trigger className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring">
|
||||||
|
<Select.Value placeholder="Select domain..." />
|
||||||
|
<Select.Icon>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</Select.Icon>
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Portal>
|
||||||
|
<Select.Content className="overflow-hidden bg-background rounded-md border shadow-md">
|
||||||
|
<Select.Viewport className="p-1">
|
||||||
|
<SelectItem value={TaskDomain.WORK}>Work</SelectItem>
|
||||||
|
<SelectItem value={TaskDomain.HOMELAB}>Homelab</SelectItem>
|
||||||
|
<SelectItem value={TaskDomain.DAILY_ROUTINES}>Daily Routines</SelectItem>
|
||||||
|
<SelectItem value={TaskDomain.HOUSE}>House</SelectItem>
|
||||||
|
<SelectItem value={TaskDomain.PROFESSIONAL_DEVELOPMENT}>Professional Development</SelectItem>
|
||||||
|
</Select.Viewport>
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Portal>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{action !== ProcessAction.PROJECT && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Context</label>
|
||||||
|
<Select.Root value={context} onValueChange={(v) => setContext(v as TaskContext)}>
|
||||||
|
<Select.Trigger className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring">
|
||||||
|
<Select.Value placeholder="Select context..." />
|
||||||
|
<Select.Icon>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</Select.Icon>
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Portal>
|
||||||
|
<Select.Content className="overflow-hidden bg-background rounded-md border shadow-md">
|
||||||
|
<Select.Viewport className="p-1">
|
||||||
|
<SelectItem value={TaskContext.DESK}>@desk</SelectItem>
|
||||||
|
<SelectItem value={TaskContext.PHONE}>@phone</SelectItem>
|
||||||
|
<SelectItem value={TaskContext.ERRAND}>@errand</SelectItem>
|
||||||
|
<SelectItem value={TaskContext.HOMELAB}>@homelab</SelectItem>
|
||||||
|
<SelectItem value={TaskContext.ANYWHERE}>@anywhere</SelectItem>
|
||||||
|
</Select.Viewport>
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Portal>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Priority (1-5)</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="5"
|
||||||
|
value={priority}
|
||||||
|
onChange={(e) => setPriority(e.target.value)}
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!action || (needsDetails && !domain) || processMutation.isPending}
|
||||||
|
>
|
||||||
|
{processMutation.isPending ? 'Processing...' : 'Process'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({ children, value, className }: { children: React.ReactNode; value: string; className?: string }) {
|
||||||
|
return (
|
||||||
|
<Select.Item
|
||||||
|
value={value}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<Select.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</Select.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<Select.ItemText>{children}</Select.ItemText>
|
||||||
|
</Select.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
packages/frontend/src/components/layout/Layout.tsx
Normal file
67
packages/frontend/src/components/layout/Layout.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||||
|
import { Inbox, Calendar, FolderKanban, Settings, LogOut } from 'lucide-react';
|
||||||
|
import { useAuthStore } from '../../store/auth';
|
||||||
|
import { Button } from '../ui/Button';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: '/inbox', icon: Inbox, label: 'Inbox' },
|
||||||
|
{ to: '/calendar', icon: Calendar, label: 'Calendar' },
|
||||||
|
{ to: '/projects', icon: FolderKanban, label: 'Projects' },
|
||||||
|
{ to: '/settings', icon: Settings, label: 'Settings' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Layout() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const logout = useAuthStore((state) => state.logout);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex">
|
||||||
|
<aside className="w-64 bg-muted border-r flex flex-col">
|
||||||
|
<div className="p-6">
|
||||||
|
<h1 className="text-xl font-bold">AutoScheduler</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">GTD System</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 px-4">
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{navItems.map(({ to, icon: Icon, label }) => (
|
||||||
|
<li key={to}>
|
||||||
|
<NavLink
|
||||||
|
to={to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-4 border-t">
|
||||||
|
<Button variant="ghost" className="w-full justify-start" onClick={handleLogout}>
|
||||||
|
<LogOut className="h-5 w-5 mr-3" />
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-auto">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
packages/frontend/src/components/ui/Button.tsx
Normal file
46
packages/frontend/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
|
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-10 px-4 py-2',
|
||||||
|
sm: 'h-9 rounded-md px-3',
|
||||||
|
lg: 'h-11 rounded-md px-8',
|
||||||
|
icon: 'h-10 w-10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
55
packages/frontend/src/components/ui/Card.tsx
Normal file
55
packages/frontend/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Card.displayName = 'Card';
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardHeader.displayName = 'CardHeader';
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardTitle.displayName = 'CardTitle';
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
));
|
||||||
|
CardDescription.displayName = 'CardDescription';
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardContent.displayName = 'CardContent';
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardFooter.displayName = 'CardFooter';
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||||
23
packages/frontend/src/components/ui/Input.tsx
Normal file
23
packages/frontend/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
export { Input };
|
||||||
90
packages/frontend/src/components/ui/Toaster.tsx
Normal file
90
packages/frontend/src/components/ui/Toaster.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as ToastPrimitive from '@radix-ui/react-toast';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
const ToastProvider = ToastPrimitive.Provider;
|
||||||
|
const ToastViewport = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitive.Viewport>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Viewport>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitive.Viewport
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastViewport.displayName = ToastPrimitive.Viewport.displayName;
|
||||||
|
|
||||||
|
const Toast = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Root> & {
|
||||||
|
variant?: 'default' | 'destructive';
|
||||||
|
}
|
||||||
|
>(({ className, variant = 'default', ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<ToastPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all',
|
||||||
|
variant === 'default' && 'border bg-background text-foreground',
|
||||||
|
variant === 'destructive' &&
|
||||||
|
'destructive group border-destructive bg-destructive text-destructive-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Toast.displayName = ToastPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const ToastClose = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitive.Close>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Close>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitive.Close
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
toast-close=""
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</ToastPrimitive.Close>
|
||||||
|
));
|
||||||
|
ToastClose.displayName = ToastPrimitive.Close.displayName;
|
||||||
|
|
||||||
|
const ToastTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitive.Title ref={ref} className={cn('text-sm font-semibold', className)} {...props} />
|
||||||
|
));
|
||||||
|
ToastTitle.displayName = ToastPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const ToastDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm opacity-90', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastDescription.displayName = ToastPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
<ToastViewport />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toast, ToastClose, ToastTitle, ToastDescription };
|
||||||
51
packages/frontend/src/index.css
Normal file
51
packages/frontend/src/index.css
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
--primary: 222.2 47.4% 11.2%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--accent: 210 40% 96.1%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 222.2 84% 4.9%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
--primary: 210 40% 98%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 212.7 26.8% 83.9%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
99
packages/frontend/src/lib/api.ts
Normal file
99
packages/frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { useAuthStore } from '../store/auth';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api/v1',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = useAuthStore.getState().token;
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
useAuthStore.getState().logout();
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export { api };
|
||||||
|
|
||||||
|
// Auth API
|
||||||
|
export const authApi = {
|
||||||
|
register: (data: { email: string; password: string; name: string; timezone: string }) =>
|
||||||
|
api.post('/auth/register', data),
|
||||||
|
login: (data: { email: string; password: string }) => api.post('/auth/login', data),
|
||||||
|
logout: () => api.post('/auth/logout'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inbox API
|
||||||
|
export const inboxApi = {
|
||||||
|
getAll: () => api.get('/inbox'),
|
||||||
|
create: (content: string) => api.post('/inbox', { content }),
|
||||||
|
process: (
|
||||||
|
id: string,
|
||||||
|
data: {
|
||||||
|
action: string;
|
||||||
|
title?: string;
|
||||||
|
context?: string;
|
||||||
|
domain?: string;
|
||||||
|
priority?: number;
|
||||||
|
},
|
||||||
|
) => api.post(`/inbox/${id}/process`, data),
|
||||||
|
delete: (id: string) => api.delete(`/inbox/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tasks API
|
||||||
|
export const tasksApi = {
|
||||||
|
getAll: (params?: { status?: string; context?: string; domain?: string }) =>
|
||||||
|
api.get('/tasks', { params }),
|
||||||
|
getOne: (id: string) => api.get(`/tasks/${id}`),
|
||||||
|
create: (data: {
|
||||||
|
title: string;
|
||||||
|
domain: string;
|
||||||
|
context?: string;
|
||||||
|
priority?: number;
|
||||||
|
estimatedDuration?: number;
|
||||||
|
dueDate?: string;
|
||||||
|
projectId?: string;
|
||||||
|
notes?: string;
|
||||||
|
status?: string;
|
||||||
|
}) => api.post('/tasks', data),
|
||||||
|
update: (id: string, data: Record<string, unknown>) => api.patch(`/tasks/${id}`, data),
|
||||||
|
delete: (id: string) => api.delete(`/tasks/${id}`),
|
||||||
|
lock: (id: string) => api.post(`/tasks/${id}/lock`),
|
||||||
|
unlock: (id: string) => api.post(`/tasks/${id}/unlock`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Projects API
|
||||||
|
export const projectsApi = {
|
||||||
|
getAll: (params?: { status?: string; domain?: string }) => api.get('/projects', { params }),
|
||||||
|
getOne: (id: string) => api.get(`/projects/${id}`),
|
||||||
|
getTasks: (id: string) => api.get(`/projects/${id}/tasks`),
|
||||||
|
create: (data: {
|
||||||
|
name: string;
|
||||||
|
domain: string;
|
||||||
|
description?: string;
|
||||||
|
desiredOutcome?: string;
|
||||||
|
}) => api.post('/projects', data),
|
||||||
|
update: (id: string, data: Record<string, unknown>) => api.patch(`/projects/${id}`, data),
|
||||||
|
delete: (id: string) => api.delete(`/projects/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// User API
|
||||||
|
export const userApi = {
|
||||||
|
getMe: () => api.get('/users/me'),
|
||||||
|
getPreferences: () => api.get('/users/preferences'),
|
||||||
|
updatePreferences: (data: Record<string, unknown>) => api.patch('/users/preferences', data),
|
||||||
|
};
|
||||||
16
packages/frontend/src/lib/utils.test.ts
Normal file
16
packages/frontend/src/lib/utils.test.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { cn } from './utils';
|
||||||
|
|
||||||
|
describe('cn utility', () => {
|
||||||
|
it('should merge class names', () => {
|
||||||
|
expect(cn('foo', 'bar')).toBe('foo bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle conditional classes', () => {
|
||||||
|
expect(cn('foo', false && 'bar', 'baz')).toBe('foo baz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should merge tailwind classes correctly', () => {
|
||||||
|
expect(cn('px-2 py-1', 'px-4')).toBe('py-1 px-4');
|
||||||
|
});
|
||||||
|
});
|
||||||
6
packages/frontend/src/lib/utils.ts
Normal file
6
packages/frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
25
packages/frontend/src/main.tsx
Normal file
25
packages/frontend/src/main.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000 * 60,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
18
packages/frontend/src/pages/CalendarPage.tsx
Normal file
18
packages/frontend/src/pages/CalendarPage.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export function CalendarPage() {
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold mb-2">Calendar</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
View and manage your scheduled tasks.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted rounded-lg p-12 text-center text-muted-foreground">
|
||||||
|
Calendar view will be implemented in Phase 2 with FullCalendar integration.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
packages/frontend/src/pages/InboxPage.tsx
Normal file
128
packages/frontend/src/pages/InboxPage.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Plus, ArrowRight, Trash2 } from 'lucide-react';
|
||||||
|
import { inboxApi } from '../lib/api';
|
||||||
|
import { Button } from '../components/ui/Button';
|
||||||
|
import { Input } from '../components/ui/Input';
|
||||||
|
import { Card, CardContent } from '../components/ui/Card';
|
||||||
|
import { ProcessModal } from '../components/inbox/ProcessModal';
|
||||||
|
import type { InboxItemResponseDto } from '@nick-tracker/shared-types';
|
||||||
|
|
||||||
|
export function InboxPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [newItem, setNewItem] = useState('');
|
||||||
|
const [selectedItem, setSelectedItem] = useState<InboxItemResponseDto | null>(null);
|
||||||
|
|
||||||
|
const { data: items = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['inbox'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await inboxApi.getAll();
|
||||||
|
return response.data as InboxItemResponseDto[];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (content: string) => inboxApi.create(content),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['inbox'] });
|
||||||
|
setNewItem('');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => inboxApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['inbox'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (newItem.trim()) {
|
||||||
|
createMutation.mutate(newItem.trim());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProcess = (item: InboxItemResponseDto) => {
|
||||||
|
setSelectedItem(item);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setSelectedItem(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['inbox'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold mb-2">Inbox</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Capture everything on your mind. Process items to clarify what they mean.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="mb-8 flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={newItem}
|
||||||
|
onChange={(e) => setNewItem(e.target.value)}
|
||||||
|
placeholder="What's on your mind?"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={createMutation.isPending || !newItem.trim()}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center text-muted-foreground py-8">Loading...</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Your inbox is empty. Add items above to capture thoughts and tasks.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items.map((item) => (
|
||||||
|
<Card key={item.id}>
|
||||||
|
<CardContent className="py-4 flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium">{item.content}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{new Date(item.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleProcess(item)}
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-4 w-4 mr-2" />
|
||||||
|
Process
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => deleteMutation.mutate(item.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedItem && (
|
||||||
|
<ProcessModal item={selectedItem} onClose={handleCloseModal} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
packages/frontend/src/pages/LoginPage.tsx
Normal file
89
packages/frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '../store/auth';
|
||||||
|
import { authApi } from '../lib/api';
|
||||||
|
import { Button } from '../components/ui/Button';
|
||||||
|
import { Input } from '../components/ui/Input';
|
||||||
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '../components/ui/Card';
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const setAuth = useAuthStore((state) => state.setAuth);
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authApi.login({ email, password });
|
||||||
|
setAuth(response.data.token, response.data.user);
|
||||||
|
navigate('/inbox');
|
||||||
|
} catch (err) {
|
||||||
|
setError('Invalid email or password');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-muted">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle>Sign In</CardTitle>
|
||||||
|
<CardDescription>Enter your credentials to access your account</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="email" className="text-sm font-medium">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="password" className="text-sm font-medium">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex flex-col gap-4">
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading ? 'Signing in...' : 'Sign In'}
|
||||||
|
</Button>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<Link to="/register" className="text-primary hover:underline">
|
||||||
|
Register
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
packages/frontend/src/pages/ProjectsPage.tsx
Normal file
60
packages/frontend/src/pages/ProjectsPage.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { FolderKanban } from 'lucide-react';
|
||||||
|
import { projectsApi } from '../lib/api';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/Card';
|
||||||
|
import type { ProjectResponseDto } from '@nick-tracker/shared-types';
|
||||||
|
|
||||||
|
export function ProjectsPage() {
|
||||||
|
const { data: projects = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['projects'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await projectsApi.getAll();
|
||||||
|
return response.data as ProjectResponseDto[];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold mb-2">Projects</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Multi-step outcomes requiring more than one action.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center text-muted-foreground py-8">Loading...</div>
|
||||||
|
) : projects.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<FolderKanban className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
No projects yet. Process inbox items to create projects.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<Card key={project.id}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">{project.name}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span className="px-2 py-0.5 bg-muted rounded">{project.domain}</span>
|
||||||
|
<span className="px-2 py-0.5 bg-muted rounded">{project.status}</span>
|
||||||
|
</div>
|
||||||
|
{project.description && (
|
||||||
|
<p className="mt-2 text-sm">{project.description}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
packages/frontend/src/pages/RegisterPage.tsx
Normal file
108
packages/frontend/src/pages/RegisterPage.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '../store/auth';
|
||||||
|
import { authApi } from '../lib/api';
|
||||||
|
import { Button } from '../components/ui/Button';
|
||||||
|
import { Input } from '../components/ui/Input';
|
||||||
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '../components/ui/Card';
|
||||||
|
|
||||||
|
export function RegisterPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const setAuth = useAuthStore((state) => state.setAuth);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
const response = await authApi.register({ name, email, password, timezone });
|
||||||
|
setAuth(response.data.token, response.data.user);
|
||||||
|
navigate('/inbox');
|
||||||
|
} catch (err) {
|
||||||
|
setError('Registration failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-muted">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle>Create Account</CardTitle>
|
||||||
|
<CardDescription>Start organizing your tasks with GTD</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="name" className="text-sm font-medium">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Your name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="email" className="text-sm font-medium">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="password" className="text-sm font-medium">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Create a password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Password must be at least 8 characters with uppercase, lowercase, and number.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex flex-col gap-4">
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading ? 'Creating account...' : 'Create Account'}
|
||||||
|
</Button>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link to="/login" className="text-primary hover:underline">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
packages/frontend/src/pages/SettingsPage.tsx
Normal file
54
packages/frontend/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { useAuthStore } from '../store/auth';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '../components/ui/Card';
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const user = useAuthStore((state) => state.user);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold mb-2">Settings</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage your account and preferences.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Profile</CardTitle>
|
||||||
|
<CardDescription>Your account information</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-muted-foreground">Name</label>
|
||||||
|
<p className="mt-1">{user?.name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-muted-foreground">Email</label>
|
||||||
|
<p className="mt-1">{user?.email}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-muted-foreground">Timezone</label>
|
||||||
|
<p className="mt-1">{user?.timezone}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Integrations</CardTitle>
|
||||||
|
<CardDescription>Connect your calendars and email</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Calendar and email integrations will be available in Phase 3.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
packages/frontend/src/store/auth.ts
Normal file
24
packages/frontend/src/store/auth.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import type { UserResponseDto } from '@nick-tracker/shared-types';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
token: string | null;
|
||||||
|
user: UserResponseDto | null;
|
||||||
|
setAuth: (token: string, user: UserResponseDto) => void;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
token: null,
|
||||||
|
user: null,
|
||||||
|
setAuth: (token, user) => set({ token, user }),
|
||||||
|
logout: () => set({ token: null, user: null }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'auth-storage',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
1
packages/frontend/src/test/setup.ts
Normal file
1
packages/frontend/src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
41
packages/frontend/tailwind.config.js
Normal file
41
packages/frontend/tailwind.config.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))',
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
|
foreground: 'hsl(var(--destructive-foreground))',
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
|
foreground: 'hsl(var(--accent-foreground))',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
25
packages/frontend/tsconfig.json
Normal file
25
packages/frontend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
11
packages/frontend/tsconfig.node.json
Normal file
11
packages/frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
21
packages/frontend/vite.config.ts
Normal file
21
packages/frontend/vite.config.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
17
packages/frontend/vitest.config.ts
Normal file
17
packages/frontend/vitest.config.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./src/test/setup.ts'],
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
27
packages/shared-types/package.json
Normal file
27
packages/shared-types/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "@nick-tracker/shared-types",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Shared TypeScript types for nick-tracker",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"module": "dist/index.mjs",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/index.mjs",
|
||||||
|
"require": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup src/index.ts --dts --format cjs,esm",
|
||||||
|
"dev": "tsup src/index.ts --dts --format cjs,esm --watch",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"lint": "echo 'No lint configured'",
|
||||||
|
"test": "echo 'No tests'",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tsup": "^8.0.1",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
243
packages/shared-types/src/index.ts
Normal file
243
packages/shared-types/src/index.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
// Enums
|
||||||
|
export enum TaskStatus {
|
||||||
|
NEXT_ACTION = 'NEXT_ACTION',
|
||||||
|
WAITING_FOR = 'WAITING_FOR',
|
||||||
|
SOMEDAY_MAYBE = 'SOMEDAY_MAYBE',
|
||||||
|
TICKLER = 'TICKLER',
|
||||||
|
COMPLETED = 'COMPLETED',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TaskContext {
|
||||||
|
DESK = 'DESK',
|
||||||
|
PHONE = 'PHONE',
|
||||||
|
ERRAND = 'ERRAND',
|
||||||
|
HOMELAB = 'HOMELAB',
|
||||||
|
ANYWHERE = 'ANYWHERE',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TaskDomain {
|
||||||
|
WORK = 'WORK',
|
||||||
|
HOMELAB = 'HOMELAB',
|
||||||
|
DAILY_ROUTINES = 'DAILY_ROUTINES',
|
||||||
|
HOUSE = 'HOUSE',
|
||||||
|
PROFESSIONAL_DEVELOPMENT = 'PROFESSIONAL_DEVELOPMENT',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum InboxSource {
|
||||||
|
MANUAL = 'MANUAL',
|
||||||
|
EMAIL = 'EMAIL',
|
||||||
|
CONNECTWISE = 'CONNECTWISE',
|
||||||
|
TICKLER = 'TICKLER',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ProcessAction {
|
||||||
|
TASK = 'TASK',
|
||||||
|
PROJECT = 'PROJECT',
|
||||||
|
WAITING_FOR = 'WAITING_FOR',
|
||||||
|
SOMEDAY_MAYBE = 'SOMEDAY_MAYBE',
|
||||||
|
TICKLER = 'TICKLER',
|
||||||
|
REFERENCE = 'REFERENCE',
|
||||||
|
TRASH = 'TRASH',
|
||||||
|
}
|
||||||
|
|
||||||
|
// DTOs - Auth
|
||||||
|
export interface RegisterDto {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
name: string;
|
||||||
|
timezone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginDto {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponseDto {
|
||||||
|
token: string;
|
||||||
|
user: UserResponseDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshTokenDto {
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DTOs - User
|
||||||
|
export interface UserResponseDto {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
timezone: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserPreferencesDto {
|
||||||
|
workingHours?: WorkingHours;
|
||||||
|
timezone?: string;
|
||||||
|
notificationPreferences?: NotificationPreferences;
|
||||||
|
weeklyReviewDay?: number;
|
||||||
|
weeklyReviewTime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkingHours {
|
||||||
|
monday?: DayHours;
|
||||||
|
tuesday?: DayHours;
|
||||||
|
wednesday?: DayHours;
|
||||||
|
thursday?: DayHours;
|
||||||
|
friday?: DayHours;
|
||||||
|
saturday?: DayHours;
|
||||||
|
sunday?: DayHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DayHours {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationPreferences {
|
||||||
|
email: boolean;
|
||||||
|
webhook: boolean;
|
||||||
|
webhookUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DTOs - Inbox
|
||||||
|
export interface CreateInboxItemDto {
|
||||||
|
content: string;
|
||||||
|
sourceMetadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InboxItemResponseDto {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
source: InboxSource;
|
||||||
|
sourceMetadata?: Record<string, unknown>;
|
||||||
|
processed: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessInboxItemDto {
|
||||||
|
action: ProcessAction;
|
||||||
|
title?: string;
|
||||||
|
context?: TaskContext;
|
||||||
|
domain?: TaskDomain;
|
||||||
|
priority?: number;
|
||||||
|
estimatedDuration?: number;
|
||||||
|
dueDate?: string;
|
||||||
|
projectId?: string;
|
||||||
|
followUpDate?: string;
|
||||||
|
ticklerDate?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DTOs - Task
|
||||||
|
export interface CreateTaskDto {
|
||||||
|
title: string;
|
||||||
|
domain: TaskDomain;
|
||||||
|
context?: TaskContext;
|
||||||
|
priority?: number;
|
||||||
|
estimatedDuration?: number;
|
||||||
|
dueDate?: string;
|
||||||
|
projectId?: string;
|
||||||
|
notes?: string;
|
||||||
|
status?: TaskStatus;
|
||||||
|
followUpDate?: string;
|
||||||
|
ticklerDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTaskDto {
|
||||||
|
title?: string;
|
||||||
|
domain?: TaskDomain;
|
||||||
|
context?: TaskContext;
|
||||||
|
priority?: number;
|
||||||
|
estimatedDuration?: number;
|
||||||
|
dueDate?: string;
|
||||||
|
projectId?: string;
|
||||||
|
notes?: string;
|
||||||
|
status?: TaskStatus;
|
||||||
|
followUpDate?: string;
|
||||||
|
ticklerDate?: string;
|
||||||
|
scheduledStart?: string;
|
||||||
|
scheduledEnd?: string;
|
||||||
|
isLocked?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskResponseDto {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
domain: TaskDomain;
|
||||||
|
context?: TaskContext;
|
||||||
|
status: TaskStatus;
|
||||||
|
priority?: number;
|
||||||
|
estimatedDuration?: number;
|
||||||
|
dueDate?: string;
|
||||||
|
scheduledStart?: string;
|
||||||
|
scheduledEnd?: string;
|
||||||
|
isLocked: boolean;
|
||||||
|
projectId?: string;
|
||||||
|
notes?: string;
|
||||||
|
followUpDate?: string;
|
||||||
|
ticklerDate?: string;
|
||||||
|
connectwisePriority?: string;
|
||||||
|
connectwiseSLA?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskFilterDto {
|
||||||
|
status?: TaskStatus;
|
||||||
|
context?: TaskContext;
|
||||||
|
domain?: TaskDomain;
|
||||||
|
projectId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DTOs - Project
|
||||||
|
export interface CreateProjectDto {
|
||||||
|
name: string;
|
||||||
|
domain: TaskDomain;
|
||||||
|
description?: string;
|
||||||
|
desiredOutcome?: string;
|
||||||
|
connectwiseProjectId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProjectDto {
|
||||||
|
name?: string;
|
||||||
|
domain?: TaskDomain;
|
||||||
|
description?: string;
|
||||||
|
desiredOutcome?: string;
|
||||||
|
status?: ProjectStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ProjectStatus {
|
||||||
|
ACTIVE = 'ACTIVE',
|
||||||
|
ON_HOLD = 'ON_HOLD',
|
||||||
|
COMPLETED = 'COMPLETED',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectResponseDto {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
domain: TaskDomain;
|
||||||
|
description?: string;
|
||||||
|
desiredOutcome?: string;
|
||||||
|
status: ProjectStatus;
|
||||||
|
connectwiseProjectId?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
export interface HealthCheckResponseDto {
|
||||||
|
status: 'ok' | 'error';
|
||||||
|
database: 'connected' | 'disconnected';
|
||||||
|
redis: 'connected' | 'disconnected';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error response
|
||||||
|
export interface ErrorResponseDto {
|
||||||
|
statusCode: number;
|
||||||
|
message: string;
|
||||||
|
timestamp: string;
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
18
packages/shared-types/tsconfig.json
Normal file
18
packages/shared-types/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
4
prd.json
4
prd.json
@@ -8,7 +8,7 @@
|
|||||||
"name": "GTD Inbox Capture",
|
"name": "GTD Inbox Capture",
|
||||||
"description": "Multi-source task capture system that ingests tasks from manual web form, REST API, email (IMAP/Microsoft Graph), and ConnectWise Manage sync into an unprocessed inbox for later GTD clarification",
|
"description": "Multi-source task capture system that ingests tasks from manual web form, REST API, email (IMAP/Microsoft Graph), and ConnectWise Manage sync into an unprocessed inbox for later GTD clarification",
|
||||||
"priority": 1,
|
"priority": 1,
|
||||||
"passes": false,
|
"passes": true,
|
||||||
"acceptance": "Manual tasks can be submitted via web form quick-add and appear in inbox"
|
"acceptance": "Manual tasks can be submitted via web form quick-add and appear in inbox"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"name": "GTD Processing Workflow",
|
"name": "GTD Processing Workflow",
|
||||||
"description": "Interactive inbox processing interface that guides users through GTD clarification: converting raw inbox items into Next Actions with context tags, Projects, Waiting For items, Someday/Maybe, Reference Material, Tickler items, or Trash",
|
"description": "Interactive inbox processing interface that guides users through GTD clarification: converting raw inbox items into Next Actions with context tags, Projects, Waiting For items, Someday/Maybe, Reference Material, Tickler items, or Trash",
|
||||||
"priority": 2,
|
"priority": 2,
|
||||||
"passes": false,
|
"passes": true,
|
||||||
"acceptance": "Inbox view displays unprocessed items with processing workflow controls"
|
"acceptance": "Inbox view displays unprocessed items with processing workflow controls"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
14
progress.txt
14
progress.txt
@@ -5,3 +5,17 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
[2026-01-11T04:59:40.694Z] [0] [INIT] - Project scaffold created
|
[2026-01-11T04:59:40.694Z] [0] [INIT] - Project scaffold created
|
||||||
|
[2026-01-11T08:42:00.000Z] [1] [COMPLETE] - Phase 1 Foundation implemented
|
||||||
|
- Monorepo initialized with npm workspaces (packages/backend, packages/frontend, packages/shared-types)
|
||||||
|
- Backend NestJS app scaffolded with modules: AuthModule, UsersModule, TasksModule, ProjectsModule, InboxModule, HealthModule
|
||||||
|
- Frontend React app created with Vite, TailwindCSS, Radix UI configured
|
||||||
|
- PostgreSQL database schema created via TypeORM entities: User, InboxItem, Task, Project
|
||||||
|
- Docker Compose stack configured: backend, postgres, redis, frontend services
|
||||||
|
- JWT-based authentication implemented with Passport.js, bcrypt password hashing
|
||||||
|
- Frontend authentication flow: login/register forms, protected routes, Zustand auth store
|
||||||
|
- Global exception filter for standardized error responses
|
||||||
|
- Health endpoint with database and Redis connection status
|
||||||
|
- Inbox capture endpoints: POST /api/v1/inbox, GET /api/v1/inbox, POST /api/v1/inbox/:id/process
|
||||||
|
- Inbox UI with quick-add form and GTD processing workflow modal
|
||||||
|
- All tests passing, build succeeds, lint passes (one warning)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user