From a8270cc5cb790c0477489e3bcdad6436a9ab7938 Mon Sep 17 00:00:00 2001 From: Automated Action Date: Fri, 27 Jun 2025 16:50:54 +0000 Subject: [PATCH] Complete rebuild: Node.js/Express/TypeScript WhatsApp AI task scheduling service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replaced Python/FastAPI implementation with Node.js/Express/TypeScript - Added Prisma ORM with SQLite database - Implemented JWT authentication with bcrypt password hashing - Created comprehensive task management API with CRUD operations - Integrated Twilio WhatsApp Business API for notifications - Added OpenAI integration for intelligent task scheduling - Implemented automated background jobs with node-cron - Added comprehensive error handling and validation - Structured logging with Winston - Complete API documentation and setup instructions 🤖 Generated with Claude Code Co-Authored-By: Claude --- .eslintrc.js | 25 ++ README.md | 206 ++++++++++++++- alembic.ini | 41 --- alembic/env.py | 55 ---- alembic/script.py.mako | 24 -- alembic/versions/001_initial_migration.py | 63 ----- app/__init__.py | 0 app/api/__init__.py | 0 app/api/auth.py | 53 ---- app/api/schemas.py | 76 ------ app/api/tasks.py | 165 ------------ app/core/__init__.py | 0 app/core/auth.py | 31 --- app/core/config.py | 23 -- app/core/security.py | 46 ---- app/db/__init__.py | 0 app/db/base.py | 3 - app/db/session.py | 22 -- app/models/__init__.py | 0 app/models/task.py | 40 --- app/models/user.py | 20 -- app/services/__init__.py | 0 app/services/ai_service.py | 133 ---------- app/services/scheduler_service.py | 129 --------- app/services/whatsapp_service.py | 64 ----- package.json | 48 ++++ prisma/schema.prisma | 59 +++++ requirements.txt | 15 -- src/app.ts | 100 +++++++ src/controllers/authController.ts | 118 +++++++++ src/controllers/healthController.ts | 42 +++ src/controllers/taskController.ts | 306 ++++++++++++++++++++++ src/index.ts | 43 +++ src/middleware/auth.ts | 50 ++++ src/middleware/errorHandler.ts | 26 ++ src/routes/auth.ts | 9 + src/routes/health.ts | 8 + src/routes/index.ts | 12 + src/routes/tasks.ts | 23 ++ src/services/aiService.ts | 162 ++++++++++++ src/services/schedulerService.ts | 190 ++++++++++++++ src/services/whatsappService.ts | 61 +++++ src/types/index.ts | 42 +++ src/utils/auth.ts | 25 ++ src/utils/config.ts | 27 ++ src/utils/database.ts | 45 ++++ src/utils/logger.ts | 27 ++ src/utils/validation.ts | 39 +++ tsconfig.json | 39 +++ 49 files changed, 1730 insertions(+), 1005 deletions(-) create mode 100644 .eslintrc.js delete mode 100644 alembic.ini delete mode 100644 alembic/env.py delete mode 100644 alembic/script.py.mako delete mode 100644 alembic/versions/001_initial_migration.py delete mode 100644 app/__init__.py delete mode 100644 app/api/__init__.py delete mode 100644 app/api/auth.py delete mode 100644 app/api/schemas.py delete mode 100644 app/api/tasks.py delete mode 100644 app/core/__init__.py delete mode 100644 app/core/auth.py delete mode 100644 app/core/config.py delete mode 100644 app/core/security.py delete mode 100644 app/db/__init__.py delete mode 100644 app/db/base.py delete mode 100644 app/db/session.py delete mode 100644 app/models/__init__.py delete mode 100644 app/models/task.py delete mode 100644 app/models/user.py delete mode 100644 app/services/__init__.py delete mode 100644 app/services/ai_service.py delete mode 100644 app/services/scheduler_service.py delete mode 100644 app/services/whatsapp_service.py create mode 100644 package.json create mode 100644 prisma/schema.prisma delete mode 100644 requirements.txt create mode 100644 src/app.ts create mode 100644 src/controllers/authController.ts create mode 100644 src/controllers/healthController.ts create mode 100644 src/controllers/taskController.ts create mode 100644 src/index.ts create mode 100644 src/middleware/auth.ts create mode 100644 src/middleware/errorHandler.ts create mode 100644 src/routes/auth.ts create mode 100644 src/routes/health.ts create mode 100644 src/routes/index.ts create mode 100644 src/routes/tasks.ts create mode 100644 src/services/aiService.ts create mode 100644 src/services/schedulerService.ts create mode 100644 src/services/whatsappService.ts create mode 100644 src/types/index.ts create mode 100644 src/utils/auth.ts create mode 100644 src/utils/config.ts create mode 100644 src/utils/database.ts create mode 100644 src/utils/logger.ts create mode 100644 src/utils/validation.ts create mode 100644 tsconfig.json diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..3361755 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,25 @@ +module.exports = { + parser: '@typescript-eslint/parser', + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname, + sourceType: 'module', + }, + plugins: ['@typescript-eslint'], + extends: [ + 'eslint:recommended', + '@typescript-eslint/recommended', + ], + root: true, + env: { + node: true, + jest: true, + }, + ignorePatterns: ['.eslintrc.js', 'dist/**/*'], + 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', + }, +}; \ No newline at end of file diff --git a/README.md b/README.md index e8acfba..8a2705c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,205 @@ -# FastAPI Application +# WhatsApp AI Task Scheduling Service -This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. +A Node.js/Express/TypeScript application that provides intelligent task scheduling with WhatsApp notifications and AI-powered optimization. + +## Features + +- **Task Management**: Create, update, delete, and track tasks with priorities and due dates +- **AI-Powered Scheduling**: Uses OpenAI to suggest optimal times for task completion +- **WhatsApp Integration**: Sends task reminders and notifications via WhatsApp using Twilio +- **User Authentication**: JWT-based authentication system +- **Automated Scheduling**: Background jobs for daily reminders and overdue task notifications +- **Health Monitoring**: Built-in health check endpoints + +## Tech Stack + +- **Runtime**: Node.js with TypeScript +- **Framework**: Express.js +- **Database**: SQLite with Prisma ORM +- **Authentication**: JWT tokens with bcrypt password hashing +- **WhatsApp**: Twilio WhatsApp Business API +- **AI**: OpenAI GPT-3.5 for intelligent scheduling +- **Scheduling**: node-cron for background jobs +- **Logging**: Winston for structured logging + +## Prerequisites + +- Node.js 18+ and npm +- Environment variables (see below) + +## Installation + +1. Clone the repository +2. Install dependencies: +```bash +npm install +``` + +3. Copy environment configuration: +```bash +cp .env.example .env +``` + +4. Configure environment variables in `.env` file + +5. Generate Prisma client and run migrations: +```bash +npm run prisma:generate +npm run prisma:migrate +``` + +6. Build the application: +```bash +npm run build +``` + +7. Start the server: +```bash +npm start +``` + +For development: +```bash +npm run dev +``` + +## Required Environment Variables + +```env +# Database +DATABASE_URL="file:./storage/db/database.db" + +# JWT Authentication +JWT_SECRET="your-super-secret-jwt-key-here" +JWT_EXPIRES_IN="24h" + +# Twilio WhatsApp Integration +TWILIO_ACCOUNT_SID="your-twilio-account-sid" +TWILIO_AUTH_TOKEN="your-twilio-auth-token" +TWILIO_WHATSAPP_FROM="whatsapp:+14155238886" + +# OpenAI for AI Features +OPENAI_API_KEY="your-openai-api-key" + +# Server Configuration +PORT=3000 +NODE_ENV="development" +``` + +## API Endpoints + +### Base URL +- **GET** `/` - Service information and documentation links +- **GET** `/docs` - API documentation +- **GET** `/api/health` - Health check endpoint + +### Authentication +- **POST** `/api/auth/register` - Register new user +- **POST** `/api/auth/login` - User login + +### Tasks (Protected Routes) +- **POST** `/api/tasks` - Create new task +- **GET** `/api/tasks` - Get user's tasks (with pagination and filtering) +- **GET** `/api/tasks/:id` - Get specific task +- **PUT** `/api/tasks/:id` - Update task +- **DELETE** `/api/tasks/:id` - Delete task +- **POST** `/api/tasks/:id/remind` - Send WhatsApp reminder for task + +## Usage Examples + +### Register User +```bash +curl -X POST http://localhost:3000/api/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "email": "user@example.com", + "password": "securepassword", + "fullName": "John Doe", + "whatsappNumber": "+1234567890" + }' +``` + +### Create Task +```bash +curl -X POST http://localhost:3000/api/tasks \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -d '{ + "title": "Complete project proposal", + "description": "Write and review the Q1 project proposal", + "priority": "HIGH", + "dueDate": "2024-02-15T17:00:00.000Z" + }' +``` + +## Features Details + +### AI-Powered Scheduling +The service uses OpenAI to analyze task priority, due dates, and optimal work hours to suggest the best time to work on tasks. + +### WhatsApp Notifications +- Daily reminders for scheduled tasks (9 AM UTC) +- Overdue task notifications (6 PM UTC) +- Manual reminder triggers via API + +### Background Jobs +- **Daily Reminders**: Sends WhatsApp reminders for tasks scheduled for today +- **Overdue Checks**: Notifies users about overdue tasks + +## Database Schema + +### Users Table +- `id`, `email`, `password` (hashed) +- `fullName`, `phoneNumber`, `whatsappNumber` +- `isActive`, `createdAt`, `updatedAt` + +### Tasks Table +- `id`, `title`, `description` +- `status` (PENDING, IN_PROGRESS, COMPLETED, CANCELLED) +- `priority` (LOW, MEDIUM, HIGH, URGENT) +- `dueDate`, `scheduledAt`, `aiSuggestedTime` +- `whatsappReminderSent`, `whatsappCompletionSent` +- `ownerId`, timestamps + +## Development + +### Available Scripts +- `npm run dev` - Start development server with hot reload +- `npm run build` - Build TypeScript to JavaScript +- `npm start` - Start production server +- `npm run lint` - Run ESLint +- `npm run lint:fix` - Fix ESLint issues +- `npm run prisma:generate` - Generate Prisma client +- `npm run prisma:migrate` - Run database migrations +- `npm run prisma:studio` - Open Prisma Studio + +### Project Structure +``` +src/ +├── controllers/ # Request handlers +├── middleware/ # Express middleware +├── models/ # Database models (Prisma) +├── routes/ # API route definitions +├── services/ # Business logic services +├── types/ # TypeScript type definitions +└── utils/ # Utility functions +``` + +## Error Handling + +The application includes comprehensive error handling with: +- Structured error responses +- Request validation using Joi +- Database error handling +- Authentication error handling +- Rate limiting protection + +## Logging + +Winston logger provides structured logging with different levels: +- Development: Console output with colors +- Production: File-based logging (error.log, combined.log) + +## Contributing + +This project was generated by BackendIM, an AI-powered backend generation platform. diff --git a/alembic.ini b/alembic.ini deleted file mode 100644 index 017f263..0000000 --- a/alembic.ini +++ /dev/null @@ -1,41 +0,0 @@ -[alembic] -script_location = alembic -prepend_sys_path = . -version_path_separator = os -sqlalchemy.url = sqlite:////app/storage/db/db.sqlite - -[post_write_hooks] - -[loggers] -keys = root,sqlalchemy,alembic - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARN -handlers = console -qualname = - -[logger_sqlalchemy] -level = WARN -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py deleted file mode 100644 index ad1be24..0000000 --- a/alembic/env.py +++ /dev/null @@ -1,55 +0,0 @@ -import os -import sys -from logging.config import fileConfig - -from sqlalchemy import engine_from_config -from sqlalchemy import pool - -from alembic import context - -sys.path = ['', '..'] + sys.path[1:] - -from app.db.base import Base -from app.models.user import User -from app.models.task import Task - -config = context.config - -if config.config_file_name is not None: - fileConfig(config.config_file_name) - -target_metadata = Base.metadata - -def run_migrations_offline() -> None: - url = config.get_main_option("sqlalchemy.url") - context.configure( - url=url, - target_metadata=target_metadata, - literal_binds=True, - dialect_opts={"paramstyle": "named"}, - ) - - with context.begin_transaction(): - context.run_migrations() - - -def run_migrations_online() -> None: - connectable = engine_from_config( - config.get_section(config.config_ini_section), - prefix="sqlalchemy.", - poolclass=pool.NullPool, - ) - - with connectable.connect() as connection: - context.configure( - connection=connection, target_metadata=target_metadata - ) - - with context.begin_transaction(): - context.run_migrations() - - -if context.is_offline_mode(): - run_migrations_offline() -else: - run_migrations_online() \ No newline at end of file diff --git a/alembic/script.py.mako b/alembic/script.py.mako deleted file mode 100644 index 37d0cac..0000000 --- a/alembic/script.py.mako +++ /dev/null @@ -1,24 +0,0 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} - -""" -from alembic import op -import sqlalchemy as sa -${imports if imports else ""} - -# revision identifiers, used by Alembic. -revision = ${repr(up_revision)} -down_revision = ${repr(down_revision)} -branch_labels = ${repr(branch_labels)} -depends_on = ${repr(depends_on)} - - -def upgrade() -> None: - ${upgrades if upgrades else "pass"} - - -def downgrade() -> None: - ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/alembic/versions/001_initial_migration.py b/alembic/versions/001_initial_migration.py deleted file mode 100644 index dcd4d66..0000000 --- a/alembic/versions/001_initial_migration.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Initial migration - -Revision ID: 001 -Revises: -Create Date: 2024-01-01 00:00:00.000000 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = '001' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('users', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('email', sa.String(), nullable=False), - sa.Column('hashed_password', sa.String(), nullable=False), - sa.Column('full_name', sa.String(), nullable=True), - sa.Column('phone_number', sa.String(), nullable=True), - sa.Column('whatsapp_number', sa.String(), nullable=True), - sa.Column('is_active', sa.Boolean(), nullable=True), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) - op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False) - op.create_table('tasks', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('title', sa.String(), nullable=False), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('status', sa.Enum('PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', name='taskstatus'), nullable=True), - sa.Column('priority', sa.Enum('LOW', 'MEDIUM', 'HIGH', 'URGENT', name='taskpriority'), nullable=True), - sa.Column('due_date', sa.DateTime(timezone=True), nullable=True), - sa.Column('scheduled_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('ai_suggested_time', sa.DateTime(timezone=True), nullable=True), - sa.Column('whatsapp_reminder_sent', sa.Boolean(), nullable=True), - sa.Column('whatsapp_completion_sent', sa.Boolean(), nullable=True), - sa.Column('owner_id', sa.Integer(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_tasks_id'), 'tasks', ['id'], unique=False) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_tasks_id'), table_name='tasks') - op.drop_table('tasks') - op.drop_index(op.f('ix_users_id'), table_name='users') - op.drop_index(op.f('ix_users_email'), table_name='users') - op.drop_table('users') - # ### end Alembic commands ### \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/api/__init__.py b/app/api/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/api/auth.py b/app/api/auth.py deleted file mode 100644 index 535ed40..0000000 --- a/app/api/auth.py +++ /dev/null @@ -1,53 +0,0 @@ -from datetime import timedelta -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.orm import Session -from app.db.session import get_db -from app.models.user import User -from app.api.schemas import UserCreate, UserResponse, UserLogin, Token -from app.core.security import verify_password, get_password_hash, create_access_token -from app.core.config import settings - -router = APIRouter() - - -@router.post("/register", response_model=UserResponse) -def register_user(user: UserCreate, db: Session = Depends(get_db)): - db_user = db.query(User).filter(User.email == user.email).first() - if db_user: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Email already registered" - ) - - hashed_password = get_password_hash(user.password) - db_user = User( - email=user.email, - hashed_password=hashed_password, - full_name=user.full_name, - phone_number=user.phone_number, - whatsapp_number=user.whatsapp_number - ) - db.add(db_user) - db.commit() - db.refresh(db_user) - - return db_user - - -@router.post("/login", response_model=Token) -def login_user(user_credentials: UserLogin, db: Session = Depends(get_db)): - user = db.query(User).filter(User.email == user_credentials.email).first() - - if not user or not verify_password(user_credentials.password, user.hashed_password): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect email or password", - headers={"WWW-Authenticate": "Bearer"}, - ) - - access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) - access_token = create_access_token( - data={"sub": user.email}, expires_delta=access_token_expires - ) - - return {"access_token": access_token, "token_type": "bearer"} \ No newline at end of file diff --git a/app/api/schemas.py b/app/api/schemas.py deleted file mode 100644 index a18dd93..0000000 --- a/app/api/schemas.py +++ /dev/null @@ -1,76 +0,0 @@ -from datetime import datetime -from typing import Optional -from pydantic import BaseModel, EmailStr -from app.models.task import TaskStatus, TaskPriority - - -class UserBase(BaseModel): - email: EmailStr - full_name: Optional[str] = None - phone_number: Optional[str] = None - whatsapp_number: Optional[str] = None - - -class UserCreate(UserBase): - password: str - - -class UserResponse(UserBase): - id: int - is_active: bool - created_at: datetime - - class Config: - from_attributes = True - - -class UserLogin(BaseModel): - email: EmailStr - password: str - - -class Token(BaseModel): - access_token: str - token_type: str - - -class TaskBase(BaseModel): - title: str - description: Optional[str] = None - priority: TaskPriority = TaskPriority.MEDIUM - due_date: Optional[datetime] = None - scheduled_at: Optional[datetime] = None - - -class TaskCreate(TaskBase): - pass - - -class TaskUpdate(BaseModel): - title: Optional[str] = None - description: Optional[str] = None - priority: Optional[TaskPriority] = None - status: Optional[TaskStatus] = None - due_date: Optional[datetime] = None - scheduled_at: Optional[datetime] = None - - -class TaskResponse(TaskBase): - id: int - status: TaskStatus - ai_suggested_time: Optional[datetime] = None - whatsapp_reminder_sent: bool - whatsapp_completion_sent: bool - owner_id: int - created_at: datetime - updated_at: Optional[datetime] = None - completed_at: Optional[datetime] = None - - class Config: - from_attributes = True - - -class HealthResponse(BaseModel): - status: str - timestamp: datetime - version: str \ No newline at end of file diff --git a/app/api/tasks.py b/app/api/tasks.py deleted file mode 100644 index 4340d73..0000000 --- a/app/api/tasks.py +++ /dev/null @@ -1,165 +0,0 @@ -from typing import List -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.orm import Session -from datetime import datetime -from app.db.session import get_db -from app.models.user import User -from app.models.task import Task, TaskStatus -from app.api.schemas import TaskCreate, TaskUpdate, TaskResponse -from app.core.auth import get_current_user -from app.services.ai_service import suggest_optimal_time -from app.services.whatsapp_service import send_task_reminder - -router = APIRouter() - - -@router.post("/", response_model=TaskResponse) -def create_task( - task: TaskCreate, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - ai_suggested_time = None - if task.due_date and not task.scheduled_at: - ai_suggested_time = suggest_optimal_time(task.due_date, task.priority) - - db_task = Task( - title=task.title, - description=task.description, - priority=task.priority, - due_date=task.due_date, - scheduled_at=task.scheduled_at, - ai_suggested_time=ai_suggested_time, - owner_id=current_user.id - ) - db.add(db_task) - db.commit() - db.refresh(db_task) - - return db_task - - -@router.get("/", response_model=List[TaskResponse]) -def get_tasks( - skip: int = 0, - limit: int = 100, - status: TaskStatus = None, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - query = db.query(Task).filter(Task.owner_id == current_user.id) - if status: - query = query.filter(Task.status == status) - - tasks = query.offset(skip).limit(limit).all() - return tasks - - -@router.get("/{task_id}", response_model=TaskResponse) -def get_task( - task_id: int, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - task = db.query(Task).filter( - Task.id == task_id, - Task.owner_id == current_user.id - ).first() - - if not task: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Task not found" - ) - - return task - - -@router.put("/{task_id}", response_model=TaskResponse) -def update_task( - task_id: int, - task_update: TaskUpdate, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - task = db.query(Task).filter( - Task.id == task_id, - Task.owner_id == current_user.id - ).first() - - if not task: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Task not found" - ) - - update_data = task_update.dict(exclude_unset=True) - - if task_update.status == TaskStatus.COMPLETED and task.status != TaskStatus.COMPLETED: - update_data["completed_at"] = datetime.utcnow() - - for field, value in update_data.items(): - setattr(task, field, value) - - db.commit() - db.refresh(task) - - return task - - -@router.delete("/{task_id}") -def delete_task( - task_id: int, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - task = db.query(Task).filter( - Task.id == task_id, - Task.owner_id == current_user.id - ).first() - - if not task: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Task not found" - ) - - db.delete(task) - db.commit() - - return {"message": "Task deleted successfully"} - - -@router.post("/{task_id}/send-reminder") -def send_reminder( - task_id: int, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - task = db.query(Task).filter( - Task.id == task_id, - Task.owner_id == current_user.id - ).first() - - if not task: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Task not found" - ) - - if not current_user.whatsapp_number: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="WhatsApp number not configured" - ) - - success = send_task_reminder(current_user.whatsapp_number, task) - if success: - task.whatsapp_reminder_sent = True - db.commit() - return {"message": "Reminder sent successfully"} - else: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to send reminder" - ) \ No newline at end of file diff --git a/app/core/__init__.py b/app/core/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/core/auth.py b/app/core/auth.py deleted file mode 100644 index ef8f834..0000000 --- a/app/core/auth.py +++ /dev/null @@ -1,31 +0,0 @@ -from fastapi import Depends, HTTPException, status -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from sqlalchemy.orm import Session -from app.db.session import get_db -from app.models.user import User -from app.core.security import verify_token - -security = HTTPBearer() - - -def get_current_user( - credentials: HTTPAuthorizationCredentials = Depends(security), - db: Session = Depends(get_db) -) -> User: - payload = verify_token(credentials.credentials) - email = payload.get("sub") - - user = db.query(User).filter(User.email == email).first() - if user is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="User not found" - ) - - if not user.is_active: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Inactive user" - ) - - return user \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py deleted file mode 100644 index b159f49..0000000 --- a/app/core/config.py +++ /dev/null @@ -1,23 +0,0 @@ -import os -from pydantic import BaseSettings - - -class Settings(BaseSettings): - SECRET_KEY: str = os.getenv("SECRET_KEY", "your-secret-key-here") - ALGORITHM: str = "HS256" - ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 - - TWILIO_ACCOUNT_SID: str = os.getenv("TWILIO_ACCOUNT_SID", "") - TWILIO_AUTH_TOKEN: str = os.getenv("TWILIO_AUTH_TOKEN", "") - TWILIO_WHATSAPP_FROM: str = os.getenv("TWILIO_WHATSAPP_FROM", "") - - OPENAI_API_KEY: str = os.getenv("OPENAI_API_KEY", "") - - PROJECT_NAME: str = "WhatsApp AI Task Scheduling Service" - PROJECT_VERSION: str = "1.0.0" - - class Config: - env_file = ".env" - - -settings = Settings() \ No newline at end of file diff --git a/app/core/security.py b/app/core/security.py deleted file mode 100644 index 7a9aea6..0000000 --- a/app/core/security.py +++ /dev/null @@ -1,46 +0,0 @@ -from datetime import datetime, timedelta -from typing import Optional -from jose import JWTError, jwt -from passlib.context import CryptContext -from fastapi import HTTPException, status -from app.core.config import settings - -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - - -def verify_password(plain_password: str, hashed_password: str) -> bool: - return pwd_context.verify(plain_password, hashed_password) - - -def get_password_hash(password: str) -> str: - return pwd_context.hash(password) - - -def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): - to_encode = data.copy() - if expires_delta: - expire = datetime.utcnow() + expires_delta - else: - expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) - to_encode.update({"exp": expire}) - encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) - return encoded_jwt - - -def verify_token(token: str) -> dict: - try: - payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) - email: str = payload.get("sub") - if email is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) - return payload - except JWTError: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) \ No newline at end of file diff --git a/app/db/__init__.py b/app/db/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/db/base.py b/app/db/base.py deleted file mode 100644 index 7c2377a..0000000 --- a/app/db/base.py +++ /dev/null @@ -1,3 +0,0 @@ -from sqlalchemy.ext.declarative import declarative_base - -Base = declarative_base() \ No newline at end of file diff --git a/app/db/session.py b/app/db/session.py deleted file mode 100644 index 684d6e7..0000000 --- a/app/db/session.py +++ /dev/null @@ -1,22 +0,0 @@ -from pathlib import Path -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker - -DB_DIR = Path("/app") / "storage" / "db" -DB_DIR.mkdir(parents=True, exist_ok=True) - -SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_DIR}/db.sqlite" - -engine = create_engine( - SQLALCHEMY_DATABASE_URL, - connect_args={"check_same_thread": False} -) - -SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - -def get_db(): - db = SessionLocal() - try: - yield db - finally: - db.close() \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/models/task.py b/app/models/task.py deleted file mode 100644 index 75a6805..0000000 --- a/app/models/task.py +++ /dev/null @@ -1,40 +0,0 @@ -from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, ForeignKey, Enum -from sqlalchemy.sql import func -from sqlalchemy.orm import relationship -from app.db.base import Base -import enum - - -class TaskStatus(str, enum.Enum): - PENDING = "pending" - IN_PROGRESS = "in_progress" - COMPLETED = "completed" - CANCELLED = "cancelled" - - -class TaskPriority(str, enum.Enum): - LOW = "low" - MEDIUM = "medium" - HIGH = "high" - URGENT = "urgent" - - -class Task(Base): - __tablename__ = "tasks" - - id = Column(Integer, primary_key=True, index=True) - title = Column(String, nullable=False) - description = Column(Text, nullable=True) - status = Column(Enum(TaskStatus), default=TaskStatus.PENDING) - priority = Column(Enum(TaskPriority), default=TaskPriority.MEDIUM) - due_date = Column(DateTime(timezone=True), nullable=True) - scheduled_at = Column(DateTime(timezone=True), nullable=True) - ai_suggested_time = Column(DateTime(timezone=True), nullable=True) - whatsapp_reminder_sent = Column(Boolean, default=False) - whatsapp_completion_sent = Column(Boolean, default=False) - owner_id = Column(Integer, ForeignKey("users.id"), nullable=False) - created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), onupdate=func.now()) - completed_at = Column(DateTime(timezone=True), nullable=True) - - owner = relationship("User", back_populates="tasks") \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py deleted file mode 100644 index 9749be2..0000000 --- a/app/models/user.py +++ /dev/null @@ -1,20 +0,0 @@ -from sqlalchemy import Column, Integer, String, DateTime, Boolean -from sqlalchemy.sql import func -from sqlalchemy.orm import relationship -from app.db.base import Base - - -class User(Base): - __tablename__ = "users" - - id = Column(Integer, primary_key=True, index=True) - email = Column(String, unique=True, index=True, nullable=False) - hashed_password = Column(String, nullable=False) - full_name = Column(String, nullable=True) - phone_number = Column(String, nullable=True) - whatsapp_number = Column(String, nullable=True) - is_active = Column(Boolean, default=True) - created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), onupdate=func.now()) - - tasks = relationship("Task", back_populates="owner") \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/services/ai_service.py b/app/services/ai_service.py deleted file mode 100644 index 786f789..0000000 --- a/app/services/ai_service.py +++ /dev/null @@ -1,133 +0,0 @@ -import logging -from datetime import datetime, timedelta -from typing import Optional, List -import openai -from app.core.config import settings -from app.models.task import TaskPriority - -logger = logging.getLogger(__name__) - - -class AIService: - def __init__(self): - if settings.OPENAI_API_KEY: - openai.api_key = settings.OPENAI_API_KEY - self.client = openai.OpenAI(api_key=settings.OPENAI_API_KEY) - else: - self.client = None - logger.warning("OpenAI API key not configured. AI functionality disabled.") - - def suggest_optimal_time(self, due_date: datetime, priority: TaskPriority) -> Optional[datetime]: - if not self.client: - return self._fallback_scheduling(due_date, priority) - - try: - current_time = datetime.utcnow() - time_until_due = due_date - current_time - - if time_until_due.total_seconds() <= 0: - return current_time + timedelta(hours=1) - - prompt = f""" - Based on task management best practices, suggest an optimal time to work on a task with the following details: - - Current time: {current_time.strftime('%Y-%m-%d %H:%M:%S')} - Due date: {due_date.strftime('%Y-%m-%d %H:%M:%S')} - Priority: {priority.value} - Time available: {time_until_due.days} days, {time_until_due.seconds // 3600} hours - - Consider: - - Task priority (urgent tasks should be scheduled sooner) - - Optimal work hours (9 AM - 5 PM on weekdays) - - Buffer time before due date - - Work-life balance - - Respond with only the suggested datetime in format: YYYY-MM-DD HH:MM:SS - """ - - response = self.client.chat.completions.create( - model="gpt-3.5-turbo", - messages=[ - {"role": "system", "content": "You are a task scheduling AI assistant."}, - {"role": "user", "content": prompt} - ], - max_tokens=50, - temperature=0.3 - ) - - suggested_time_str = response.choices[0].message.content.strip() - suggested_time = datetime.strptime(suggested_time_str, '%Y-%m-%d %H:%M:%S') - - if suggested_time < current_time: - suggested_time = current_time + timedelta(hours=1) - elif suggested_time > due_date: - suggested_time = due_date - timedelta(hours=2) - - return suggested_time - - except Exception as e: - logger.error(f"AI scheduling failed: {str(e)}") - return self._fallback_scheduling(due_date, priority) - - def _fallback_scheduling(self, due_date: datetime, priority: TaskPriority) -> datetime: - current_time = datetime.utcnow() - time_until_due = due_date - current_time - - if priority == TaskPriority.URGENT: - return current_time + timedelta(hours=1) - elif priority == TaskPriority.HIGH: - return current_time + timedelta(hours=min(6, time_until_due.total_seconds() // 3600 // 4)) - elif priority == TaskPriority.MEDIUM: - return current_time + timedelta(hours=min(24, time_until_due.total_seconds() // 3600 // 2)) - else: # LOW priority - return current_time + timedelta(hours=min(48, time_until_due.total_seconds() // 3600 * 0.75)) - - def generate_task_insights(self, tasks: List) -> str: - if not self.client: - return "AI insights not available. Please configure OpenAI API key." - - try: - task_summary = [] - for task in tasks: - task_summary.append(f"- {task.title} (Priority: {task.priority.value}, Status: {task.status.value})") - - prompt = f""" - Analyze the following task list and provide productivity insights: - - {chr(10).join(task_summary)} - - Provide insights on: - 1. Task distribution by priority - 2. Potential bottlenecks - 3. Productivity recommendations - 4. Time management suggestions - - Keep the response concise and actionable (max 200 words). - """ - - response = self.client.chat.completions.create( - model="gpt-3.5-turbo", - messages=[ - {"role": "system", "content": "You are a productivity analysis AI assistant."}, - {"role": "user", "content": prompt} - ], - max_tokens=250, - temperature=0.7 - ) - - return response.choices[0].message.content.strip() - - except Exception as e: - logger.error(f"AI insights generation failed: {str(e)}") - return "Unable to generate insights at this time." - - -ai_service = AIService() - - -def suggest_optimal_time(due_date: datetime, priority: TaskPriority) -> Optional[datetime]: - return ai_service.suggest_optimal_time(due_date, priority) - - -def generate_task_insights(tasks: List) -> str: - return ai_service.generate_task_insights(tasks) \ No newline at end of file diff --git a/app/services/scheduler_service.py b/app/services/scheduler_service.py deleted file mode 100644 index 71b4e5e..0000000 --- a/app/services/scheduler_service.py +++ /dev/null @@ -1,129 +0,0 @@ -import logging -from datetime import datetime, timedelta -from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.triggers.cron import CronTrigger -from sqlalchemy.orm import Session -from app.db.session import SessionLocal -from app.models.task import Task, TaskStatus -from app.models.user import User -from app.services.whatsapp_service import send_task_reminder, send_task_overdue - -logger = logging.getLogger(__name__) - - -class SchedulerService: - def __init__(self): - self.scheduler = BackgroundScheduler() - self.scheduler.start() - self._setup_jobs() - - def _setup_jobs(self): - self.scheduler.add_job( - func=self.send_reminders, - trigger=CronTrigger(hour=9, minute=0), # Daily at 9 AM - id='daily_reminders', - name='Send daily task reminders', - replace_existing=True - ) - - self.scheduler.add_job( - func=self.check_overdue_tasks, - trigger=CronTrigger(hour=18, minute=0), # Daily at 6 PM - id='overdue_check', - name='Check for overdue tasks', - replace_existing=True - ) - - def send_reminders(self): - logger.info("Starting daily reminder job") - db: Session = SessionLocal() - - try: - # Get tasks scheduled for today that haven't been reminded - today = datetime.utcnow().date() - tomorrow = today + timedelta(days=1) - - tasks = db.query(Task).join(User).filter( - Task.status.in_([TaskStatus.PENDING, TaskStatus.IN_PROGRESS]), - Task.scheduled_at >= datetime.combine(today, datetime.min.time()), - Task.scheduled_at < datetime.combine(tomorrow, datetime.min.time()), - Task.whatsapp_reminder_sent == False, - User.whatsapp_number.isnot(None) - ).all() - - sent_count = 0 - for task in tasks: - if send_task_reminder(task.owner.whatsapp_number, task): - task.whatsapp_reminder_sent = True - sent_count += 1 - - db.commit() - logger.info(f"Sent {sent_count} reminders") - - except Exception as e: - logger.error(f"Error in reminder job: {str(e)}") - db.rollback() - finally: - db.close() - - def check_overdue_tasks(self): - logger.info("Starting overdue task check") - db: Session = SessionLocal() - - try: - now = datetime.utcnow() - overdue_tasks = db.query(Task).join(User).filter( - Task.status.in_([TaskStatus.PENDING, TaskStatus.IN_PROGRESS]), - Task.due_date < now, - User.whatsapp_number.isnot(None) - ).all() - - sent_count = 0 - for task in overdue_tasks: - if send_task_overdue(task.owner.whatsapp_number, task): - sent_count += 1 - - logger.info(f"Sent {sent_count} overdue notifications") - - except Exception as e: - logger.error(f"Error in overdue check job: {str(e)}") - finally: - db.close() - - def schedule_task_reminder(self, task_id: int, remind_at: datetime): - job_id = f"task_reminder_{task_id}" - - self.scheduler.add_job( - func=self._send_individual_reminder, - trigger='date', - run_date=remind_at, - args=[task_id], - id=job_id, - name=f'Reminder for task {task_id}', - replace_existing=True - ) - - def _send_individual_reminder(self, task_id: int): - db: Session = SessionLocal() - - try: - task = db.query(Task).join(User).filter( - Task.id == task_id, - User.whatsapp_number.isnot(None) - ).first() - - if task and task.status in [TaskStatus.PENDING, TaskStatus.IN_PROGRESS]: - send_task_reminder(task.owner.whatsapp_number, task) - task.whatsapp_reminder_sent = True - db.commit() - - except Exception as e: - logger.error(f"Error sending individual reminder for task {task_id}: {str(e)}") - finally: - db.close() - - def shutdown(self): - self.scheduler.shutdown() - - -scheduler_service = SchedulerService() \ No newline at end of file diff --git a/app/services/whatsapp_service.py b/app/services/whatsapp_service.py deleted file mode 100644 index 0890ec3..0000000 --- a/app/services/whatsapp_service.py +++ /dev/null @@ -1,64 +0,0 @@ -import logging -from typing import Optional -from twilio.rest import Client -from twilio.base.exceptions import TwilioException -from app.core.config import settings -from app.models.task import Task - -logger = logging.getLogger(__name__) - - -class WhatsAppService: - def __init__(self): - if settings.TWILIO_ACCOUNT_SID and settings.TWILIO_AUTH_TOKEN: - self.client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN) - else: - self.client = None - logger.warning("Twilio credentials not configured. WhatsApp functionality disabled.") - - def send_message(self, to_number: str, message: str) -> bool: - if not self.client: - logger.error("WhatsApp service not configured") - return False - - try: - # Ensure phone number is in WhatsApp format - if not to_number.startswith("whatsapp:"): - to_number = f"whatsapp:{to_number}" - - message = self.client.messages.create( - body=message, - from_=settings.TWILIO_WHATSAPP_FROM, - to=to_number - ) - logger.info(f"WhatsApp message sent successfully. SID: {message.sid}") - return True - except TwilioException as e: - logger.error(f"Failed to send WhatsApp message: {str(e)}") - return False - except Exception as e: - logger.error(f"Unexpected error sending WhatsApp message: {str(e)}") - return False - - -whatsapp_service = WhatsAppService() - - -def send_task_reminder(phone_number: str, task: Task) -> bool: - due_info = f" (Due: {task.due_date.strftime('%Y-%m-%d %H:%M')})" if task.due_date else "" - message = f"🔔 Task Reminder: {task.title}\n\n{task.description or 'No description provided'}{due_info}\n\nPriority: {task.priority.value.upper()}" - - return whatsapp_service.send_message(phone_number, message) - - -def send_task_completion(phone_number: str, task: Task) -> bool: - message = f"✅ Task Completed: {task.title}\n\nCongratulations! You've successfully completed this task." - - return whatsapp_service.send_message(phone_number, message) - - -def send_task_overdue(phone_number: str, task: Task) -> bool: - overdue_days = (task.due_date - task.created_at).days if task.due_date else 0 - message = f"⚠️ Task Overdue: {task.title}\n\nThis task is overdue by {overdue_days} days. Please complete it as soon as possible.\n\nPriority: {task.priority.value.upper()}" - - return whatsapp_service.send_message(phone_number, message) \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..3df0f56 --- /dev/null +++ b/package.json @@ -0,0 +1,48 @@ +{ + "name": "whatsapp-ai-task-scheduling-service", + "version": "1.0.0", + "description": "WhatsApp AI Task Scheduling Service built with Node.js, Express, and TypeScript", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "ts-node-dev --respawn --transpile-only src/index.ts", + "lint": "eslint src/**/*.ts", + "lint:fix": "eslint src/**/*.ts --fix", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev", + "prisma:studio": "prisma studio" + }, + "keywords": ["whatsapp", "ai", "task-scheduling", "nodejs", "express", "typescript"], + "author": "BackendIM", + "license": "MIT", + "dependencies": { + "express": "^4.18.2", + "express-rate-limit": "^7.1.5", + "helmet": "^7.1.0", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "jsonwebtoken": "^9.0.2", + "bcryptjs": "^2.4.3", + "joi": "^17.11.0", + "prisma": "^5.7.1", + "@prisma/client": "^5.7.1", + "twilio": "^4.19.0", + "openai": "^4.20.1", + "node-cron": "^3.0.3", + "winston": "^3.11.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/cors": "^2.8.17", + "@types/jsonwebtoken": "^9.0.5", + "@types/bcryptjs": "^2.4.6", + "@types/node": "^20.10.4", + "@types/node-cron": "^3.0.11", + "@typescript-eslint/eslint-plugin": "^6.14.0", + "@typescript-eslint/parser": "^6.14.0", + "eslint": "^8.55.0", + "ts-node-dev": "^2.0.0", + "typescript": "^5.3.3" + } +} \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..91df7aa --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,59 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + password String + fullName String? @map("full_name") + phoneNumber String? @map("phone_number") + whatsappNumber String? @map("whatsapp_number") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + tasks Task[] + + @@map("users") +} + +model Task { + id Int @id @default(autoincrement()) + title String + description String? + status TaskStatus @default(PENDING) + priority TaskPriority @default(MEDIUM) + dueDate DateTime? @map("due_date") + scheduledAt DateTime? @map("scheduled_at") + aiSuggestedTime DateTime? @map("ai_suggested_time") + whatsappReminderSent Boolean @default(false) @map("whatsapp_reminder_sent") + whatsappCompletionSent Boolean @default(false) @map("whatsapp_completion_sent") + ownerId Int @map("owner_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + completedAt DateTime? @map("completed_at") + + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + + @@map("tasks") +} + +enum TaskStatus { + PENDING + IN_PROGRESS + COMPLETED + CANCELLED +} + +enum TaskPriority { + LOW + MEDIUM + HIGH + URGENT +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 025bd19..0000000 --- a/requirements.txt +++ /dev/null @@ -1,15 +0,0 @@ -fastapi==0.104.1 -uvicorn==0.24.0 -sqlalchemy==2.0.23 -alembic==1.13.0 -pydantic==2.5.0 -python-multipart==0.0.6 -passlib==1.7.4 -python-jose==3.3.0 -bcrypt==4.1.2 -httpx==0.25.2 -python-dotenv==1.0.0 -apscheduler==3.10.4 -openai==1.3.7 -twilio==8.10.3 -ruff==0.1.6 \ No newline at end of file diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..8920095 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,100 @@ +import express from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import rateLimit from 'express-rate-limit'; +import { config } from '@/utils/config'; +import logger from '@/utils/logger'; +import { errorHandler } from '@/middleware/errorHandler'; +import routes from '@/routes'; +import schedulerService from '@/services/schedulerService'; + +const app = express(); + +app.use(helmet()); + +app.use(cors({ + origin: '*', + credentials: true, +})); + +const limiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, // limit each IP to 100 requests per windowMs + message: { + success: false, + message: 'Too many requests from this IP, please try again later.', + }, +}); + +app.use(limiter); + +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true, limit: '10mb' })); + +app.get('/', (req, res) => { + res.json({ + success: true, + message: 'WhatsApp AI Task Scheduling Service', + data: { + title: 'WhatsApp AI Task Scheduling Service', + version: '1.0.0', + documentation: '/docs', + health: '/health', + endpoints: { + auth: '/api/auth', + tasks: '/api/tasks', + health: '/api/health' + } + } + }); +}); + +app.use('/api', routes); + +app.get('/docs', (req, res) => { + res.json({ + success: true, + message: 'API Documentation', + data: { + title: 'WhatsApp AI Task Scheduling Service API', + version: '1.0.0', + endpoints: { + auth: { + 'POST /api/auth/register': 'Register a new user', + 'POST /api/auth/login': 'Login user' + }, + tasks: { + 'POST /api/tasks': 'Create a new task', + 'GET /api/tasks': 'Get all tasks for user', + 'GET /api/tasks/:id': 'Get specific task', + 'PUT /api/tasks/:id': 'Update task', + 'DELETE /api/tasks/:id': 'Delete task', + 'POST /api/tasks/:id/remind': 'Send WhatsApp reminder' + }, + health: { + 'GET /api/health': 'Health check endpoint' + } + } + } + }); +}); + +app.use('*', (req, res) => { + res.status(404).json({ + success: false, + message: 'Route not found', + }); +}); + +app.use(errorHandler); + +const gracefulShutdown = () => { + logger.info('Received shutdown signal, shutting down gracefully...'); + schedulerService.shutdown(); + process.exit(0); +}; + +process.on('SIGTERM', gracefulShutdown); +process.on('SIGINT', gracefulShutdown); + +export default app; \ No newline at end of file diff --git a/src/controllers/authController.ts b/src/controllers/authController.ts new file mode 100644 index 0000000..abfee0e --- /dev/null +++ b/src/controllers/authController.ts @@ -0,0 +1,118 @@ +import { Request, Response } from 'express'; +import { hashPassword, comparePassword, generateToken } from '@/utils/auth'; +import { validate, userRegistrationSchema, userLoginSchema } from '@/utils/validation'; +import prisma from '@/utils/database'; +import logger from '@/utils/logger'; +import { CreateUserData, LoginData, ApiResponse } from '@/types'; + +export const register = async (req: Request, res: Response) => { + try { + const userData: CreateUserData = validate(userRegistrationSchema, req.body); + + const existingUser = await prisma.user.findUnique({ + where: { email: userData.email } + }); + + if (existingUser) { + return res.status(400).json({ + success: false, + message: 'User with this email already exists' + } as ApiResponse); + } + + const hashedPassword = await hashPassword(userData.password); + + const user = await prisma.user.create({ + data: { + email: userData.email, + password: hashedPassword, + fullName: userData.fullName, + phoneNumber: userData.phoneNumber, + whatsappNumber: userData.whatsappNumber, + }, + select: { + id: true, + email: true, + fullName: true, + phoneNumber: true, + whatsappNumber: true, + isActive: true, + createdAt: true, + } + }); + + const token = generateToken({ id: user.id, email: user.email }); + + logger.info(`New user registered: ${user.email}`); + + return res.status(201).json({ + success: true, + message: 'User registered successfully', + data: { + user, + token + } + } as ApiResponse); + + } catch (error) { + logger.error('Registration error:', error); + return res.status(400).json({ + success: false, + message: error instanceof Error ? error.message : 'Registration failed' + } as ApiResponse); + } +}; + +export const login = async (req: Request, res: Response) => { + try { + const loginData: LoginData = validate(userLoginSchema, req.body); + + const user = await prisma.user.findUnique({ + where: { email: loginData.email } + }); + + if (!user || !user.isActive) { + return res.status(401).json({ + success: false, + message: 'Invalid credentials' + } as ApiResponse); + } + + const isPasswordValid = await comparePassword(loginData.password, user.password); + + if (!isPasswordValid) { + return res.status(401).json({ + success: false, + message: 'Invalid credentials' + } as ApiResponse); + } + + const token = generateToken({ id: user.id, email: user.email }); + + logger.info(`User logged in: ${user.email}`); + + return res.status(200).json({ + success: true, + message: 'Login successful', + data: { + user: { + id: user.id, + email: user.email, + fullName: user.fullName, + phoneNumber: user.phoneNumber, + whatsappNumber: user.whatsappNumber, + isActive: user.isActive, + createdAt: user.createdAt, + }, + token + } + } as ApiResponse); + + } catch (error) { + logger.error('Login error:', error); + return res.status(400).json({ + success: false, + message: error instanceof Error ? error.message : 'Login failed' + } as ApiResponse); + } +}; \ No newline at end of file diff --git a/src/controllers/healthController.ts b/src/controllers/healthController.ts new file mode 100644 index 0000000..41f749a --- /dev/null +++ b/src/controllers/healthController.ts @@ -0,0 +1,42 @@ +import { Request, Response } from 'express'; +import prisma from '@/utils/database'; +import { ApiResponse } from '@/types'; + +export const healthCheck = async (req: Request, res: Response) => { + try { + await prisma.$queryRaw`SELECT 1`; + + const healthData = { + status: 'healthy', + timestamp: new Date().toISOString(), + version: '1.0.0', + services: { + database: 'connected', + api: 'running' + } + }; + + return res.status(200).json({ + success: true, + message: 'Service is healthy', + data: healthData + } as ApiResponse); + + } catch (error) { + const healthData = { + status: 'unhealthy', + timestamp: new Date().toISOString(), + version: '1.0.0', + services: { + database: 'disconnected', + api: 'running' + } + }; + + return res.status(503).json({ + success: false, + message: 'Service is unhealthy', + data: healthData + } as ApiResponse); + } +}; \ No newline at end of file diff --git a/src/controllers/taskController.ts b/src/controllers/taskController.ts new file mode 100644 index 0000000..be6a1a3 --- /dev/null +++ b/src/controllers/taskController.ts @@ -0,0 +1,306 @@ +import { Response } from 'express'; +import { validate, taskCreationSchema, taskUpdateSchema } from '@/utils/validation'; +import prisma from '@/utils/database'; +import logger from '@/utils/logger'; +import { AuthenticatedRequest, CreateTaskData, UpdateTaskData, ApiResponse } from '@/types'; +import { suggestOptimalTime } from '@/services/aiService'; +import { sendTaskReminder } from '@/services/whatsappService'; + +export const createTask = async (req: AuthenticatedRequest, res: Response) => { + try { + const taskData: CreateTaskData = validate(taskCreationSchema, req.body); + const userId = req.user!.id; + + let aiSuggestedTime = null; + if (taskData.dueDate && !taskData.scheduledAt) { + aiSuggestedTime = await suggestOptimalTime(taskData.dueDate, taskData.priority || 'MEDIUM'); + } + + const task = await prisma.task.create({ + data: { + title: taskData.title, + description: taskData.description, + priority: taskData.priority || 'MEDIUM', + dueDate: taskData.dueDate, + scheduledAt: taskData.scheduledAt, + aiSuggestedTime, + ownerId: userId, + }, + include: { + owner: { + select: { + id: true, + email: true, + fullName: true, + } + } + } + }); + + logger.info(`Task created: ${task.title} by user ${userId}`); + + return res.status(201).json({ + success: true, + message: 'Task created successfully', + data: task + } as ApiResponse); + + } catch (error) { + logger.error('Task creation error:', error); + return res.status(400).json({ + success: false, + message: error instanceof Error ? error.message : 'Task creation failed' + } as ApiResponse); + } +}; + +export const getTasks = async (req: AuthenticatedRequest, res: Response) => { + try { + const userId = req.user!.id; + const { status, priority, page = '1', limit = '20' } = req.query; + + const pageNum = parseInt(page as string); + const limitNum = parseInt(limit as string); + const skip = (pageNum - 1) * limitNum; + + const where: any = { ownerId: userId }; + if (status) where.status = status; + if (priority) where.priority = priority; + + const [tasks, total] = await Promise.all([ + prisma.task.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: limitNum, + include: { + owner: { + select: { + id: true, + email: true, + fullName: true, + } + } + } + }), + prisma.task.count({ where }) + ]); + + return res.status(200).json({ + success: true, + message: 'Tasks retrieved successfully', + data: { + tasks, + pagination: { + page: pageNum, + limit: limitNum, + total, + pages: Math.ceil(total / limitNum) + } + } + } as ApiResponse); + + } catch (error) { + logger.error('Get tasks error:', error); + return res.status(500).json({ + success: false, + message: 'Failed to retrieve tasks' + } as ApiResponse); + } +}; + +export const getTask = async (req: AuthenticatedRequest, res: Response) => { + try { + const taskId = parseInt(req.params.id); + const userId = req.user!.id; + + const task = await prisma.task.findFirst({ + where: { + id: taskId, + ownerId: userId + }, + include: { + owner: { + select: { + id: true, + email: true, + fullName: true, + } + } + } + }); + + if (!task) { + return res.status(404).json({ + success: false, + message: 'Task not found' + } as ApiResponse); + } + + return res.status(200).json({ + success: true, + message: 'Task retrieved successfully', + data: task + } as ApiResponse); + + } catch (error) { + logger.error('Get task error:', error); + return res.status(500).json({ + success: false, + message: 'Failed to retrieve task' + } as ApiResponse); + } +}; + +export const updateTask = async (req: AuthenticatedRequest, res: Response) => { + try { + const taskId = parseInt(req.params.id); + const userId = req.user!.id; + const updateData: UpdateTaskData = validate(taskUpdateSchema, req.body); + + const existingTask = await prisma.task.findFirst({ + where: { + id: taskId, + ownerId: userId + } + }); + + if (!existingTask) { + return res.status(404).json({ + success: false, + message: 'Task not found' + } as ApiResponse); + } + + const updatePayload: any = { ...updateData }; + + if (updateData.status === 'COMPLETED' && existingTask.status !== 'COMPLETED') { + updatePayload.completedAt = new Date(); + } + + const task = await prisma.task.update({ + where: { id: taskId }, + data: updatePayload, + include: { + owner: { + select: { + id: true, + email: true, + fullName: true, + } + } + } + }); + + logger.info(`Task updated: ${task.title} by user ${userId}`); + + return res.status(200).json({ + success: true, + message: 'Task updated successfully', + data: task + } as ApiResponse); + + } catch (error) { + logger.error('Task update error:', error); + return res.status(400).json({ + success: false, + message: error instanceof Error ? error.message : 'Task update failed' + } as ApiResponse); + } +}; + +export const deleteTask = async (req: AuthenticatedRequest, res: Response) => { + try { + const taskId = parseInt(req.params.id); + const userId = req.user!.id; + + const task = await prisma.task.findFirst({ + where: { + id: taskId, + ownerId: userId + } + }); + + if (!task) { + return res.status(404).json({ + success: false, + message: 'Task not found' + } as ApiResponse); + } + + await prisma.task.delete({ + where: { id: taskId } + }); + + logger.info(`Task deleted: ${task.title} by user ${userId}`); + + return res.status(200).json({ + success: true, + message: 'Task deleted successfully' + } as ApiResponse); + + } catch (error) { + logger.error('Task deletion error:', error); + return res.status(500).json({ + success: false, + message: 'Failed to delete task' + } as ApiResponse); + } +}; + +export const sendReminder = async (req: AuthenticatedRequest, res: Response) => { + try { + const taskId = parseInt(req.params.id); + const userId = req.user!.id; + + const task = await prisma.task.findFirst({ + where: { + id: taskId, + ownerId: userId + }, + include: { + owner: true + } + }); + + if (!task) { + return res.status(404).json({ + success: false, + message: 'Task not found' + } as ApiResponse); + } + + if (!task.owner.whatsappNumber) { + return res.status(400).json({ + success: false, + message: 'WhatsApp number not configured' + } as ApiResponse); + } + + const success = await sendTaskReminder(task.owner.whatsappNumber, task); + + if (success) { + await prisma.task.update({ + where: { id: taskId }, + data: { whatsappReminderSent: true } + }); + + return res.status(200).json({ + success: true, + message: 'Reminder sent successfully' + } as ApiResponse); + } else { + return res.status(500).json({ + success: false, + message: 'Failed to send reminder' + } as ApiResponse); + } + + } catch (error) { + logger.error('Send reminder error:', error); + return res.status(500).json({ + success: false, + message: 'Failed to send reminder' + } as ApiResponse); + } +}; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..77975de --- /dev/null +++ b/src/index.ts @@ -0,0 +1,43 @@ +import app from './app'; +import { config } from '@/utils/config'; +import logger from '@/utils/logger'; +import prisma from '@/utils/database'; + +async function startServer() { + try { + await prisma.$connect(); + logger.info('Database connected successfully'); + + const server = app.listen(config.port, () => { + logger.info(`Server is running on port ${config.port}`); + logger.info(`Environment: ${config.nodeEnv}`); + logger.info(`Health check: http://localhost:${config.port}/api/health`); + logger.info(`Documentation: http://localhost:${config.port}/docs`); + }); + + const gracefulShutdown = () => { + logger.info('Received shutdown signal, shutting down gracefully...'); + server.close(() => { + logger.info('HTTP server closed'); + prisma.$disconnect() + .then(() => { + logger.info('Database connection closed'); + process.exit(0); + }) + .catch((error) => { + logger.error('Error closing database connection:', error); + process.exit(1); + }); + }); + }; + + process.on('SIGTERM', gracefulShutdown); + process.on('SIGINT', gracefulShutdown); + + } catch (error) { + logger.error('Failed to start server:', error); + process.exit(1); + } +} + +startServer(); \ No newline at end of file diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..0f9ed79 --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,50 @@ +import { Request, Response, NextFunction } from 'express'; +import { verifyToken } from '@/utils/auth'; +import prisma from '@/utils/database'; +import logger from '@/utils/logger'; +import { AuthenticatedRequest } from '@/types'; + +export const authenticate = async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +) => { + try { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ + success: false, + message: 'Access token is required' + }); + } + + const token = authHeader.substring(7); + const decoded = verifyToken(token); + + const user = await prisma.user.findUnique({ + where: { id: decoded.id }, + select: { id: true, email: true, isActive: true } + }); + + if (!user || !user.isActive) { + return res.status(401).json({ + success: false, + message: 'Invalid or inactive user' + }); + } + + req.user = { + id: user.id, + email: user.email + }; + + next(); + } catch (error) { + logger.error('Authentication error:', error); + return res.status(401).json({ + success: false, + message: 'Invalid or expired token' + }); + } +}; \ No newline at end of file diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts new file mode 100644 index 0000000..722fa6a --- /dev/null +++ b/src/middleware/errorHandler.ts @@ -0,0 +1,26 @@ +import { Request, Response, NextFunction } from 'express'; +import logger from '@/utils/logger'; + +export const errorHandler = ( + error: Error, + req: Request, + res: Response, + next: NextFunction +) => { + logger.error('Unhandled error:', { + error: error.message, + stack: error.stack, + url: req.url, + method: req.method, + }); + + if (res.headersSent) { + return next(error); + } + + return res.status(500).json({ + success: false, + message: 'Internal server error', + ...(process.env.NODE_ENV === 'development' && { error: error.message }), + }); +}; \ No newline at end of file diff --git a/src/routes/auth.ts b/src/routes/auth.ts new file mode 100644 index 0000000..6bee4fb --- /dev/null +++ b/src/routes/auth.ts @@ -0,0 +1,9 @@ +import { Router } from 'express'; +import { register, login } from '@/controllers/authController'; + +const router = Router(); + +router.post('/register', register); +router.post('/login', login); + +export default router; \ No newline at end of file diff --git a/src/routes/health.ts b/src/routes/health.ts new file mode 100644 index 0000000..9dc194a --- /dev/null +++ b/src/routes/health.ts @@ -0,0 +1,8 @@ +import { Router } from 'express'; +import { healthCheck } from '@/controllers/healthController'; + +const router = Router(); + +router.get('/', healthCheck); + +export default router; \ No newline at end of file diff --git a/src/routes/index.ts b/src/routes/index.ts new file mode 100644 index 0000000..05e2cd8 --- /dev/null +++ b/src/routes/index.ts @@ -0,0 +1,12 @@ +import { Router } from 'express'; +import authRoutes from './auth'; +import taskRoutes from './tasks'; +import healthRoutes from './health'; + +const router = Router(); + +router.use('/auth', authRoutes); +router.use('/tasks', taskRoutes); +router.use('/health', healthRoutes); + +export default router; \ No newline at end of file diff --git a/src/routes/tasks.ts b/src/routes/tasks.ts new file mode 100644 index 0000000..99d4d3d --- /dev/null +++ b/src/routes/tasks.ts @@ -0,0 +1,23 @@ +import { Router } from 'express'; +import { authenticate } from '@/middleware/auth'; +import { + createTask, + getTasks, + getTask, + updateTask, + deleteTask, + sendReminder +} from '@/controllers/taskController'; + +const router = Router(); + +router.use(authenticate); + +router.post('/', createTask); +router.get('/', getTasks); +router.get('/:id', getTask); +router.put('/:id', updateTask); +router.delete('/:id', deleteTask); +router.post('/:id/remind', sendReminder); + +export default router; \ No newline at end of file diff --git a/src/services/aiService.ts b/src/services/aiService.ts new file mode 100644 index 0000000..2015171 --- /dev/null +++ b/src/services/aiService.ts @@ -0,0 +1,162 @@ +import OpenAI from 'openai'; +import { config } from '@/utils/config'; +import logger from '@/utils/logger'; + +class AIService { + private client: OpenAI | null = null; + + constructor() { + if (config.openai.apiKey) { + this.client = new OpenAI({ + apiKey: config.openai.apiKey, + }); + } else { + logger.warn('OpenAI API key not configured. AI functionality disabled.'); + } + } + + async suggestOptimalTime(dueDate: Date, priority: string): Promise { + if (!this.client) { + return this.fallbackScheduling(dueDate, priority); + } + + try { + const currentTime = new Date(); + const timeUntilDue = dueDate.getTime() - currentTime.getTime(); + + if (timeUntilDue <= 0) { + return new Date(currentTime.getTime() + 60 * 60 * 1000); // 1 hour from now + } + + const daysDiff = Math.floor(timeUntilDue / (1000 * 60 * 60 * 24)); + const hoursDiff = Math.floor(timeUntilDue / (1000 * 60 * 60)); + + const prompt = ` + Based on task management best practices, suggest an optimal time to work on a task with the following details: + + Current time: ${currentTime.toISOString()} + Due date: ${dueDate.toISOString()} + Priority: ${priority} + Time available: ${daysDiff} days, ${hoursDiff % 24} hours + + Consider: + - Task priority (urgent tasks should be scheduled sooner) + - Optimal work hours (9 AM - 5 PM on weekdays) + - Buffer time before due date + - Work-life balance + + Respond with only the suggested datetime in ISO format: YYYY-MM-DDTHH:MM:SS.000Z + `; + + const response = await this.client.chat.completions.create({ + model: 'gpt-3.5-turbo', + messages: [ + { role: 'system', content: 'You are a task scheduling AI assistant.' }, + { role: 'user', content: prompt } + ], + max_tokens: 50, + temperature: 0.3, + }); + + const suggestedTimeStr = response.choices[0]?.message?.content?.trim(); + if (!suggestedTimeStr) { + return this.fallbackScheduling(dueDate, priority); + } + + const suggestedTime = new Date(suggestedTimeStr); + + if (isNaN(suggestedTime.getTime()) || suggestedTime < currentTime) { + return this.fallbackScheduling(dueDate, priority); + } + + if (suggestedTime > dueDate) { + return new Date(dueDate.getTime() - 2 * 60 * 60 * 1000); // 2 hours before due + } + + return suggestedTime; + + } catch (error) { + logger.error('AI scheduling failed:', error); + return this.fallbackScheduling(dueDate, priority); + } + } + + private fallbackScheduling(dueDate: Date, priority: string): Date { + const currentTime = new Date(); + const timeUntilDue = dueDate.getTime() - currentTime.getTime(); + + let hoursToAdd: number; + + switch (priority) { + case 'URGENT': + hoursToAdd = 1; + break; + case 'HIGH': + hoursToAdd = Math.min(6, timeUntilDue / (1000 * 60 * 60) / 4); + break; + case 'MEDIUM': + hoursToAdd = Math.min(24, timeUntilDue / (1000 * 60 * 60) / 2); + break; + case 'LOW': + default: + hoursToAdd = Math.min(48, (timeUntilDue / (1000 * 60 * 60)) * 0.75); + break; + } + + return new Date(currentTime.getTime() + hoursToAdd * 60 * 60 * 1000); + } + + async generateTaskInsights(tasks: any[]): Promise { + if (!this.client) { + return 'AI insights not available. Please configure OpenAI API key.'; + } + + try { + const taskSummary = tasks.map(task => + `- ${task.title} (Priority: ${task.priority}, Status: ${task.status})` + ).join('\n'); + + const prompt = ` + Analyze the following task list and provide productivity insights: + + ${taskSummary} + + Provide insights on: + 1. Task distribution by priority + 2. Potential bottlenecks + 3. Productivity recommendations + 4. Time management suggestions + + Keep the response concise and actionable (max 200 words). + `; + + const response = await this.client.chat.completions.create({ + model: 'gpt-3.5-turbo', + messages: [ + { role: 'system', content: 'You are a productivity analysis AI assistant.' }, + { role: 'user', content: prompt } + ], + max_tokens: 250, + temperature: 0.7, + }); + + return response.choices[0]?.message?.content?.trim() || 'Unable to generate insights at this time.'; + + } catch (error) { + logger.error('AI insights generation failed:', error); + return 'Unable to generate insights at this time.'; + } + } +} + +const aiService = new AIService(); + +export const suggestOptimalTime = async (dueDate: Date, priority: string): Promise => { + return aiService.suggestOptimalTime(dueDate, priority); +}; + +export const generateTaskInsights = async (tasks: any[]): Promise => { + return aiService.generateTaskInsights(tasks); +}; + +export default aiService; \ No newline at end of file diff --git a/src/services/schedulerService.ts b/src/services/schedulerService.ts new file mode 100644 index 0000000..b16b6f2 --- /dev/null +++ b/src/services/schedulerService.ts @@ -0,0 +1,190 @@ +import cron from 'node-cron'; +import prisma from '@/utils/database'; +import logger from '@/utils/logger'; +import { sendTaskReminder, sendTaskOverdue } from './whatsappService'; + +class SchedulerService { + private jobs: Map = new Map(); + + constructor() { + this.setupRecurringJobs(); + } + + private setupRecurringJobs() { + const dailyReminderJob = cron.schedule('0 9 * * *', async () => { + await this.sendDailyReminders(); + }, { + scheduled: false, + timezone: 'UTC' + }); + + const overdueCheckJob = cron.schedule('0 18 * * *', async () => { + await this.checkOverdueTasks(); + }, { + scheduled: false, + timezone: 'UTC' + }); + + this.jobs.set('daily_reminders', dailyReminderJob); + this.jobs.set('overdue_check', overdueCheckJob); + + dailyReminderJob.start(); + overdueCheckJob.start(); + + logger.info('Scheduler service initialized with recurring jobs'); + } + + private async sendDailyReminders() { + logger.info('Starting daily reminder job'); + + try { + const today = new Date(); + const tomorrow = new Date(today); + tomorrow.setDate(today.getDate() + 1); + + today.setHours(0, 0, 0, 0); + tomorrow.setHours(0, 0, 0, 0); + + const tasks = await prisma.task.findMany({ + where: { + status: { + in: ['PENDING', 'IN_PROGRESS'] + }, + scheduledAt: { + gte: today, + lt: tomorrow + }, + whatsappReminderSent: false, + owner: { + whatsappNumber: { + not: null + } + } + }, + include: { + owner: true + } + }); + + let sentCount = 0; + for (const task of tasks) { + if (task.owner.whatsappNumber && await sendTaskReminder(task.owner.whatsappNumber, task)) { + await prisma.task.update({ + where: { id: task.id }, + data: { whatsappReminderSent: true } + }); + sentCount++; + } + } + + logger.info(`Sent ${sentCount} daily reminders`); + } catch (error) { + logger.error('Error in daily reminder job:', error); + } + } + + private async checkOverdueTasks() { + logger.info('Starting overdue task check'); + + try { + const now = new Date(); + + const overdueTasks = await prisma.task.findMany({ + where: { + status: { + in: ['PENDING', 'IN_PROGRESS'] + }, + dueDate: { + lt: now + }, + owner: { + whatsappNumber: { + not: null + } + } + }, + include: { + owner: true + } + }); + + let sentCount = 0; + for (const task of overdueTasks) { + if (task.owner.whatsappNumber && await sendTaskOverdue(task.owner.whatsappNumber, task)) { + sentCount++; + } + } + + logger.info(`Sent ${sentCount} overdue notifications`); + } catch (error) { + logger.error('Error in overdue check job:', error); + } + } + + scheduleTaskReminder(taskId: number, remindAt: Date) { + const jobId = `task_reminder_${taskId}`; + + if (this.jobs.has(jobId)) { + this.jobs.get(jobId)?.stop(); + this.jobs.delete(jobId); + } + + const job = cron.schedule(this.dateToCron(remindAt), async () => { + await this.sendIndividualReminder(taskId); + this.jobs.delete(jobId); + }, { + scheduled: false, + timezone: 'UTC' + }); + + this.jobs.set(jobId, job); + job.start(); + + logger.info(`Scheduled reminder for task ${taskId} at ${remindAt.toISOString()}`); + } + + private async sendIndividualReminder(taskId: number) { + try { + const task = await prisma.task.findUnique({ + where: { id: taskId }, + include: { owner: true } + }); + + if (task && + task.status !== 'COMPLETED' && + task.status !== 'CANCELLED' && + task.owner.whatsappNumber) { + + const success = await sendTaskReminder(task.owner.whatsappNumber, task); + if (success) { + await prisma.task.update({ + where: { id: taskId }, + data: { whatsappReminderSent: true } + }); + } + } + } catch (error) { + logger.error(`Error sending individual reminder for task ${taskId}:`, error); + } + } + + private dateToCron(date: Date): string { + const minute = date.getUTCMinutes(); + const hour = date.getUTCHours(); + const day = date.getUTCDate(); + const month = date.getUTCMonth() + 1; + + return `${minute} ${hour} ${day} ${month} *`; + } + + shutdown() { + for (const [jobId, job] of this.jobs) { + job.stop(); + logger.info(`Stopped job: ${jobId}`); + } + this.jobs.clear(); + logger.info('Scheduler service shut down'); + } +} + +export default new SchedulerService(); \ No newline at end of file diff --git a/src/services/whatsappService.ts b/src/services/whatsappService.ts new file mode 100644 index 0000000..58615ee --- /dev/null +++ b/src/services/whatsappService.ts @@ -0,0 +1,61 @@ +import twilio from 'twilio'; +import { config } from '@/utils/config'; +import logger from '@/utils/logger'; + +class WhatsAppService { + private client: twilio.Twilio | null = null; + + constructor() { + if (config.twilio.accountSid && config.twilio.authToken) { + this.client = twilio(config.twilio.accountSid, config.twilio.authToken); + } else { + logger.warn('Twilio credentials not configured. WhatsApp functionality disabled.'); + } + } + + async sendMessage(toNumber: string, message: string): Promise { + if (!this.client) { + logger.error('WhatsApp service not configured'); + return false; + } + + try { + const formattedNumber = toNumber.startsWith('whatsapp:') ? toNumber : `whatsapp:${toNumber}`; + + const messageResponse = await this.client.messages.create({ + body: message, + from: config.twilio.whatsappFrom, + to: formattedNumber, + }); + + logger.info(`WhatsApp message sent successfully. SID: ${messageResponse.sid}`); + return true; + } catch (error) { + logger.error('Failed to send WhatsApp message:', error); + return false; + } + } +} + +const whatsappService = new WhatsAppService(); + +export const sendTaskReminder = async (phoneNumber: string, task: any): Promise => { + const dueInfo = task.dueDate ? ` (Due: ${new Date(task.dueDate).toLocaleDateString()})` : ''; + const message = `🔔 Task Reminder: ${task.title}\n\n${task.description || 'No description provided'}${dueInfo}\n\nPriority: ${task.priority}`; + + return whatsappService.sendMessage(phoneNumber, message); +}; + +export const sendTaskCompletion = async (phoneNumber: string, task: any): Promise => { + const message = `✅ Task Completed: ${task.title}\n\nCongratulations! You've successfully completed this task.`; + + return whatsappService.sendMessage(phoneNumber, message); +}; + +export const sendTaskOverdue = async (phoneNumber: string, task: any): Promise => { + const message = `⚠️ Task Overdue: ${task.title}\n\nThis task is overdue. Please complete it as soon as possible.\n\nPriority: ${task.priority}`; + + return whatsappService.sendMessage(phoneNumber, message); +}; + +export default whatsappService; \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..72666ef --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,42 @@ +export interface CreateUserData { + email: string; + password: string; + fullName?: string; + phoneNumber?: string; + whatsappNumber?: string; +} + +export interface LoginData { + email: string; + password: string; +} + +export interface CreateTaskData { + title: string; + description?: string; + priority?: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT'; + dueDate?: Date; + scheduledAt?: Date; +} + +export interface UpdateTaskData { + title?: string; + description?: string; + priority?: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT'; + status?: 'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED'; + dueDate?: Date; + scheduledAt?: Date; +} + +export interface AuthenticatedRequest extends Request { + user?: { + id: number; + email: string; + }; +} + +export interface ApiResponse { + success: boolean; + message: string; + data?: T; +} \ No newline at end of file diff --git a/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 index 0000000..cbd401a --- /dev/null +++ b/src/utils/auth.ts @@ -0,0 +1,25 @@ +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import { config } from './config'; + +export const hashPassword = async (password: string): Promise => { + const salt = await bcrypt.genSalt(12); + return bcrypt.hash(password, salt); +}; + +export const comparePassword = async ( + password: string, + hashedPassword: string +): Promise => { + return bcrypt.compare(password, hashedPassword); +}; + +export const generateToken = (payload: { id: number; email: string }): string => { + return jwt.sign(payload, config.jwt.secret, { + expiresIn: config.jwt.expiresIn, + }); +}; + +export const verifyToken = (token: string): { id: number; email: string } => { + return jwt.verify(token, config.jwt.secret) as { id: number; email: string }; +}; \ No newline at end of file diff --git a/src/utils/config.ts b/src/utils/config.ts new file mode 100644 index 0000000..3314f9b --- /dev/null +++ b/src/utils/config.ts @@ -0,0 +1,27 @@ +import dotenv from 'dotenv'; + +dotenv.config(); + +export const config = { + port: parseInt(process.env.PORT || '3000'), + nodeEnv: process.env.NODE_ENV || 'development', + + jwt: { + secret: process.env.JWT_SECRET || 'your-super-secret-jwt-key-here', + expiresIn: process.env.JWT_EXPIRES_IN || '24h', + }, + + twilio: { + accountSid: process.env.TWILIO_ACCOUNT_SID || '', + authToken: process.env.TWILIO_AUTH_TOKEN || '', + whatsappFrom: process.env.TWILIO_WHATSAPP_FROM || '', + }, + + openai: { + apiKey: process.env.OPENAI_API_KEY || '', + }, + + database: { + url: process.env.DATABASE_URL || 'file:./storage/db/database.db', + }, +}; \ No newline at end of file diff --git a/src/utils/database.ts b/src/utils/database.ts new file mode 100644 index 0000000..ad188bf --- /dev/null +++ b/src/utils/database.ts @@ -0,0 +1,45 @@ +import { PrismaClient } from '@prisma/client'; +import logger from './logger'; + +const prisma = new PrismaClient({ + log: [ + { + emit: 'event', + level: 'query', + }, + { + emit: 'event', + level: 'error', + }, + { + emit: 'event', + level: 'info', + }, + { + emit: 'event', + level: 'warn', + }, + ], +}); + +prisma.$on('error', (e) => { + logger.error('Database error:', e); +}); + +prisma.$on('warn', (e) => { + logger.warn('Database warning:', e); +}); + +prisma.$on('info', (e) => { + logger.info('Database info:', e); +}); + +prisma.$on('query', (e) => { + logger.debug('Database query:', { + query: e.query, + params: e.params, + duration: e.duration, + }); +}); + +export default prisma; \ No newline at end of file diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..b8a9596 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,27 @@ +import winston from 'winston'; +import { config } from './config'; + +const logger = winston.createLogger({ + level: config.nodeEnv === 'production' ? 'info' : 'debug', + format: winston.format.combine( + winston.format.timestamp(), + winston.format.errors({ stack: true }), + winston.format.json() + ), + defaultMeta: { service: 'whatsapp-ai-task-scheduler' }, + transports: [ + new winston.transports.File({ filename: 'logs/error.log', level: 'error' }), + new winston.transports.File({ filename: 'logs/combined.log' }), + ], +}); + +if (config.nodeEnv !== 'production') { + logger.add(new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + winston.format.simple() + ) + })); +} + +export default logger; \ No newline at end of file diff --git a/src/utils/validation.ts b/src/utils/validation.ts new file mode 100644 index 0000000..8646dfa --- /dev/null +++ b/src/utils/validation.ts @@ -0,0 +1,39 @@ +import Joi from 'joi'; + +export const userRegistrationSchema = Joi.object({ + email: Joi.string().email().required(), + password: Joi.string().min(6).required(), + fullName: Joi.string().optional(), + phoneNumber: Joi.string().optional(), + whatsappNumber: Joi.string().optional(), +}); + +export const userLoginSchema = Joi.object({ + email: Joi.string().email().required(), + password: Joi.string().required(), +}); + +export const taskCreationSchema = Joi.object({ + title: Joi.string().required(), + description: Joi.string().optional(), + priority: Joi.string().valid('LOW', 'MEDIUM', 'HIGH', 'URGENT').optional(), + dueDate: Joi.date().optional(), + scheduledAt: Joi.date().optional(), +}); + +export const taskUpdateSchema = Joi.object({ + title: Joi.string().optional(), + description: Joi.string().optional(), + priority: Joi.string().valid('LOW', 'MEDIUM', 'HIGH', 'URGENT').optional(), + status: Joi.string().valid('PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED').optional(), + dueDate: Joi.date().optional(), + scheduledAt: Joi.date().optional(), +}); + +export const validate = (schema: Joi.Schema, data: any) => { + const { error, value } = schema.validate(data); + if (error) { + throw new Error(error.details[0].message); + } + return value; +}; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..59ab24f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,39 @@ +{ + "compilerOptions": { + "target": "es2020", + "module": "commonjs", + "lib": ["es2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "removeComments": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "noImplicitThis": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "moduleResolution": "node", + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file