Complete rebuild: Node.js/Express/TypeScript WhatsApp AI task scheduling service

- 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 <noreply@anthropic.com>
This commit is contained in:
Automated Action 2025-06-27 16:50:54 +00:00
parent 7446415eec
commit a8270cc5cb
49 changed files with 1730 additions and 1005 deletions

25
.eslintrc.js Normal file
View File

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

206
README.md
View File

@ -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.

View File

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

View File

@ -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()

View File

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

View File

@ -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 ###

View File

View File

View File

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

View File

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

View File

@ -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"
)

View File

View File

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

View File

@ -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()

View File

@ -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"},
)

View File

View File

@ -1,3 +0,0 @@
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()

View File

@ -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()

View File

View File

@ -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")

View File

@ -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")

View File

@ -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)

View File

@ -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()

View File

@ -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)

48
package.json Normal file
View File

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

59
prisma/schema.prisma Normal file
View File

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

View File

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

100
src/app.ts Normal file
View File

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

View File

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

View File

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

View File

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

43
src/index.ts Normal file
View File

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

50
src/middleware/auth.ts Normal file
View File

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

View File

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

9
src/routes/auth.ts Normal file
View File

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

8
src/routes/health.ts Normal file
View File

@ -0,0 +1,8 @@
import { Router } from 'express';
import { healthCheck } from '@/controllers/healthController';
const router = Router();
router.get('/', healthCheck);
export default router;

12
src/routes/index.ts Normal file
View File

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

23
src/routes/tasks.ts Normal file
View File

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

162
src/services/aiService.ts Normal file
View File

@ -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<Date | null> {
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<string> {
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<Date | null> => {
return aiService.suggestOptimalTime(dueDate, priority);
};
export const generateTaskInsights = async (tasks: any[]): Promise<string> => {
return aiService.generateTaskInsights(tasks);
};
export default aiService;

View File

@ -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<string, cron.ScheduledTask> = 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();

View File

@ -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<boolean> {
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<boolean> => {
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<boolean> => {
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<boolean> => {
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;

42
src/types/index.ts Normal file
View File

@ -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<T = any> {
success: boolean;
message: string;
data?: T;
}

25
src/utils/auth.ts Normal file
View File

@ -0,0 +1,25 @@
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { config } from './config';
export const hashPassword = async (password: string): Promise<string> => {
const salt = await bcrypt.genSalt(12);
return bcrypt.hash(password, salt);
};
export const comparePassword = async (
password: string,
hashedPassword: string
): Promise<boolean> => {
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 };
};

27
src/utils/config.ts Normal file
View File

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

45
src/utils/database.ts Normal file
View File

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

27
src/utils/logger.ts Normal file
View File

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

39
src/utils/validation.ts Normal file
View File

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

39
tsconfig.json Normal file
View File

@ -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"
]
}