Express migration (#80)

* Initial migration

* Cleanup and create migration scripts

* Introduce test suite

* Fix test issues

* Correct CORS issue and update paths

* Update README
This commit is contained in:
Chris 2025-06-16 21:50:44 +03:00 committed by GitHub
parent 7a5fe2b11c
commit 3c1209a5a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
167 changed files with 24985 additions and 9335 deletions

View file

@ -1,8 +0,0 @@
{
"presets": [
"@babel/preset-env",
"@babel/preset-react",
"@babel/preset-typescript"
],
"plugins": ["react-refresh/babel"]
}

View file

@ -1,6 +1,110 @@
db/*.sqlite3
*.sqlite3
*.sqlite3-shm
*.sqlite3-wal
certs/
.DS_Store
# ============================================================================
# Optimized .dockerignore for multi-stage build
# ============================================================================
# Node modules (installed in container)
**/node_modules/
**/.npm
# Development files
*.log
*.tmp
*.swp
*.swo
*~
**/.env.local
**/.env.development
**/.env.test
# Git and version control
.git/
.gitignore
.gitattributes
.github/
# Development databases
**/db/*.sqlite3*
**/*.sqlite3*
**/db/*.db
# IDE and editor files
.vscode/
.idea/
*.sublime-*
**/.editorconfig
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Test files
test/
spec/
**/*.test.js
**/*.spec.js
**/jest.config.*
**/vitest.config.*
# Documentation and assets
README.md
LICENSE
docs/
screenshots/
**/*.md
# Build artifacts (built in container)
dist/
build/
.next/
.nuxt/
coverage/
.nyc_output/
# Temporary and cache files
tmp/
temp/
.cache/
**/.cache
**/npm-debug.log*
**/yarn-debug.log*
**/yarn-error.log*
# Development only
frontend.log
server.log
cookies.txt
**/.eslintcache
# Certificates (generated in container)
**/certs/
# Docker related (avoid recursion)
Dockerfile*
docker-compose*.yml
.dockerignore
# Development scripts not needed in production
backend-express/scripts/
backend-express/migrations/
backend-express/seeders/
# Additional exclusions for minimal image
backend-express/test/
backend-express/tests/
**/node_modules/.cache/
**/.git/
**/.gitignore
**/yarn.lock
**/*.log
**/*.tmp
**/coverage/
# Backup files
**/*.bak
**/*.backup
**/*.orig

2
.gitignore vendored
View file

@ -10,3 +10,5 @@ node_modules
public/js/bundle.js
.aider*
backend/coverage/

View file

@ -1,8 +0,0 @@
Metrics/ClassLength:
Max: 500
Metrics/BlockLength:
Max: 50
Metrics/MethodLength:
Max: 50
Style/Documentation:
Enabled: false

View file

@ -1,108 +1,156 @@
# Use a base image that supports both Node.js and Ruby
FROM ruby:3.2.2-slim
# ============================================================================
# Ultra-optimized multi-stage build for minimal rootless Docker image
# ============================================================================
# Install Node.js and necessary packages
RUN apt-get update -qq && \
apt-get install -y --no-install-recommends \
build-essential \
libsqlite3-dev \
openssl \
libffi-dev \
libpq-dev \
curl \
gnupg2 \
ca-certificates && \
# Install Node.js 20
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
apt-get install -y nodejs && \
rm -rf /var/lib/apt/lists/* && \
apt-get clean
# Stage 1: Frontend Build Environment (optimized)
FROM node:20-alpine AS frontend-builder
WORKDIR /usr/src/app
WORKDIR /app
# Install Ruby dependencies first
COPY Gemfile* ./
RUN bundle config set --local deployment 'true' && \
bundle config set --local without 'development test' && \
bundle install --jobs 4 --retry 3
# Install Node.js dependencies
# Copy frontend package files
COPY package*.json ./
COPY webpack.config.js ./
COPY babel.config.js ./
COPY tsconfig.json ./
COPY postcss.config.js ./
COPY tailwind.config.js ./
RUN npm ci
COPY webpack.config.js babel.config.js tsconfig.json postcss.config.js tailwind.config.js ./
# Remove any existing development databases
RUN rm -f db/development*
# Install frontend dependencies (including dev deps for build)
RUN npm install --ignore-scripts --no-audit --no-fund && \
npm cache clean --force && \
rm -rf ~/.npm
# Copy application files
COPY app/ app/
COPY config/ config/
COPY config.ru ./
COPY Rakefile ./
COPY app.rb ./
COPY db/migrate/ db/migrate/
COPY db/schema.rb db/schema.rb
# Copy frontend source code
COPY frontend/ frontend/
COPY public/ public/
COPY src/ src/
# Create non-root user for security
RUN useradd -m -U app && \
chown -R app:app /usr/src/app
# Build frontend assets with optimizations
RUN NODE_ENV=production npm run build && \
# Remove source maps and dev artifacts
find dist -name "*.map" -delete && \
find dist -name "*.dev.*" -delete && \
# Compress built assets
find dist -type f \( -name "*.js" -o -name "*.css" -o -name "*.html" \) -exec gzip -9 -k {} \;
# Stage 2: Backend Dependencies (ultra-minimal)
FROM node:20-alpine AS backend-deps
WORKDIR /app
# Install build dependencies temporarily for native modules
RUN apk add --no-cache --virtual .build-deps \
python3 \
make \
g++ \
sqlite-dev
# Install only runtime dependencies for backend
COPY backend/package*.json ./
RUN npm install --production --no-audit --no-fund && \
npm cache clean --force && \
rm -rf ~/.npm /tmp/* && \
# Remove build dependencies after install
apk del .build-deps && \
# Remove unnecessary files from node_modules
find node_modules -name "*.md" -delete && \
find node_modules -name "*.txt" -delete && \
find node_modules -name "LICENSE*" -delete && \
find node_modules -name "CHANGELOG*" -delete && \
find node_modules -name "README*" -delete && \
find node_modules -name ".github" -type d -exec rm -rf {} + 2>/dev/null || true && \
find node_modules -name "test" -type d -exec rm -rf {} + 2>/dev/null || true && \
find node_modules -name "tests" -type d -exec rm -rf {} + 2>/dev/null || true && \
find node_modules -name "docs" -type d -exec rm -rf {} + 2>/dev/null || true && \
find node_modules -name "examples" -type d -exec rm -rf {} + 2>/dev/null || true
# Stage 3: Test Stage (run tests before production)
FROM node:20-alpine AS test
WORKDIR /app
# Install build dependencies for testing
RUN apk add --no-cache --virtual .test-deps \
python3 \
make \
g++ \
sqlite-dev
# Copy backend package files and install all dependencies (including dev)
COPY backend/package*.json ./backend/
RUN cd backend && npm install --no-audit --no-fund
# Copy backend source code
COPY backend/ ./backend/
# Run tests
RUN cd backend && npm test
# Stage 4: Final Production Image (minimal base)
FROM node:20-alpine AS production
# Create non-root user first (before installing packages)
RUN addgroup -g 1001 -S app && \
adduser -S app -u 1001 -G app
# Install minimal runtime dependencies with size optimization
RUN apk add --no-cache --virtual .runtime-deps \
sqlite \
openssl \
curl \
dumb-init && \
# Clean up package cache immediately
rm -rf /var/cache/apk/* /tmp/* && \
# Remove unnecessary files
rm -rf /usr/share/man /usr/share/doc /usr/share/info
# Set working directory
WORKDIR /app
# Copy backend dependencies from deps stage (optimized)
COPY --from=backend-deps --chown=app:app /app/node_modules ./backend/node_modules
# Copy backend application code (exclude unnecessary files)
COPY --chown=app:app backend/app.js ./backend/
COPY --chown=app:app backend/package*.json ./backend/
COPY --chown=app:app backend/config/ ./backend/config/
COPY --chown=app:app backend/models/ ./backend/models/
COPY --chown=app:app backend/routes/ ./backend/routes/
COPY --chown=app:app backend/middleware/ ./backend/middleware/
COPY --chown=app:app backend/services/ ./backend/services/
# Copy minimal built frontend assets from builder stage
COPY --from=frontend-builder --chown=app:app /app/dist ./backend/dist
COPY --from=frontend-builder --chown=app:app /app/public/locales ./backend/dist/locales
# Create ultra-minimal startup script (before switching to non-root user)
RUN printf '#!/bin/sh\nset -e\ncd backend\nmkdir -p db certs\nDB_FILE="db/production.sqlite3"\n[ "$NODE_ENV" = "development" ] && DB_FILE="db/development.sqlite3"\nif [ ! -f "$DB_FILE" ]; then\n node -e "require(\\"./models\\").sequelize.sync({force:true}).then(()=>{console.log(\\"✅ DB ready\\");process.exit(0)}).catch(e=>{console.error(\\"❌\\",e.message);process.exit(1)})"\nelse\n node -e "require(\\"./models\\").sequelize.authenticate().then(()=>{console.log(\\"✅ DB OK\\");process.exit(0)}).catch(e=>{console.error(\\"❌\\",e.message);process.exit(1)})"\nfi\nif [ -n "$TUDUDI_USER_EMAIL" ]&&[ -n "$TUDUDI_USER_PASSWORD" ]; then\n node -e "const{User}=require(\\"./models\\");const bcrypt=require(\\"bcrypt\\");(async()=>{try{const[u,c]=await User.findOrCreate({where:{email:process.env.TUDUDI_USER_EMAIL},defaults:{email:process.env.TUDUDI_USER_EMAIL,password_digest:await bcrypt.hash(process.env.TUDUDI_USER_PASSWORD,10)}});console.log(c?\\"✅ User created\\":\\" User exists\\");process.exit(0)}catch(e){console.error(\\"❌\\",e.message);process.exit(1)}})();"||exit 1\nfi\n[ "$TUDUDI_INTERNAL_SSL_ENABLED" = "true" ]&&[ ! -f "certs/server.crt" ]&&openssl req -x509 -newkey rsa:2048 -keyout certs/server.key -out certs/server.crt -days 365 -nodes -subj "/CN=localhost" 2>/dev/null||true\nexec node app.js\n' > start.sh && chmod +x start.sh
# Create necessary directories and final cleanup
RUN mkdir -p ./backend/db ./backend/certs && \
chown -R app:app ./backend/db ./backend/certs ./start.sh && \
# Final size optimization - remove Node.js build tools and cache
apk del --no-cache .runtime-deps sqlite openssl curl && \
apk add --no-cache sqlite-libs openssl curl dumb-init && \
rm -rf /usr/local/lib/node_modules/npm/docs /usr/local/lib/node_modules/npm/man && \
rm -rf /root/.npm /tmp/* /var/tmp/* /var/cache/apk/*
# Switch to non-root user
USER app
# Expose ports for both frontend (8080) and backend (9292)
EXPOSE 8080 9292
# Expose port
EXPOSE 3002
# Set production environment variables
ENV RACK_ENV=production \
NODE_ENV=production \
# Set optimized production environment variables
ENV NODE_ENV=production \
PORT=3002 \
TUDUDI_INTERNAL_SSL_ENABLED=false \
TUDUDI_ALLOWED_ORIGINS="http://localhost:8080,http://localhost:9292,http://127.0.0.1:8080,http://127.0.0.1:9292,http://0.0.0.0:8080,http://0.0.0.0:9292" \
LANG=C.UTF-8 \
TZ=UTC
TUDUDI_ALLOWED_ORIGINS="http://localhost:8080,http://localhost:3002,http://127.0.0.1:8080,http://127.0.0.1:3002" \
TUDUDI_SESSION_SECRET="" \
TUDUDI_USER_EMAIL="" \
TUDUDI_USER_PASSWORD="" \
DISABLE_TELEGRAM=false \
DISABLE_SCHEDULER=false
# Generate SSL certificates if needed
RUN mkdir -p certs && \
if [ "$TUDUDI_INTERNAL_SSL_ENABLED" = "true" ]; then \
openssl req -x509 -newkey rsa:4096 \
-keyout certs/server.key -out certs/server.crt \
-days 365 -nodes \
-subj '/CN=localhost' \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"; \
fi
# Minimal healthcheck
HEALTHCHECK --interval=60s --timeout=3s --start-period=10s --retries=2 \
CMD curl -sf http://localhost:3002/api/health || exit 1
# Add healthcheck for backend
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:9292/api/health || exit 1
# Build production frontend assets
RUN npm run build
# Copy translation files to dist folder for production serving
RUN cp -r public/locales dist/
# Create startup script
RUN echo '#!/bin/bash\n\
set -e\n\
\n\
# Run database migrations\n\
bundle exec rake db:migrate\n\
\n\
# Create user if it does not exist\n\
if [ -n "$TUDUDI_USER_EMAIL" ] && [ -n "$TUDUDI_USER_PASSWORD" ]; then\n\
echo "Creating user if it does not exist..."\n\
echo "user = User.find_by(email: \"$TUDUDI_USER_EMAIL\") || User.create(email: \"$TUDUDI_USER_EMAIL\", password: \"$TUDUDI_USER_PASSWORD\"); puts \"User: #{user.email}\"" | bundle exec rake console\n\
fi\n\
\n\
# Start backend with both API and static file serving\n\
bundle exec puma -C app/config/puma.rb\n\
' > start.sh && chmod +x start.sh
# Run both services
CMD ["./start.sh"]
# Use dumb-init for proper signal handling
ENTRYPOINT ["dumb-init", "--"]
CMD ["/app/start.sh"]

28
Gemfile
View file

@ -1,28 +0,0 @@
source 'https://rubygems.org'
gem 'puma'
gem 'rack-protection', '~> 3.1.0'
gem 'rake', '~> 13.0'
gem 'rufus-scheduler', '~> 3.8.2'
# DB
gem 'sinatra-activerecord'
gem 'sinatra-cross_origin'
gem 'sinatra-namespace'
gem 'sqlite3'
# Authentication
gem 'bcrypt', '~> 3.1'
# Other
gem 'byebug', '~> 11.1'
gem 'nokogiri', '~> 1.15'
gem 'rerun'
# Development
gem 'faker'
gem 'rubocop'
# Testing
gem 'minitest', group: :test
gem 'rack-test', group: :test

View file

@ -1,146 +0,0 @@
GEM
remote: https://rubygems.org/
specs:
activemodel (7.1.1)
activesupport (= 7.1.1)
activerecord (7.1.1)
activemodel (= 7.1.1)
activesupport (= 7.1.1)
timeout (>= 0.4.0)
activesupport (7.1.1)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
minitest (>= 5.1)
mutex_m
tzinfo (~> 2.0)
ast (2.4.2)
base64 (0.2.0)
bcrypt (3.1.19)
bigdecimal (3.1.4)
byebug (11.1.3)
concurrent-ruby (1.2.2)
connection_pool (2.4.1)
drb (2.2.0)
ruby2_keywords
et-orbi (1.2.11)
tzinfo
faker (3.2.2)
i18n (>= 1.8.11, < 2)
ffi (1.16.3)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
i18n (1.14.1)
concurrent-ruby (~> 1.0)
json (2.6.3)
language_server-protocol (3.17.0.3)
listen (3.8.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
minitest (5.20.0)
multi_json (1.15.0)
mustermann (3.0.0)
ruby2_keywords (~> 0.0.1)
mutex_m (0.2.0)
nio4r (2.5.9)
nokogiri (1.18.8-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.8-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.8-x86_64-linux-gnu)
racc (~> 1.4)
parallel (1.23.0)
parser (3.2.2.4)
ast (~> 2.4.1)
racc
puma (6.4.0)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.7.3)
rack (2.2.8)
rack-protection (3.1.0)
rack (~> 2.2, >= 2.2.4)
rack-test (2.1.0)
rack (>= 1.3)
rainbow (3.1.1)
rake (13.2.1)
rb-fsevent (0.11.2)
rb-inotify (0.10.1)
ffi (~> 1.0)
regexp_parser (2.8.2)
rerun (0.14.0)
listen (~> 3.0)
rexml (3.2.6)
rubocop (1.57.2)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.2.2.4)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.28.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.30.0)
parser (>= 3.2.1.0)
ruby-progressbar (1.13.0)
ruby2_keywords (0.0.5)
rufus-scheduler (3.8.2)
fugit (~> 1.1, >= 1.1.6)
sinatra (3.1.0)
mustermann (~> 3.0)
rack (~> 2.2, >= 2.2.4)
rack-protection (= 3.1.0)
tilt (~> 2.0)
sinatra-activerecord (2.0.27)
activerecord (>= 4.1)
sinatra (>= 1.0)
sinatra-contrib (3.1.0)
multi_json
mustermann (~> 3.0)
rack-protection (= 3.1.0)
sinatra (= 3.1.0)
tilt (~> 2.0)
sinatra-cross_origin (0.4.0)
sinatra-namespace (1.0)
sinatra-contrib
sqlite3 (1.6.8-aarch64-linux)
sqlite3 (1.6.8-arm64-darwin)
sqlite3 (1.6.8-x86_64-linux)
tilt (2.3.0)
timeout (0.4.1)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (2.5.0)
PLATFORMS
aarch64-linux
arm64-darwin-22
arm64-darwin-24
x86_64-linux
DEPENDENCIES
bcrypt (~> 3.1)
byebug (~> 11.1)
faker
minitest
nokogiri (~> 1.15)
puma
rack-protection (~> 3.1.0)
rack-test
rake (~> 13.0)
rerun
rubocop
rufus-scheduler (~> 3.8.2)
sinatra-activerecord
sinatra-cross_origin
sinatra-namespace
sqlite3
BUNDLED WITH
2.4.21

181
README.md
View file

@ -14,6 +14,7 @@
This app allows users to manage their tasks, projects, areas, notes, and tags in an organized way. Users can create tasks, projects, areas (to group projects), notes, and tags. Each task can be associated with a project, and both tasks and notes can be tagged for better organization. Projects can belong to areas and can also have multiple notes and tags. This structure helps users categorize and track their work efficiently, whether theyre managing individual tasks, larger projects, or keeping detailed notes.
## ✨ Features
- **Task Management**: Create, update, and delete tasks. Mark tasks as completed and view them by different filters (Today, Upcoming, Someday). Order them by Name, Due Date, Date Created, or Priority.
@ -35,9 +36,9 @@ Check out our [GitHub Project](https://github.com/users/chrisvel/projects/2) for
## 🛠️ Getting Started
**One simple command**, that's all it takes to run tududi with _docker_.
### Quick Start with Docker
### 🐋 Docker
**One simple command**, that's all it takes to run tududi with Docker.
First pull the latest image:
@ -58,7 +59,7 @@ The following environment variables are used to configure tududi:
- `TUDUDI_INTERNAL_SSL_ENABLED` - Set to 'true' if using HTTPS internally (default: false)
- `TUDUDI_ALLOWED_ORIGINS` - Controls CORS access for different deployment scenarios:
- Not set: Only allows localhost origins
- Specific domains: `https://tududi.com,http://localhost:9292`
- Specific domains: `https://tududi.com,http://localhost:3002`
- Allow all (development only): Set to empty string `""`
#### Common Configuration Examples:
@ -89,23 +90,31 @@ docker run \
-e TUDUDI_USER_PASSWORD=mysecurepassword \
-e TUDUDI_SESSION_SECRET=$(openssl rand -hex 64) \
-e TUDUDI_INTERNAL_SSL_ENABLED=false \
-e TUDUDI_ALLOWED_ORIGINS=https://tududi,http://tududi:9292 \
-v ~/tududi_db:/usr/src/app/tududi_db \
-p 9292:9292 \
-e TUDUDI_ALLOWED_ORIGINS=https://tududi,http://tududi:3002 \
-v ~/tududi_db:/usr/src/app/backend/db \
-p 3002:3002 \
-d chrisvel/tududi:latest
```
Navigate to [https://localhost:9292](https://localhost:9292) and login with your credentials.
Navigate to [http://localhost:3002](http://localhost:3002) and login with your credentials.
### 🔑 Authentication
The application uses session-based authentication with secure cookies. For development:
- Frontend runs on port 8080 with webpack dev server
- Backend runs on port 3001 and handles authentication
- CORS is configured to allow cross-origin requests during development
- In production (Docker), both frontend and backend run on the same port (3002)
## 🚧 Development
### Prerequisites
Before you begin, ensure you have the following installed:
- Ruby (version 3.2.2 or higher)
- Sinatra
- Node.js (version 20 or higher)
- Express.js
- SQLite3
- Puma
- npm
- ReactJS
### 🏗 Installation
@ -120,59 +129,165 @@ To install `tududi`, follow these steps:
```bash
cd tududi
```
3. Install the required gems:
3. Install the required dependencies:
```bash
bundle install
# Install frontend dependencies
npm install
# Install backend dependencies
cd backend
npm install
cd ..
```
### 🔒 SSL Setup
### 🔒 SSL Setup (Optional)
For HTTPS support, create SSL certificates:
1. Create and enter the directory:
```bash
mkdir certs
cd certs
mkdir backend/certs
cd backend/certs
```
2. Create the key and cert:
```bash
openssl genrsa -out server.key 2048
openssl req -new -x509 -key server.key -out server.crt -days 365
cd ../..
```
### 📂 Database Setup
Execute the migrations:
The database will be automatically initialized when you start the Express backend. For manual database operations:
```bash
rake db:migrate
```bash
cd backend
# Initialize database (creates tables, drops existing data)
npm run db:init
# Sync database (creates tables if they don't exist)
npm run db:sync
# Migrate database (alters existing tables to match models)
npm run db:migrate
# Reset database (drops and recreates all tables)
npm run db:reset
# Check database status and connection
npm run db:status
cd ..
```
### 👤 Create Your User
### 🔄 Database Migrations
1. Open the console:
```bash
rake console
```
2. Add the user:
```ruby
User.create(email: "myemail@somewhere.com", password: "awes0meHax0Rp4ssword")
```
For schema changes, use Sequelize migrations (similar to Rails/Ruby migrations):
```bash
cd backend
# Create a new migration
npm run migration:create add-description-to-tasks
# Run pending migrations
npm run migration:run
# Check migration status
npm run migration:status
# Rollback last migration
npm run migration:undo
# Rollback all migrations
npm run migration:undo:all
cd ..
```
#### Creating a New Migration Example:
```bash
# 1. Create the migration file
npm run migration:create add-priority-to-projects
# 2. Edit the generated file in migrations/ folder:
# - Add your schema changes in the 'up' function
# - Add rollback logic in the 'down' function
# 3. Run the migration
npm run migration:run
```
### 👤 User Setup
#### For Development
Set environment variables to automatically create the initial user:
```bash
export TUDUDI_USER_EMAIL=dev@example.com
export TUDUDI_USER_PASSWORD=password123
export TUDUDI_SESSION_SECRET=$(openssl rand -hex 64)
```
Or create a user manually:
```bash
cd backend
npm run user:create dev@example.com password123
cd ..
```
#### Default Development Credentials
If no environment variables are set, you can use the default development credentials:
- Email: `dev@example.com`
- Password: `password123`
### 🚀 Usage
To start the application, run:
To start the application for development:
```bash
puma -C app/config/puma.rb
```
1. **Start the Express backend** (in one terminal):
```bash
cd backend
npm run dev # Development mode with auto-reload
# Or: npm start # Production mode
```
The backend will run on `http://localhost:3001`
2. **Start the frontend development server** (in another terminal):
```bash
npm run dev
```
The frontend will run on `http://localhost:8080`
3. **Access the application**: Open your browser to `http://localhost:8080`
### Port Configuration
- **Development Frontend**: `http://localhost:8080` (webpack dev server)
- **Development Backend**: `http://localhost:3001` (Express API server)
- **Docker/Production**: `http://localhost:3002` (combined frontend + backend)
The webpack dev server automatically proxies API calls and locales to the backend server.
### 🔍 Testing
To run tests, execute:
To run tests:
```bash
bundle exec ruby -Itest test/test_app.rb
# Backend tests
cd backend
npm test
# Frontend tests
cd ..
npm test
```
Note: Test suites are currently being migrated from the Ruby/Sinatra implementation.
## 🤝 Contributing
Contributions to `tududi` are welcome. To contribute:

View file

@ -1,12 +0,0 @@
require 'irb'
require 'sinatra/activerecord'
require 'sinatra/activerecord/rake'
require './app'
desc 'Start an interactive console'
task :console do
ARGV.clear
IRB.start
end

208
app.rb
View file

@ -1,208 +0,0 @@
require 'sinatra'
require 'sinatra/activerecord'
require 'securerandom'
require 'byebug'
# Models
require './app/models/user'
require './app/models/area'
require './app/models/project'
require './app/models/task'
require './app/models/tag'
require './app/models/note'
require './app/models/inbox_item'
# Services
require './app/services/task_summary_service'
require './app/services/url_title_extractor_service'
require './config/initializers/scheduler'
require './config/initializers/telegram_initializer'
# Helpers
require './app/helpers/authentication_helper'
require './app/routes/authentication_routes'
require './app/routes/tasks_routes'
require './app/routes/projects_routes'
require './app/routes/areas_routes'
require './app/routes/notes_routes'
require './app/routes/tags_routes'
require './app/routes/users_routes'
require './app/routes/inbox_routes'
require './app/routes/telegram_poller'
require './app/routes/telegram_routes'
require './app/routes/url_routes'
require 'sinatra/cross_origin'
helpers AuthenticationHelper
use Rack::MethodOverride
set :database_file, './app/config/database.yml'
set :views, proc { File.join(root, 'app/views') }
set :public_folder, production? ? 'dist' : 'public'
configure do
enable :cross_origin
enable :sessions
# Session configuration
secure_flag = production? && ENV['TUDUDI_INTERNAL_SSL_ENABLED'] == 'true'
set :sessions, httponly: true,
secure: secure_flag,
expire_after: 2_592_000,
same_site: secure_flag ? :none : :lax
set :session_secret, ENV.fetch('TUDUDI_SESSION_SECRET') { SecureRandom.hex(64) }
# CORS configuration - use environment variable in production, fallback to localhost for development
allowed_origins = if ENV['TUDUDI_ALLOWED_ORIGINS']
ENV['TUDUDI_ALLOWED_ORIGINS'].split(',').map(&:strip)
else
['http://localhost:8080', 'http://localhost:9292', 'http://127.0.0.1:8080', 'http://127.0.0.1:9292']
end
set :allow_origin, allowed_origins
set :allow_methods, %i[get post patch delete options]
set :allow_credentials, true
set :max_age, '1728000'
set :expose_headers, ['Content-Type']
set :allow_headers, %w[Authorization Content-Type Accept X-Requested-With]
# Ensure ActiveRecord connection is established
ActiveRecord::Base.establish_connection
# Auto-create user if not exists
if ENV['TUDUDI_USER_EMAIL'] && ENV['TUDUDI_USER_PASSWORD'] && ActiveRecord::Base.connection.table_exists?('users')
user = User.find_or_initialize_by(email: ENV['TUDUDI_USER_EMAIL'])
if user.new_record?
user.password = ENV['TUDUDI_USER_PASSWORD']
user.save
end
end
# Initialize the Telegram polling after database is ready
initialize_telegram_polling
end
# Rack Protection configuration - completely disable for development
if development?
# Disable Rack::Protection completely in development to avoid CSRF issues
set :protection, false
else
# Use the same allowed origins for Rack::Protection in production
use Rack::Protection,
except: %i[remote_token session_hijacking remote_referrer],
origin_whitelist: settings.allow_origin
end
before do
# Handle CORS preflight requests
if request.request_method == 'OPTIONS'
response.headers['Access-Control-Allow-Methods'] = settings.allow_methods.map(&:to_s).join(', ')
response.headers['Access-Control-Allow-Headers'] = settings.allow_headers.join(', ')
response.headers['Access-Control-Max-Age'] = settings.max_age
halt 200
end
# Set CORS headers for all requests
if request.env['HTTP_ORIGIN'] && settings.allow_origin.include?(request.env['HTTP_ORIGIN'])
response.headers['Access-Control-Allow-Origin'] = request.env['HTTP_ORIGIN']
response.headers['Access-Control-Allow-Credentials'] = 'true'
response.headers['Access-Control-Expose-Headers'] = settings.expose_headers.join(', ')
end
# Authentication check - only for API routes
if request.path_info.start_with?('/api/') && !['/api/login', '/api/health'].include?(request.path_info)
require_login
end
end
helpers do
def current_path
request.path_info
end
def partial(page, options = {})
erb page, options.merge!(layout: false)
end
def nav_link_active?(path, query_params = {}, project_id = nil)
current_uri = request.path_info
current_query = request.query_string
current_params = Rack::Utils.parse_nested_query(current_query)
is_project_page = current_uri.include?('/project/') && path.include?('/project/')
if is_project_page
current_uri == path && (!project_id || current_uri.end_with?("/#{project_id}"))
elsif !query_params.empty?
current_uri == path && query_params.all? { |k, v| current_params[k] == v }
else
current_uri == path && current_params.empty?
end
end
def nav_link(path, query_params = {}, project_id = nil)
is_active = nav_link_active?(path, query_params, project_id)
classes = 'nav-link py-1 px-3'
classes += ' active-link' if is_active
classes
end
def update_query_params(key, value)
uri = URI(request.url)
params = Rack::Utils.parse_nested_query(uri.query)
params[key] = value
Rack::Utils.build_query(params)
end
def url_without_tag
uri = URI(request.url)
params = Rack::Utils.parse_nested_query(uri.query)
params.delete('tag') # Remove the 'tag' parameter
uri.query = Rack::Utils.build_query(params)
uri.to_s
end
end
get '/' do
if settings.production?
# In production, serve the built index.html directly
send_file File.join(settings.public_folder, 'index.html')
else
# In development, use ERB template
erb :index
end
end
# Catch-all route for SPA routing in production
get '*' do
# Skip API routes and static assets
unless request.path_info.start_with?('/api/') || request.path_info.match(/\.(js|css|png|jpg|jpeg|gif|ico|svg)$/)
if settings.production?
# In production, serve the built index.html for all SPA routes
send_file File.join(settings.public_folder, 'index.html')
else
# In development, use ERB template
erb :index
end
end
end
# Health check endpoint for Docker
get '/api/health' do
content_type :json
{ status: 'ok', timestamp: Time.now.iso8601 }.to_json
end
# Catch-all route for non-API routes to serve the SPA
get '*' do
pass if request.path_info.start_with?('/api/')
erb :index
end
not_found do
content_type :json
status 404
{ error: 'Not Found', message: 'The requested resource could not be found.' }.to_json
end

View file

@ -1,17 +0,0 @@
# config/database.yml
default: &default
adapter: sqlite3
pool: 15
timeout: 5000
development:
<<: *default
database: db/development.sqlite3
test:
<<: *default
database: db/test.sqlite3
production:
<<: *default
database: tududi_db/production.sqlite3

View file

@ -1,9 +0,0 @@
if ENV['TUDUDI_INTERNAL_SSL_ENABLED'] == 'true'
ssl_bind '0.0.0.0', '9292', {
key: 'certs/server.key',
cert: 'certs/server.crt',
verify_mode: 'none'
}
else
bind 'tcp://0.0.0.0:9292'
end

View file

@ -1,21 +0,0 @@
module AuthenticationHelper
def logged_in?
!!session[:user_id]
end
def current_user
@current_user ||= User.find(session[:user_id]) if session[:user_id]
end
def require_login
return if ['/api/login', '/api/logout', '/api/current_user'].include?(request.path_info)
return if logged_in?
if request.xhr? || request.path_info.start_with?('/api/')
halt 401, { error: 'You must be logged in' }.to_json
else
redirect '/login'
end
end
end

View file

@ -1,6 +0,0 @@
class Area < ActiveRecord::Base
belongs_to :user
has_many :projects, dependent: :destroy
validates :name, presence: true, uniqueness: { scope: :user_id }
end

View file

@ -1,22 +0,0 @@
class InboxItem < ActiveRecord::Base
belongs_to :user
enum status: { added: 'added', processed: 'processed', deleted: 'deleted' }
enum source: { tududi: 'tududi', telegram: 'telegram' }
scope :active, -> { where(status: 'added') }
scope :processed, -> { where(status: 'processed') }
scope :by_source, ->(source) { where(source: source) }
validates :content, presence: true
validates :status, inclusion: { in: statuses.keys }
validates :source, inclusion: { in: sources.keys }
def mark_as_processed!
update(status: 'processed')
end
def mark_as_deleted!
update(status: 'deleted')
end
end

View file

@ -1,8 +0,0 @@
class Note < ActiveRecord::Base
belongs_to :user
belongs_to :project, optional: true
has_and_belongs_to_many :tags
validates :content, presence: true
validates :title, presence: true, uniqueness: { scope: :user_id }
end

View file

@ -1,39 +0,0 @@
class Project < ActiveRecord::Base
belongs_to :user
belongs_to :area, optional: true
has_many :tasks, dependent: :destroy
has_many :notes, dependent: :destroy
has_and_belongs_to_many :tags
enum priority: { low: 0, medium: 1, high: 2 }
scope :with_incomplete_tasks, -> { joins(:tasks).where.not(tasks: { status: Task.statuses[:done] }).distinct }
scope :with_complete_tasks, -> { joins(:tasks).where(tasks: { status: Task.statuses[:done] }).distinct }
validates :name, presence: true, uniqueness: { scope: :user_id }
def task_status_counts
status_counts = tasks.group(:status).count
total = status_counts.values.sum
{
total: total,
in_progress: status_counts['in_progress'] || 0,
done: status_counts['done'] || 0,
not_started: status_counts['not_started'] || 0
}
end
def progress_percentage
counts = task_status_counts
return 0 if counts[:total].zero?
completed_tasks = counts[:total] - counts[:not_started]
(completed_tasks.to_f / counts[:total] * 100).round
end
def due_date_at
self[:due_date_at]&.strftime('%Y-%m-%d')
end
end

View file

@ -1,8 +0,0 @@
class Tag < ActiveRecord::Base
belongs_to :user
has_and_belongs_to_many :tasks
has_and_belongs_to_many :notes
has_and_belongs_to_many :projects
validates :name, presence: true, uniqueness: { scope: :user_id }
end

View file

@ -1,178 +0,0 @@
class Task < ActiveRecord::Base
belongs_to :user
belongs_to :project, optional: true
has_and_belongs_to_many :tags
enum priority: { low: 0, medium: 1, high: 2 }
enum status: { not_started: 0, in_progress: 1, done: 2, archived: 3, waiting: 4 }
scope :complete, -> { where(status: statuses[:done]) }
scope :incomplete, -> { where.not(status: statuses[:done]) }
scope :due_today, -> { incomplete.where('DATE(due_date) <= ?', Date.today) }
scope :upcoming, -> { incomplete.where('due_date BETWEEN ? AND ?', Date.today, Date.today + 7.days) }
scope :someday, -> { incomplete.where(due_date: nil) }
scope :next_actions, -> { incomplete.where(due_date: nil, project_id: nil) }
scope :waiting_for, -> { incomplete.where(status: statuses[:waiting]) }
scope :inbox, -> { incomplete.where('due_date IS NULL OR project_id IS NULL') }
scope :ordered_by_due_date, lambda { |direction = 'asc'|
order(Arel.sql("CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date #{direction}"))
}
scope :with_tag, lambda { |tag_name|
joins(:tags).where(tags: { name: tag_name })
}
scope :by_status, ->(status) { where(status: statuses[status]) }
scope :by_priority, ->(priority) { where(priority: priorities[priority]) }
scope :order_by_priority, -> { order(priority: :desc) }
validates :name, presence: true, uniqueness: { scope: :user_id }
def self.filter_by_params(params, user)
tasks = user.tasks.includes(:project, :tags)
tasks = case params[:type]
when 'today'
tasks
when 'upcoming'
tasks.upcoming
when 'next'
tasks.next_actions
when 'inbox'
tasks.inbox
when 'someday'
tasks.someday
when 'waiting'
tasks.waiting_for
else
params[:status] == 'done' ? tasks.complete : tasks.incomplete
end
tasks = tasks.with_tag(params[:tag]) if params[:tag]
tasks = tasks.apply_ordering(params[:order_by]) if params[:order_by]
tasks.left_joins(:tags).distinct
end
scope :apply_ordering, lambda { |order_by|
order_column, order_direction = order_by.split(':')
order_direction ||= 'asc'
order_direction = order_direction.downcase == 'desc' ? :desc : :asc
allowed_columns = %w[created_at updated_at name priority status due_date]
raise ArgumentError, 'Invalid order column specified.' unless allowed_columns.include?(order_column)
if order_column == 'due_date'
ordered_by_due_date(order_direction)
else
order("tasks.#{order_column} #{order_direction}")
end
}
def self.compute_metrics(user)
total_open_tasks = user.tasks.incomplete.count
one_month_ago = Date.today - 30
tasks_pending_over_month = user.tasks.incomplete.where('created_at < ?', one_month_ago).count
tasks_in_progress = user.tasks.incomplete.where(status: statuses[:in_progress]).order(priority: :desc)
tasks_in_progress_count = tasks_in_progress.count
# Calculate tasks due today including those due via projects
tasks_due_today = user.tasks.incomplete.joins(:project)
.where('tasks.due_date <= ? OR projects.due_date_at <= ?', Date.today, Date.today)
.distinct
# Gather an array of IDs to be excluded from suggested tasks
excluded_task_ids = tasks_in_progress.pluck(:id) + tasks_due_today.pluck(:id)
# Gather tasks in projects expiring starting today, order by task priority
tasks_in_expiring_projects = user.tasks.incomplete
.joins(:project)
.where('projects.due_date_at >= ?', Date.today)
.where(projects: { active: true }) # Only active projects
.where.not(id: excluded_task_ids)
.order(Arel.sql('projects.due_date_at ASC, tasks.priority DESC'))
.limit(5)
# Gather tasks not assigned to projects expiring today, ordered by task priority
tasks_without_projects = user.tasks.incomplete
.where(status: statuses[:not_started], project_id: nil)
.or(user.tasks.where(project_id: nil, status: statuses[:not_started]))
.where.not(id: excluded_task_ids)
.order(priority: :desc)
.limit(5)
# Combine both list of suggested tasks
suggested_tasks = sort_suggested_tasks(tasks_in_expiring_projects + tasks_without_projects)
{
total_open_tasks: total_open_tasks,
tasks_pending_over_month: tasks_pending_over_month,
tasks_in_progress: tasks_in_progress,
tasks_in_progress_count: tasks_in_progress_count,
tasks_due_today: tasks_due_today,
suggested_tasks: suggested_tasks
}
end
def self.sort_suggested_tasks(tasks)
tasks.sort_by do |task|
# Parse or default the task due date
task_due_date = if task.due_date.is_a?(String)
Date.parse(task.due_date)
else
task.due_date || Date.new(9999, 12, 31)
end
# Parse or default the project due date
project_due_date = if task.project&.due_date_at.is_a?(String)
Date.parse(task&.project&.due_date_at)
else
task.project&.due_date_at || Date.new(9999, 12, 31)
end
# Priority in descending order (sorted values should be negative for sort_by)
priority_value = -Task.priorities.fetch(task.priority, -1)
# Determine sorting flags based on various criteria
is_high_priority_proj_with_due_date = task.priority == 'high' && task&.project&.due_date_at ? 0 : 1
is_high_priority_with_due_date = task.priority == 'high' && task.due_date ? 0 : 1
is_high_priority = task.priority == 'high' && !task.due_date && !task&.project&.due_date_at ? 0 : 1
is_medium_priority_proj_with_due_date = task.priority == 'medium' && task&.project&.due_date_at ? 0 : 1
is_medium_priority_with_due_date = task.priority == 'medium' && task.due_date ? 0 : 1
is_medium_priority = task.priority == 'medium' && !task.due_date && !task&.project&.due_date_at ? 0 : 1
is_low_priority_proj_with_due_date = task.priority == 'low' && task&.project&.due_date_at ? 0 : 1
is_low_priority_with_due_date = task.priority == 'low' && task.due_date ? 0 : 1
is_low_priority = task.priority == 'low' && !task.due_date && !task&.project&.due_date_at ? 0 : 1
# Primary sorting criteria
[
is_high_priority_proj_with_due_date,
is_high_priority_with_due_date,
is_high_priority,
is_medium_priority_proj_with_due_date,
is_medium_priority_with_due_date,
is_medium_priority,
is_low_priority_proj_with_due_date,
is_low_priority_with_due_date,
is_low_priority,
task_due_date,
project_due_date,
priority_value
]
end
end
def as_json(options = {})
super(options).merge(
'due_date' => due_date&.strftime('%Y-%m-%d')
)
end
end

View file

@ -1,20 +0,0 @@
class User < ActiveRecord::Base
has_secure_password
TASK_SUMMARY_FREQUENCIES = %w[daily weekdays weekly 1h 2h 4h 8h 12h].freeze
has_many :tasks, dependent: :destroy
has_many :projects, dependent: :destroy
has_many :areas, dependent: :destroy
has_many :notes, dependent: :destroy
has_many :tags, dependent: :destroy
has_many :inbox_items, dependent: :destroy
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }, uniqueness: true
validates :appearance, inclusion: { in: %w[light dark] }
validates :language, presence: true
validates :timezone, presence: true
validates :task_summary_frequency, inclusion: { in: TASK_SUMMARY_FREQUENCIES }, allow_nil: true
# has_one_attached :avatar_image
end

View file

@ -1,74 +0,0 @@
require 'sinatra'
require 'json'
post '/api/areas' do
content_type :json
begin
request_body = request.body.read
area_data = JSON.parse(request_body, symbolize_names: true)
halt 400, { error: 'Area name is required.' }.to_json unless area_data[:name] && !area_data[:name].strip.empty?
area = current_user.areas.build(name: area_data[:name], description: area_data[:description])
if area.save
status 201
area.to_json
else
status 400
{ error: 'There was a problem creating the area.', details: area.errors.full_messages }.to_json
end
rescue JSON::ParserError
halt 400, { error: 'Invalid JSON.' }.to_json
end
end
get '/api/areas/:id' do
area = current_user.areas.find_by(id: params[:id])
halt 404, { error: "Area not found or doesn't belong to the current user." }.to_json unless area
area.to_json
end
patch '/api/areas/:id' do
content_type :json
begin
area = current_user.areas.find_by(id: params[:id])
halt 404, { error: 'Area not found.' }.to_json unless area
request_body = request.body.read
area_data = JSON.parse(request_body, symbolize_names: true)
# Update Area attributes
area.name = area_data[:name] if area_data[:name]
area.description = area_data[:description] if area_data[:description]
if area.save
status 200
area.to_json
else
status 400
{ error: 'There was a problem updating the area.', details: area.errors.full_messages }.to_json
end
rescue JSON::ParserError
halt 400, { error: 'Invalid JSON.' }.to_json
end
end
delete '/api/areas/:id' do
content_type :json
area = current_user.areas.find_by(id: params[:id])
halt 404, { error: 'Area not found.' }.to_json unless area
if area.destroy
status 204
else
status 400
{ error: 'There was a problem deleting the area.' }.to_json
end
end
get '/api/areas' do
content_type :json
areas = current_user.areas
areas.to_json
end

View file

@ -1,49 +0,0 @@
require 'json'
class Sinatra::Application
get '/api/current_user' do
content_type :json
if logged_in?
{ user: { email: current_user.email, id: current_user.id, language: current_user.language,
appearance: current_user.appearance, timezone: current_user.timezone } }.to_json
else
{ user: nil }.to_json
end
end
post '/api/login' do
content_type :json
request_payload = begin
JSON.parse(request.body.read)
rescue StandardError
nil
end
if request_payload
email = request_payload['email']
password = request_payload['password']
else
halt 400, { error: 'Invalid login parameters.' }.to_json
end
user = User.find_by(email: email)
if user&.authenticate(password)
session[:user_id] = user.id
status 200
{ user: { email: user.email, id: user.id, language: user.language, appearance: user.appearance,
timezone: user.timezone } }.to_json
else
halt 401, { errors: ['Invalid credentials'] }.to_json
end
end
get '/api/logout' do
session.clear
status 200
{ message: 'Logged out successfully' }.to_json
end
# session.clear
# redirect '/login'
# end
end

View file

@ -1,92 +0,0 @@
module Sinatra
class Application
get '/api/inbox' do
content_type :json
items = current_user.inbox_items.where(status: 'added').order(created_at: :desc)
items.to_json
end
post '/api/inbox' do
content_type :json
request_body = request.body.read
item_data = begin
JSON.parse(request_body)
rescue JSON::ParserError => e
halt 400, { error: 'Invalid JSON format.' }.to_json
end
item = current_user.inbox_items.build(
content: item_data['content'],
source: item_data['source'] || 'tududi'
)
if item.save
status 201
item.to_json
else
errors = item.errors.full_messages
halt 400, { error: 'There was a problem creating the inbox item.', details: errors }.to_json
end
end
patch '/api/inbox/:id' do
content_type :json
item = current_user.inbox_items.find_by(id: params[:id])
halt 404, { error: 'Inbox item not found.' }.to_json unless item
request_body = request.body.read
item_data = begin
JSON.parse(request_body)
rescue JSON::ParserError => e
halt 400, { error: 'Invalid JSON format.' }.to_json
end
if item.update(content: item_data['content'])
item.to_json
else
errors = item.errors.full_messages
halt 400, { error: 'There was a problem updating the inbox item.', details: errors }.to_json
end
end
patch '/api/inbox/:id/process' do
content_type :json
item = current_user.inbox_items.find_by(id: params[:id])
halt 404, { error: 'Inbox item not found.' }.to_json unless item
if item.mark_as_processed!
item.to_json
else
halt 400, { error: 'There was a problem processing the inbox item.' }.to_json
end
end
# Mark an inbox item as deleted
delete '/api/inbox/:id' do
content_type :json
item = current_user.inbox_items.find_by(id: params[:id])
halt 404, { error: 'Inbox item not found.' }.to_json unless item
if item.mark_as_deleted!
{ message: 'Inbox item successfully deleted' }.to_json
else
halt 400, { error: 'There was a problem deleting the inbox item.' }.to_json
end
end
# Get a specific inbox item by ID
get '/api/inbox/:id' do
content_type :json
item = current_user.inbox_items.find_by(id: params[:id])
halt 404, { error: 'Inbox item not found.' }.to_json unless item
item.to_json
end
end
end

View file

@ -1,134 +0,0 @@
class Sinatra::Application
def update_note_tags(note, tags_array)
return if tags_array.blank?
begin
tag_names = tags_array.uniq
tags = tag_names.map do |name|
current_user.tags.find_or_create_by(name: name)
end
note.tags = tags
rescue StandardError => e
puts "Failed to update tags: #{e.message}"
end
end
get '/api/notes' do
order_by = params[:order_by] || 'title:asc'
order_column, order_direction = order_by.split(':')
@notes = current_user.notes.includes(:tags)
@notes = @notes.joins(:tags).where(tags: { name: params[:tag] }) if params[:tag]
@notes = @notes.order("notes.#{order_column} #{order_direction}")
query_params = Rack::Utils.parse_nested_query(request.query_string)
query_params.delete('tag')
@base_query = query_params.to_query
@base_url = '/notes?'
@base_url += "#{@base_query}&" unless @base_query.empty?
@notes.to_json(include: :tags)
end
get '/api/note/:id' do
content_type :json
note = current_user.notes.includes(:tags).find_by(id: params[:id])
halt 404, { error: 'Note not found.' }.to_json unless note
note.to_json(include: :tags)
end
post '/api/note' do
content_type :json
request_body = request.body.read
note_data = JSON.parse(request_body, symbolize_names: true)
note_attributes = {
title: note_data[:title],
content: note_data[:content],
user_id: current_user.id
}
if note_data[:project_id].to_s.empty?
note = current_user.notes.build(note_attributes)
else
project = current_user.projects.find_by(id: note_data[:project_id])
halt 400, { error: 'Invalid project.' }.to_json unless project
note = project.notes.build(note_attributes)
end
if note.save
# Handle tags array whether it's an array of strings or an array of objects with name property
tag_names = if note_data[:tags].is_a?(Array) && note_data[:tags].all? { |t| t.is_a?(String) }
note_data[:tags]
elsif note_data[:tags].is_a?(Array) && note_data[:tags].all? { |t| t.is_a?(Hash) && t[:name] }
note_data[:tags].map { |t| t[:name] }
else
[]
end
update_note_tags(note, tag_names)
status 201
note.to_json(include: :tags)
else
status 400
{ error: 'There was a problem creating the note.', details: note.errors.full_messages }.to_json
end
end
patch '/api/note/:id' do
content_type :json
note = current_user.notes.find_by(id: params[:id])
halt 404, { error: 'Note not found.' }.to_json unless note
request_body = request.body.read
request_data = JSON.parse(request_body)
note_attributes = {
title: request_data['title'],
content: request_data['content']
}
if request_data['project_id'] && !request_data['project_id'].to_s.empty?
project = current_user.projects.find_by(id: request_data['project_id'])
halt 400, { error: 'Invalid project.' }.to_json unless project
note.project = project
else
note.project = nil
end
if note.update(note_attributes)
# Handle tags array whether it's an array of strings or an array of objects with name property
tag_names = if request_data['tags'].is_a?(Array) && request_data['tags'].all? { |t| t.is_a?(String) }
request_data['tags']
elsif request_data['tags'].is_a?(Array) && request_data['tags'].all? do |t|
t.is_a?(Hash) && t['name']
end
request_data['tags'].map { |t| t['name'] }
else
[]
end
update_note_tags(note, tag_names)
note.to_json(include: :tags)
else
status 400
{ error: 'There was a problem updating the note.', details: note.errors.full_messages }.to_json
end
end
delete '/api/note/:id' do
content_type :json
note = current_user.notes.find_by(id: params[:id])
halt 404, { error: 'Note not found.' }.to_json unless note
if note.destroy
{ message: 'Note deleted successfully.' }.to_json
else
status 400
{ error: 'There was a problem deleting the note.' }.to_json
end
end
end

View file

@ -1,136 +0,0 @@
require 'sinatra/namespace'
class Sinatra::Application
register Sinatra::Namespace
def update_project_tags(project, tags_data)
return if tags_data.nil?
tag_names = tags_data.map { |tag| tag['name'] }.compact.reject(&:empty?).uniq
existing_tags = Tag.where(user: current_user, name: tag_names)
new_tags = tag_names - existing_tags.pluck(:name)
created_tags = new_tags.map { |name| Tag.create(name: name, user: current_user) }
project.tags = (existing_tags + created_tags).uniq
end
namespace '/api' do
before do
content_type :json
end
get '/projects' do
active_param = params[:active]
is_active = active_param == 'true' unless active_param.nil? || active_param == 'all'
pin_to_sidebar_param = params[:pin_to_sidebar]
is_pinned = pin_to_sidebar_param == 'true' unless pin_to_sidebar_param.nil?
area_id_param = params[:area_id]
projects = current_user.projects
.includes(:tags)
.left_joins(:tasks, :area)
.distinct
.order('projects.name ASC')
projects = projects.where(active: is_active) unless is_active.nil?
projects = projects.where(pin_to_sidebar: is_pinned) unless is_pinned.nil?
projects = projects.where(area_id: area_id_param) unless area_id_param.blank?
task_status_counts = projects.each_with_object({}) do |project, counts|
counts[project.id] = project.task_status_counts
end
grouped_projects = projects.group_by(&:area)
{
projects: projects.as_json(include: { tasks: {}, area: { only: :name }, tags: { only: %i[id name] } }),
task_status_counts: task_status_counts,
grouped_projects: grouped_projects.as_json(include: { area: { only: :name } })
}.to_json
end
get '/project/:id' do
project = current_user.projects.includes(:tasks, :tags).find_by(id: params[:id])
halt 404, { error: 'Project not found' }.to_json unless project
project.as_json(include: { tasks: {}, area: { only: %i[id name] }, tags: { only: %i[id name] } }).to_json
end
post '/project' do
request_body = request.body.read
project_data = begin
JSON.parse(request_body)
rescue JSON::ParserError
halt 400, { error: 'Invalid JSON format.' }.to_json
end
project_data['priority'] = Project.priorities[project_data['priority']] if project_data['priority'].is_a?(String)
project = current_user.projects.new(
name: project_data['name'],
description: project_data['description'] || '',
area_id: project_data['area_id'],
active: true,
pin_to_sidebar: false,
priority: project_data['priority'],
due_date_at: project_data['due_date_at']
)
if project.save
update_project_tags(project, project_data['tags'])
status 201
project.as_json(include: { tags: { only: %i[id name] } }).to_json
else
status 400
{ error: 'There was a problem creating the project.', details: project.errors.full_messages }.to_json
end
end
patch '/project/:id' do
project = current_user.projects.find_by(id: params[:id])
halt 404, { error: 'Project not found.' }.to_json unless project
request_body = request.body.read
project_data = begin
JSON.parse(request_body)
rescue JSON::ParserError
halt 400, { error: 'Invalid JSON format.' }.to_json
end
project.assign_attributes(
name: project_data['name'],
description: project_data['description'],
area_id: project_data['area_id'],
active: project_data['active'],
pin_to_sidebar: project_data['pin_to_sidebar'],
priority: project_data ['priority'],
due_date_at: project_data['due_date_at']
)
if project.save
update_project_tags(project, project_data['tags'])
project.as_json(include: { tags: { only: %i[id name] } }).to_json
else
status 400
{ error: 'There was a problem updating the project.', details: project.errors.full_messages }.to_json
end
end
delete '/project/:id' do
project = current_user.projects.find_by(id: params[:id])
halt 404, { error: 'Project not found' }.to_json unless project
if project.destroy
{ message: 'Project successfully deleted' }.to_json
else
status 400
{ error: 'There was a problem deleting the project.' }.to_json
end
end
end
end

View file

@ -1,67 +0,0 @@
class Sinatra::Application
get '/api/tags' do
content_type :json
tags = current_user.tags.order('name ASC')
tags.as_json(only: %i[id name]).to_json
end
get '/api/tag/:id' do
content_type :json
tag = current_user.tags.find_by(id: params[:id])
halt 404, { error: 'Tag not found' }.to_json unless tag
tag.as_json(only: %i[id name]).to_json
end
post '/api/tag' do
content_type :json
request_body = JSON.parse(request.body.read)
tag = current_user.tags.new(name: request_body['name'])
if tag.save
status 201
tag.as_json(only: %i[id name]).to_json
else
status 400
{ error: 'There was a problem creating the tag.' }.to_json
end
end
patch '/api/tag/:id' do
content_type :json
tag = current_user.tags.find_by(id: params[:id])
halt 404, { error: 'Tag not found' }.to_json unless tag
request_body = JSON.parse(request.body.read)
tag.name = request_body['name']
if tag.save
tag.as_json(only: %i[id name]).to_json
else
status 400
{ error: 'There was a problem updating the tag.' }.to_json
end
end
delete '/api/tag/:id' do
content_type :json
tag = current_user.tags.find_by(id: params[:id])
halt 404, { error: 'Tag not found' }.to_json unless tag
if tag.destroy
{ message: 'Tag successfully deleted' }.to_json
else
status 400
{ error: 'There was a problem deleting the tag.' }.to_json
end
end
end

View file

@ -1,159 +0,0 @@
module Sinatra
class Application
def update_task_tags(task, tags_data)
return if tags_data.nil?
tag_names = tags_data.map { |tag| tag['name'] }.compact.reject(&:empty?).uniq
existing_tags = Tag.where(user: current_user, name: tag_names)
new_tags = tag_names - existing_tags.pluck(:name)
created_tags = new_tags.map { |name| Tag.create(name: name, user: current_user) }
task.tags = (existing_tags + created_tags).uniq
end
get '/api/tasks' do
content_type :json
begin
tasks = Task.filter_by_params(params, current_user)
rescue ArgumentError => e
halt 400, { error: e.message }.to_json
end
metrics = Task.compute_metrics(current_user)
# Prepare the response
response = {
tasks: tasks.as_json(include: { tags: { only: %i[id name] }, project: { only: :name } }),
metrics: {
total_open_tasks: metrics[:total_open_tasks],
tasks_pending_over_month: metrics[:tasks_pending_over_month],
tasks_in_progress_count: metrics[:tasks_in_progress_count],
tasks_in_progress: metrics[:tasks_in_progress].as_json(include: { tags: { only: %i[id name] },
project: { only: :name } }),
tasks_due_today: metrics[:tasks_due_today].as_json(include: { tags: { only: %i[id name] },
project: { only: :name } }),
suggested_tasks: metrics[:suggested_tasks].as_json(include: { tags: { only: %i[id name] },
project: { only: :name } })
}
}
response.to_json
end
post '/api/task' do
content_type :json
request_body = request.body.read
task_data = begin
JSON.parse(request_body)
rescue JSON::ParserError => e
halt 400, { error: 'Invalid JSON format.' }.to_json
end
task_attributes = {
name: task_data['name'],
priority: task_data['priority'],
due_date: task_data['due_date'].presence,
status: task_data['status'] || Task.statuses[:not_started],
note: task_data['note'],
user_id: current_user.id
}
value = task_data['project_id']
task = if value.nil? || value.to_s.strip.empty?
current_user.tasks.build(task_attributes)
else
project = current_user.projects.find_by(id: value)
halt 400, { error: 'Invalid project.' }.to_json unless project
project.tasks.build(task_attributes)
end
if task.save
update_task_tags(task, task_data['tags'])
status 201
task.to_json(include: { tags: { only: :name }, project: { only: :name } })
else
errors = task.errors.full_messages
halt 400, { error: 'There was a problem creating the task.', details: errors }.to_json
end
end
patch '/api/task/:id' do
content_type :json
puts "Request to update task with ID: #{params[:id]}"
puts "Current user: #{current_user&.id}"
request_body = request.body.read
task_data = begin
JSON.parse(request_body)
rescue JSON::ParserError => e
halt 400, { error: 'Invalid JSON format.' }.to_json
end
task = current_user.tasks.find_by(id: params[:id])
halt 404, { error: 'Task not found.' }.to_json unless task
task_attributes = {
name: task_data['name'], # Get the name from the JSON body
priority: task_data['priority'],
status: task_data['status'] || Task.statuses[:not_started],
note: task_data['note'],
due_date: task_data['due_date'].presence
}
if task_data['project_id'] && !task_data['project_id'].to_s.strip.empty?
project = current_user.projects.find_by(id: task_data['project_id'])
halt 400, { error: 'Invalid project.' }.to_json unless project
task.project = project
else
task.project = nil
end
if task.update(task_attributes)
update_task_tags(task, task_data['tags'])
task.to_json(include: { tags: { only: :name }, project: { only: :name } })
else
errors = task.errors.full_messages
halt 400, { error: 'There was a problem updating the task.', details: errors }.to_json
end
end
patch '/api/task/:id/toggle_completion' do
content_type :json
task = current_user.tasks.find_by(id: params[:id])
halt 404, { error: 'Task not found.' }.to_json unless task
new_status = if task.done?
task.note.present? ? :in_progress : :not_started
else
:done
end
task.status = new_status
if task.save
task.to_json
else
status 422
{ error: 'Unable to update task' }.to_json
end
end
delete '/api/task/:id' do
content_type :json
task = current_user.tasks.find_by(id: params[:id])
halt 404, { error: 'Task not found.' }.to_json unless task
if task.destroy
status 200
{ message: 'Task successfully deleted' }.to_json
else
halt 400, { error: 'There was a problem deleting the task.' }.to_json
end
end
end
end

View file

@ -1,231 +0,0 @@
require 'net/http'
require 'uri'
require 'json'
require 'thread'
# A class to handle polling for Telegram updates
class TelegramPoller
@@instance = nil
@@mutex = Mutex.new
attr_reader :running, :thread, :poll_interval, :last_update_id, :users_to_poll
def initialize
@running = false
@thread = nil
@poll_interval = 5 # seconds
@last_update_id = 0
@users_to_poll = []
# Keep a record of which users have active polling
@user_status = {}
end
def self.instance
@@mutex.synchronize do
@@instance ||= new
end
@@instance
end
# Start polling for a specific user
def add_user(user)
return false unless user && user.telegram_bot_token
@users_to_poll << user unless @users_to_poll.any? { |u| u.id == user.id }
# Start the polling thread if not already running
start_polling if @users_to_poll.any? && !@running
true
end
# Remove a user from polling
def remove_user(user_id)
@users_to_poll.reject! { |u| u.id == user_id }
# Stop polling if no users left
stop_polling if @users_to_poll.empty? && @running
true
end
# Start the polling thread
def start_polling
return if @running
@running = true
@thread = Thread.new do
while @running
begin
poll_updates
rescue => e
puts "Error polling Telegram: #{e.message}"
puts e.backtrace.join("\n")
end
sleep @poll_interval
end
end
end
# Stop the polling thread
def stop_polling
return unless @running
@running = false
@thread.join if @thread
@thread = nil
end
# Poll for updates from Telegram
def poll_updates
@users_to_poll.each do |user|
token = user.telegram_bot_token
next unless token
begin
# Get updates from Telegram
uri = URI.parse("https://api.telegram.org/bot#{token}/getUpdates")
params = {
offset: @user_status[user.id]&.dig(:last_update_id).to_i + 1,
timeout: 1 # Short timeout for quick polling
}
uri.query = URI.encode_www_form(params)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.read_timeout = 5
request = Net::HTTP::Get.new(uri.request_uri)
response = http.request(request)
if response.code == '200'
data = JSON.parse(response.body)
if data['ok'] && data['result'].is_a?(Array)
process_updates(user, data['result'])
end
else
puts "Error polling Telegram for user #{user.id}: #{response.code} #{response.message}"
end
rescue => e
puts "Error getting updates for user #{user.id}: #{e.message}"
end
end
end
# Process updates received from Telegram
def process_updates(user, updates)
return if updates.empty?
# Track the highest update_id to avoid processing the same update twice
highest_update_id = updates.map { |u| u['update_id'].to_i }.max || 0
# Save the last update ID for this user
@user_status[user.id] ||= {}
@user_status[user.id][:last_update_id] = highest_update_id if highest_update_id > (@user_status[user.id][:last_update_id] || 0)
updates.each do |update|
begin
# Process message updates
if update['message'] && update['message']['text']
process_message(user, update)
end
rescue => e
puts "Error processing update #{update['update_id']}: #{e.message}"
end
end
end
# Process a single message
def process_message(user, update)
message = update['message']
text = message['text']
chat_id = message['chat']['id'].to_s
message_id = message['message_id']
puts "Processing message from user #{user.id}: #{text}"
# Save the chat_id if not already saved
if user.telegram_chat_id.nil? || user.telegram_chat_id.empty?
puts "Updating user's telegram_chat_id to #{chat_id}"
user.update(telegram_chat_id: chat_id)
end
# Create an inbox item
inbox_item = user.inbox_items.build(
content: text,
source: 'telegram'
)
if inbox_item.save
puts "Created inbox item #{inbox_item.id} from Telegram message"
# Send confirmation
begin
send_telegram_message(
user.telegram_bot_token,
chat_id,
"✅ Added to Tududi inbox: \"#{text}\"",
message_id
)
rescue => e
puts "Error sending confirmation: #{e.message}"
end
else
puts "Failed to create inbox item: #{inbox_item.errors.full_messages.join(', ')}"
# Send error message
begin
send_telegram_message(
user.telegram_bot_token,
chat_id,
"❌ Failed to add to inbox: #{inbox_item.errors.full_messages.join(', ')}",
message_id
)
rescue => e
puts "Error sending error message: #{e.message}"
end
end
end
# Send a message to Telegram
def send_telegram_message(token, chat_id, text, reply_to_message_id = nil)
uri = URI.parse("https://api.telegram.org/bot#{token}/sendMessage")
# Prepare message parameters
message_params = {
chat_id: chat_id,
text: text,
parse_mode: "MarkdownV2"
}
# Add reply_to_message_id if provided
message_params[:reply_to_message_id] = reply_to_message_id if reply_to_message_id
# Send the request to Telegram API
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
request = Net::HTTP::Post.new(uri.request_uri, 'Content-Type' => 'application/json')
request.body = message_params.to_json
response = http.request(request)
return JSON.parse(response.body)
end
# Get status of the poller
def status
{
running: @running,
users_count: @users_to_poll.size,
poll_interval: @poll_interval,
user_status: @user_status
}
end
end
# Initialize the poller when this file is loaded
TelegramPoller.instance

View file

@ -1,160 +0,0 @@
require 'net/http'
require 'uri'
require 'json'
require_relative 'telegram_poller'
module Sinatra
class Application
# Start polling for a user
post '/api/telegram/start-polling' do
content_type :json
# Get the current user's Telegram token
user = current_user
halt 400, { error: 'Telegram bot token not set.' }.to_json unless user.telegram_bot_token
# Add the user to the polling list
if TelegramPoller.instance.add_user(user)
{
success: true,
message: 'Telegram polling started',
status: TelegramPoller.instance.status
}.to_json
else
halt 500, { error: 'Failed to start Telegram polling.' }.to_json
end
end
# Stop polling for a user
post '/api/telegram/stop-polling' do
content_type :json
user = current_user
# Remove the user from the polling list
if TelegramPoller.instance.remove_user(user.id)
{
success: true,
message: 'Telegram polling stopped',
status: TelegramPoller.instance.status
}.to_json
else
halt 500, { error: 'Failed to stop Telegram polling.' }.to_json
end
end
# Get polling status
get '/api/telegram/polling-status' do
content_type :json
{
success: true,
status: TelegramPoller.instance.status,
is_polling: TelegramPoller.instance.users_to_poll.any? { |u| u.id == current_user.id }
}.to_json
end
# Setup the Telegram bot for a user (save token and start polling)
post '/api/telegram/setup' do
content_type :json
request_body = request.body.read
begin
setup_data = JSON.parse(request_body)
rescue JSON::ParserError
halt 400, { error: 'Invalid JSON format.' }.to_json
end
token = setup_data['token']
halt 400, { error: 'Telegram bot token is required.' }.to_json unless token && !token.empty?
# Validate the token by making a getMe request to Telegram
begin
uri = URI.parse("https://api.telegram.org/bot#{token}/getMe")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
response = http.get(uri.request_uri)
json_response = JSON.parse(response.body)
if json_response['ok']
# Token is valid, save it to the user
bot_username = json_response['result']['username']
current_user.update(telegram_bot_token: token)
# Start polling for this user
TelegramPoller.instance.add_user(current_user)
# Return success with bot info
{
success: true,
message: 'Telegram bot configured successfully and polling started!',
bot: {
username: bot_username,
polling_status: TelegramPoller.instance.status,
chat_url: "https://t.me/#{bot_username}"
}
}.to_json
else
halt 400, { error: 'Invalid Telegram bot token.', details: json_response['description'] }.to_json
end
rescue => e
halt 500, { error: 'Error validating Telegram bot token.', details: e.message }.to_json
end
end
# Test endpoint to simulate a Telegram message (for development)
post '/api/telegram/test/:user_id' do
content_type :json
request_body = request.body.read
begin
message_data = JSON.parse(request_body)
rescue JSON::ParserError
halt 400, { error: 'Invalid JSON format.' }.to_json
end
user_id = params[:user_id]
user = User.find_by(id: user_id)
halt 404, { error: 'User not found.' }.to_json unless user
halt 400, { error: 'User has no Telegram bot token configured.' }.to_json unless user.telegram_bot_token
text = message_data['text'] || 'Test message from development environment'
# Create an inbox item directly
inbox_item = user.inbox_items.build(
content: text,
source: 'telegram'
)
if inbox_item.save
# Send confirmation to Telegram if the user has a chat_id
if user.telegram_chat_id
begin
# Use the TelegramPoller's send_message method
response = TelegramPoller.instance.send_telegram_message(
user.telegram_bot_token,
user.telegram_chat_id,
"✅ Added to Tududi inbox: \"#{text}\""
)
puts "Test message confirmation sent: #{response}"
rescue => e
puts "Error sending test confirmation: #{e.message}"
end
end
{
success: true,
message: 'Test Telegram message processed successfully!',
inbox_item_id: inbox_item.id
}.to_json
else
{
success: false,
message: 'Failed to create inbox item from test message',
errors: inbox_item.errors.full_messages
}.to_json
end
end
end
end

View file

@ -1,43 +0,0 @@
require_relative '../services/url_title_extractor_service'
module Sinatra
class Application
get '/api/url/title' do
content_type :json
url = params[:url]
halt 400, { error: 'URL parameter is required' }.to_json unless url
title = UrlTitleExtractorService.extract_title(url)
if title
{ url: url, title: title }.to_json
else
{ url: url, title: nil, error: 'Could not extract title' }.to_json
end
end
post '/api/url/extract-from-text' do
content_type :json
request_body = request.body.read
begin
data = JSON.parse(request_body)
text = data['text']
halt 400, { error: 'Text parameter is required' }.to_json unless text
result = UrlTitleExtractorService.extract_title_from_text(text)
if result
result.to_json
else
{ found: false }.to_json
end
rescue JSON::ParserError
halt 400, { error: 'Invalid JSON format' }.to_json
end
end
end
end

View file

@ -1,168 +0,0 @@
module Sinatra
class Application
get '/api/profile' do
content_type :json
user = current_user
if user
user.to_json(only: %i[id email appearance language timezone avatar_image telegram_bot_token telegram_chat_id task_summary_enabled task_summary_frequency])
else
halt 404, { error: 'Profile not found.' }.to_json
end
end
patch '/api/profile' do
content_type :json
begin
request_payload = JSON.parse(request.body.read)
rescue JSON::ParserError
halt 400, { error: 'Invalid JSON format.' }.to_json
end
user = current_user
halt 404, { error: 'Profile not found.' }.to_json if user.nil?
allowed_params = {}
allowed_params[:appearance] = request_payload['appearance'] if request_payload.key?('appearance')
allowed_params[:language] = request_payload['language'] if request_payload.key?('language')
allowed_params[:timezone] = request_payload['timezone'] if request_payload.key?('timezone')
allowed_params[:avatar_image] = request_payload['avatar_image'] if request_payload.key?('avatar_image')
allowed_params[:telegram_bot_token] = request_payload['telegram_bot_token'] if request_payload.key?('telegram_bot_token')
if user.update(allowed_params)
user.to_json(only: %i[id email appearance language timezone avatar_image telegram_bot_token telegram_chat_id])
else
status 400
{ error: 'Failed to update profile.', details: user.errors.full_messages }.to_json
end
end
post '/api/profile/task-summary/toggle' do
content_type :json
user = current_user
halt 404, { error: 'User not found.' }.to_json unless user
# Toggle the task_summary_enabled flag
enabled = !user.task_summary_enabled
if user.update(task_summary_enabled: enabled)
# If enabling, send a test summary to confirm it works
if enabled && user.telegram_bot_token && user.telegram_chat_id
begin
success = TaskSummaryService.send_summary_to_user(user.id)
if success
{
success: true,
enabled: enabled,
message: 'Task summary notifications have been enabled and a test message was sent to your Telegram.'
}.to_json
else
user.update(task_summary_enabled: false)
halt 400, {
error: 'Failed to send test message to Telegram. Please check your Telegram bot configuration.'
}.to_json
end
rescue => e
user.update(task_summary_enabled: false)
halt 400, {
error: 'Error sending test message to Telegram.',
details: e.message
}.to_json
end
else
{
success: true,
enabled: enabled,
message: enabled ? 'Task summary notifications have been enabled.' : 'Task summary notifications have been disabled.'
}.to_json
end
else
halt 400, {
error: 'Failed to update task summary settings.',
details: user.errors.full_messages
}.to_json
end
end
post '/api/profile/task-summary/frequency' do
content_type :json
begin
request_payload = JSON.parse(request.body.read)
rescue JSON::ParserError
halt 400, { error: 'Invalid JSON format.' }.to_json
end
frequency = request_payload['frequency']
halt 400, { error: 'Frequency is required.' }.to_json unless frequency
# Validate frequency value
valid_frequencies = User::TASK_SUMMARY_FREQUENCIES
halt 400, { error: 'Invalid frequency value.' }.to_json unless valid_frequencies.include?(frequency)
user = current_user
halt 404, { error: 'User not found.' }.to_json unless user
if user.update(task_summary_frequency: frequency)
{
success: true,
frequency: frequency,
message: "Task summary frequency has been set to #{frequency}."
}.to_json
else
halt 400, {
error: 'Failed to update task summary frequency.',
details: user.errors.full_messages
}.to_json
end
end
post '/api/profile/task-summary/send-now' do
content_type :json
user = current_user
halt 404, { error: 'User not found.' }.to_json unless user
if user.telegram_bot_token && user.telegram_chat_id
begin
success = TaskSummaryService.send_summary_to_user(user.id)
if success
{
success: true,
message: 'Task summary was sent to your Telegram.'
}.to_json
else
halt 400, { error: 'Failed to send message to Telegram.' }.to_json
end
rescue => e
halt 400, {
error: 'Error sending message to Telegram.',
details: e.message
}.to_json
end
else
halt 400, { error: 'Telegram bot is not properly configured.' }.to_json
end
end
get '/api/profile/task-summary/status' do
content_type :json
user = current_user
halt 404, { error: 'User not found.' }.to_json unless user
{
success: true,
enabled: user.task_summary_enabled,
frequency: user.task_summary_frequency,
last_run: user.task_summary_last_run,
next_run: user.task_summary_next_run
}.to_json
end
end
end

View file

@ -1,288 +0,0 @@
# app/services/task_summary_service.rb
require 'yaml'
class TaskSummaryService
# Helper method to escape special characters for MarkdownV2
def self.escape_markdown(text)
# Characters that need to be escaped in MarkdownV2: _*[]()~`>#+-=|{}.!
text.to_s.gsub(/([_*\[\]()~`>#+\-=|{}.!])/, '\\\\\1')
end
def self.generate_summary_for_user(user_id)
user = User.find_by(id: user_id)
return nil unless user
# Get today's tasks, in progress tasks, etc.
tasks = user.tasks
today = Date.today
due_today = tasks.where('DATE(due_date) = ?', today).where.not(status: 'done')
in_progress = tasks.where(status: 'in_progress')
completed_today = tasks.where(status: 'done').where('DATE(updated_at) = ?', today)
# Generate summary message
message = "📋 *Today's Task Summary*\n\n"
# Add a header divider
message += "━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
# Start Today's Plan section
message += "✏️ *Today's Plan*\n\n"
# Add due today tasks to Today's Plan
# Add due today tasks to Today's Plan
if due_today.any?
message += "🚀 *Tasks Due Today:*\n"
due_today.order(:name).each_with_index do |task, index|
priority_emoji =
case task.priority
when 'high' then '🔴'
when 'medium' then '🟠'
when 'low' then '🟢'
else '⚪'
end
# Escape special characters in task name and project name
task_name = escape_markdown(task.name)
project_info = task.project ? " \\[#{escape_markdown(task.project.name)}\\]" : ''
message += "#{index + 1}\\. #{priority_emoji} #{task_name}#{project_info}\n"
end
message += "\n"
end
# Add in progress tasks to Today's Plan
if in_progress.any?
message += "⚙️ *In Progress Tasks:*\n"
in_progress.order(:name).each_with_index do |task, index|
priority_emoji =
case task.priority
when 'high' then '🔴'
when 'medium' then '🟠'
when 'low' then '🟢'
else '⚪'
end
# Escape special characters in task name and project name
task_name = escape_markdown(task.name)
project_info = task.project ? " \\[#{escape_markdown(task.project.name)}\\]" : ''
message += "#{index + 1}\\. #{priority_emoji} #{task_name}#{project_info}\n"
end
message += "\n"
end
# Add suggested tasks (not done, not in due today or in progress)
suggested_task_ids = due_today.pluck(:id) + in_progress.pluck(:id)
# Get tasks in expiring projects - same logic as Task.compute_metrics
tasks_in_expiring_projects = tasks
.where.not(status: 'done')
.where.not(id: suggested_task_ids)
.joins(:project)
.where('projects.due_date_at >= ?', today)
.where(projects: { active: true }) # Only active projects
.order(Arel.sql('projects.due_date_at ASC, tasks.priority DESC'))
# Get tasks not assigned to projects - same logic as Task.compute_metrics
tasks_without_projects = tasks
.where.not(status: 'done')
.where.not(id: suggested_task_ids)
.where(project_id: nil, status: 'not_started')
.order(priority: :desc)
# Combine both sets of tasks
combined_tasks = (tasks_in_expiring_projects + tasks_without_projects)
# Sort using same logic as Task.sort_suggested_tasks
suggested_tasks = combined_tasks.sort_by do |task|
# Parse or default the task due date
task_due_date = if task.due_date.is_a?(String)
Date.parse(task.due_date)
else
task.due_date || Date.new(9999, 12, 31)
end
# Parse or default the project due date
project_due_date = if task.project&.due_date_at.is_a?(String)
Date.parse(task&.project&.due_date_at)
else
task.project&.due_date_at || Date.new(9999, 12, 31)
end
# Priority in descending order (sorted values should be negative for sort_by)
priority_value = -Task.priorities.fetch(task.priority, -1)
# Determine sorting flags based on various criteria
is_high_priority_proj_with_due_date = task.priority == 'high' && task.project&.due_date_at ? 0 : 1
is_high_priority_with_due_date = task.priority == 'high' && task.due_date ? 0 : 1
is_high_priority = task.priority == 'high' && !task.due_date && !task.project&.due_date_at ? 0 : 1
is_medium_priority_proj_with_due_date = task.priority == 'medium' && task.project&.due_date_at ? 0 : 1
is_medium_priority_with_due_date = task.priority == 'medium' && task.due_date ? 0 : 1
is_medium_priority = task.priority == 'medium' && !task.due_date && !task.project&.due_date_at ? 0 : 1
is_low_priority_proj_with_due_date = task.priority == 'low' && task.project&.due_date_at ? 0 : 1
is_low_priority_with_due_date = task.priority == 'low' && task.due_date ? 0 : 1
is_low_priority = task.priority == 'low' && !task.due_date && !task.project&.due_date_at ? 0 : 1
# Primary sorting criteria - same as Task.sort_suggested_tasks
[
is_high_priority_proj_with_due_date,
is_high_priority_with_due_date,
is_high_priority,
is_medium_priority_proj_with_due_date,
is_medium_priority_with_due_date,
is_medium_priority,
is_low_priority_proj_with_due_date,
is_low_priority_with_due_date,
is_low_priority,
task_due_date,
project_due_date,
priority_value
]
end.first(5)
if suggested_tasks.any?
message += "💡 *Suggested Tasks \\(Top 3\\):*\n"
# Only display the top 3 suggested tasks
suggested_tasks.first(5).each_with_index do |task, index|
priority_emoji =
case task.priority
when 'high' then '🔴'
when 'medium' then '🟠'
when 'low' then '🟢'
else '⚪'
end
# Escape special characters in task name and project name
task_name = escape_markdown(task.name)
project_info = task.project ? " \\[#{escape_markdown(task.project.name)}\\]" : ''
due_date = task.due_date ? " \\(Due: #{escape_markdown(task.due_date.strftime('%b %d'))}\\)" : ''
message += "#{index + 1}\\. #{priority_emoji} #{task_name}#{project_info}#{due_date}\n"
end
message += "\n"
end
# Add a section divider
message += "━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
# Add completed tasks for today if any
if completed_today.any?
message += "✅ *Completed Today:*\n"
completed_today.order(updated_at: :desc).each_with_index do |task, index|
# Escape special characters in task name and project name
task_name = escape_markdown(task.name)
project_info = task.project ? " \\[#{escape_markdown(task.project.name)}\\]" : ''
message += "#{index + 1}\\. #{task_name}#{project_info}\n"
end
message += "\n"
end
# Add inbox count if available
inbox_items_count = user.inbox_items.where(status: 'added').count
if inbox_items_count > 0
message += "*Inbox:*\n"
message += "• You have #{inbox_items_count} item\\(s\\) in your inbox to process\\.\n\n"
end
# Add a section divider
message += "━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
# Add a motivational note from the YAML file
begin
quotes_file = Rails.root.join('config', 'quotes.yml')
quotes_data = YAML.load_file(quotes_file)['quotes']
message += "💪 *Today's Motivation:*\n"
quote = quotes_data.sample
# Escape special characters in the quote
message += escape_markdown(quote)
rescue StandardError => e
# Fallback to default quotes if there's an issue loading from YAML
default_quotes = [
'Focus on progress, not perfection.',
'One task at a time leads to great accomplishments.',
"Today's effort is tomorrow's success.",
'Small steps every day lead to big results.'
]
message += "💪 *Today's Motivation:*\n"
quote = default_quotes.sample
# Escape special characters in the quote
message += escape_markdown(quote)
end
message
end
def self.send_summary_to_user(user_id)
user = User.find_by(id: user_id)
return false unless user && user.telegram_bot_token && user.telegram_chat_id
summary = generate_summary_for_user(user_id)
return false unless summary
# Send the message via Telegram
begin
TelegramPoller.instance.send_telegram_message(
user.telegram_bot_token,
user.telegram_chat_id,
summary
)
# Update the last run time and calculate the next run time
now = Time.now
next_run = calculate_next_run_time(user, now)
# Update the user's tracking fields
user.update(
task_summary_last_run: now,
task_summary_next_run: next_run
)
true
rescue StandardError => e
puts "Error sending task summary to user #{user_id}: #{e.message}"
false
end
end
# Calculate when the next task summary should run based on frequency
def self.calculate_next_run_time(user, from_time = Time.now)
case user.task_summary_frequency
when 'daily'
# Next day at 7 AM
from_time.tomorrow.change(hour: 7, min: 0, sec: 0)
when 'weekdays'
# If it's Friday, next is Monday, otherwise next day (if it's a weekday)
days_until_next_weekday =
if from_time.wday == 5 # Friday
3 # Next Monday
elsif from_time.wday == 6 # Saturday
2 # Next Monday
else
1 # Next day
end
from_time.advance(days: days_until_next_weekday).change(hour: 7, min: 0, sec: 0)
when 'weekly'
# Next week same day, or next Monday if we're being specific
from_time.advance(days: 7).change(hour: 7, min: 0, sec: 0)
when '1h'
from_time + 1.hour
when '2h'
from_time + 2.hours
when '4h'
from_time + 4.hours
when '8h'
from_time + 8.hours
when '12h'
from_time + 12.hours
else
# Default to daily at 7 AM
from_time.tomorrow.change(hour: 7, min: 0, sec: 0)
end
end
end

View file

@ -1,71 +0,0 @@
require 'net/http'
require 'uri'
require 'nokogiri'
class UrlTitleExtractorService
MAX_BYTES = 50_000
TIMEOUT = 5
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
def self.url?(text)
url_regex = %r{^(https?://)?[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(/.*)?$}i
text.strip.match?(url_regex)
end
def self.extract_title(url)
url = "http://#{url}" unless url.start_with?('http://') || url.start_with?('https://')
begin
uri = URI.parse(url)
http = Net::HTTP.new(uri.host, uri.port)
http.open_timeout = TIMEOUT
http.read_timeout = TIMEOUT
if uri.scheme == 'https'
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
end
request = Net::HTTP::Get.new(uri.request_uri)
request['User-Agent'] = USER_AGENT
request['Accept'] = 'text/html'
request['Range'] = "bytes=0-#{MAX_BYTES}"
response = http.request(request)
if response.is_a?(Net::HTTPRedirection)
redirect_url = response['location']
return extract_title(redirect_url)
end
if response.code.to_i.between?(200, 299) && response.body
html = Nokogiri::HTML(response.body)
title = html.at_css('title')&.text&.strip
return title if title && !title.empty?
og_title = html.at_css('meta[property="og:title"]')&.attributes&.[]('content')&.value&.strip
return og_title if og_title && !og_title.empty?
twitter_title = html.at_css('meta[name="twitter:title"]')&.attributes&.[]('content')&.value&.strip
return twitter_title if twitter_title && !twitter_title.empty?
end
nil
rescue StandardError => e
puts "Error extracting title from URL: #{e.message}"
nil
end
end
def self.extract_title_from_text(text)
text.split(/\s+/).each do |word|
if url?(word)
title = extract_title(word)
return { url: word, title: title } if title
end
end
nil
end
end

View file

@ -1,12 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<base href="/">
<title>Tududi</title>
</head>
<body>
<div id="root"></div>
</body>
</html>

View file

@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<base href="/">
<title>Tududi</title>
</head>
<body>
<%= yield %>
</body>
</html>

4
backend/.env.test Normal file
View file

@ -0,0 +1,4 @@
# Test environment configuration
NODE_ENV=test
TUDUDI_SESSION_SECRET=test-secret-key-for-testing
TUDUDI_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080

8
backend/.sequelizerc Normal file
View file

@ -0,0 +1,8 @@
const path = require('path');
module.exports = {
'config': path.resolve('config', 'database.js'),
'models-path': path.resolve('models'),
'seeders-path': path.resolve('seeders'),
'migrations-path': path.resolve('migrations')
};

167
backend/app.js Normal file
View file

@ -0,0 +1,167 @@
require('dotenv').config();
const express = require('express');
const path = require('path');
const cors = require('cors');
const helmet = require('helmet');
const compression = require('compression');
const morgan = require('morgan');
const session = require('express-session');
const SequelizeStore = require('connect-session-sequelize')(session.Store);
const { sequelize } = require('./models');
const { initializeTelegramPolling } = require('./services/telegramInitializer');
const TaskScheduler = require('./services/taskScheduler');
const app = express();
// Session store
const sessionStore = new SequelizeStore({
db: sequelize,
});
// Middlewares
app.use(helmet());
app.use(compression());
app.use(morgan('combined'));
// CORS configuration
const allowedOrigins = process.env.TUDUDI_ALLOWED_ORIGINS
? process.env.TUDUDI_ALLOWED_ORIGINS.split(',').map(origin => origin.trim())
: ['http://localhost:8080', 'http://localhost:9292', 'http://127.0.0.1:8080', 'http://127.0.0.1:9292'];
app.use(cors({
origin: allowedOrigins,
credentials: true,
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Authorization', 'Content-Type', 'Accept', 'X-Requested-With'],
exposedHeaders: ['Content-Type'],
maxAge: 1728000
}));
// Body parsing
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Session configuration
const secureFlag = process.env.NODE_ENV === 'production' && process.env.TUDUDI_INTERNAL_SSL_ENABLED === 'true';
app.use(session({
secret: process.env.TUDUDI_SESSION_SECRET || require('crypto').randomBytes(64).toString('hex'),
store: sessionStore,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: secureFlag,
maxAge: 2592000000, // 30 days
sameSite: secureFlag ? 'none' : 'lax'
}
}));
// Static files
if (process.env.NODE_ENV === 'production') {
app.use(express.static(path.join(__dirname, 'dist')));
} else {
app.use(express.static('public'));
}
// Serve locales
if (process.env.NODE_ENV === 'production') {
app.use('/locales', express.static(path.join(__dirname, 'dist/locales')));
} else {
app.use('/locales', express.static(path.join(__dirname, '../public/locales')));
}
// Authentication middleware
const { requireAuth } = require('./middleware/auth');
// Routes
app.use('/api', require('./routes/auth'));
app.use('/api', requireAuth, require('./routes/tasks'));
app.use('/api', requireAuth, require('./routes/projects'));
app.use('/api', requireAuth, require('./routes/areas'));
app.use('/api', requireAuth, require('./routes/notes'));
app.use('/api', requireAuth, require('./routes/tags'));
app.use('/api', requireAuth, require('./routes/users'));
app.use('/api', requireAuth, require('./routes/inbox'));
app.use('/api', requireAuth, require('./routes/url'));
app.use('/api', requireAuth, require('./routes/telegram'));
app.use('/api', requireAuth, require('./routes/quotes'));
// Health check
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// SPA fallback
app.get('*', (req, res) => {
if (!req.path.startsWith('/api/') && !req.path.match(/\.(js|css|png|jpg|jpeg|gif|ico|svg)$/)) {
if (process.env.NODE_ENV === 'production') {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
} else {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
}
} else {
res.status(404).json({ error: 'Not Found', message: 'The requested resource could not be found.' });
}
});
// Error handling
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Internal Server Error', message: err.message });
});
const PORT = process.env.PORT || 3002;
// Initialize database and start server
async function startServer() {
try {
// Create session store table
await sessionStore.sync();
// Sync database
await sequelize.sync();
// Auto-create user if not exists
if (process.env.TUDUDI_USER_EMAIL && process.env.TUDUDI_USER_PASSWORD) {
const { User } = require('./models');
const bcrypt = require('bcrypt');
const [user, created] = await User.findOrCreate({
where: { email: process.env.TUDUDI_USER_EMAIL },
defaults: {
email: process.env.TUDUDI_USER_EMAIL,
password: await bcrypt.hash(process.env.TUDUDI_USER_PASSWORD, 10)
}
});
if (created) {
console.log('Default user created:', user.email);
}
}
// Initialize Telegram polling after database is ready
await initializeTelegramPolling();
// Initialize task scheduler
const scheduler = TaskScheduler.getInstance();
await scheduler.initialize();
const server = app.listen(PORT, '0.0.0.0', () => {
console.log(`Server running on port ${PORT}`);
console.log(`Server listening on http://localhost:${PORT}`);
});
server.on('error', (err) => {
console.error('Server error:', err);
});
} catch (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
}
if (require.main === module) {
startServer();
}
module.exports = app;

View file

@ -0,0 +1,42 @@
require('dotenv').config();
const path = require('path');
const dbPath = process.env.DATABASE_URL
? process.env.DATABASE_URL.replace('sqlite:///', '')
: path.join(__dirname, '..', 'db');
module.exports = {
development: {
dialect: 'sqlite',
storage: path.join(dbPath, 'development.sqlite3'),
logging: console.log,
define: {
timestamps: true,
underscored: true,
createdAt: 'created_at',
updatedAt: 'updated_at'
}
},
test: {
dialect: 'sqlite',
storage: path.join(dbPath, 'test.sqlite3'),
logging: false,
define: {
timestamps: true,
underscored: true,
createdAt: 'created_at',
updatedAt: 'updated_at'
}
},
production: {
dialect: 'sqlite',
storage: path.join(dbPath, 'production.sqlite3'),
logging: false,
define: {
timestamps: true,
underscored: true,
createdAt: 'created_at',
updatedAt: 'updated_at'
}
}
};

25
backend/jest.config.js Normal file
View file

@ -0,0 +1,25 @@
module.exports = {
testEnvironment: 'node',
setupFilesAfterEnv: ['<rootDir>/tests/helpers/setup.js'],
testMatch: [
'<rootDir>/tests/**/*.test.js',
'<rootDir>/tests/**/*.spec.js'
],
maxWorkers: 1,
collectCoverageFrom: [
'routes/**/*.js',
'models/**/*.js',
'middleware/**/*.js',
'services/**/*.js',
'!models/index.js',
'!**/*.test.js',
'!**/*.spec.js'
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
verbose: true,
forceExit: true,
clearMocks: true,
resetMocks: true,
restoreMocks: true
};

View file

@ -0,0 +1,30 @@
const { User } = require('../models');
const requireAuth = async (req, res, next) => {
try {
// Skip authentication for health check, login routes, and current_user
if (req.path === '/api/health' || req.path === '/api/login' || req.path === '/api/current_user') {
return next();
}
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const user = await User.findByPk(req.session.userId);
if (!user) {
req.session.destroy();
return res.status(401).json({ error: 'User not found' });
}
req.currentUser = user;
next();
} catch (error) {
console.error('Authentication error:', error);
res.status(500).json({ error: 'Authentication error' });
}
};
module.exports = {
requireAuth
};

View file

@ -0,0 +1,61 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('users', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
email: {
type: Sequelize.STRING,
allowNull: false,
unique: true
},
password: {
type: Sequelize.STRING,
allowNull: false
},
telegram_bot_token: {
type: Sequelize.STRING,
allowNull: true
},
telegram_chat_id: {
type: Sequelize.STRING,
allowNull: true
},
task_summary_enabled: {
type: Sequelize.BOOLEAN,
defaultValue: false
},
task_summary_frequency: {
type: Sequelize.STRING,
defaultValue: 'daily'
},
task_summary_last_run: {
type: Sequelize.DATE,
allowNull: true
},
task_summary_next_run: {
type: Sequelize.DATE,
allowNull: true
},
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
}
});
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('users');
}
};

36
backend/models/area.js Normal file
View file

@ -0,0 +1,36 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const Area = sequelize.define('Area', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
name: {
type: DataTypes.STRING,
allowNull: false
},
description: {
type: DataTypes.STRING,
allowNull: true
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id'
}
}
}, {
tableName: 'areas',
indexes: [
{
fields: ['user_id']
}
]
});
return Area;
};

View file

@ -0,0 +1,42 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const InboxItem = sequelize.define('InboxItem', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
content: {
type: DataTypes.STRING,
allowNull: false
},
status: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'added'
},
source: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'tududi'
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id'
}
}
}, {
tableName: 'inbox_items',
indexes: [
{
fields: ['user_id']
}
]
});
return InboxItem;
};

93
backend/models/index.js Normal file
View file

@ -0,0 +1,93 @@
const { Sequelize } = require('sequelize');
const path = require('path');
// Database configuration
let dbConfig;
if (process.env.NODE_ENV === 'test') {
// Use in-memory database for tests
dbConfig = {
dialect: 'sqlite',
storage: ':memory:',
logging: false,
define: {
timestamps: true,
underscored: true,
createdAt: 'created_at',
updatedAt: 'updated_at'
}
};
} else {
const dbPath = process.env.DATABASE_URL
? process.env.DATABASE_URL.replace('sqlite:///', '')
: path.join(__dirname, '../db', process.env.NODE_ENV === 'production' ? 'production.sqlite3' : 'development.sqlite3');
dbConfig = {
dialect: 'sqlite',
storage: dbPath,
logging: process.env.NODE_ENV === 'development' ? console.log : false,
define: {
timestamps: true,
underscored: true,
createdAt: 'created_at',
updatedAt: 'updated_at'
}
};
}
const sequelize = new Sequelize(dbConfig);
// Import models
const User = require('./user')(sequelize);
const Area = require('./area')(sequelize);
const Project = require('./project')(sequelize);
const Task = require('./task')(sequelize);
const Tag = require('./tag')(sequelize);
const Note = require('./note')(sequelize);
const InboxItem = require('./inbox_item')(sequelize);
// Define associations
User.hasMany(Area, { foreignKey: 'user_id' });
Area.belongsTo(User, { foreignKey: 'user_id' });
User.hasMany(Project, { foreignKey: 'user_id' });
Project.belongsTo(User, { foreignKey: 'user_id' });
Project.belongsTo(Area, { foreignKey: 'area_id', allowNull: true });
Area.hasMany(Project, { foreignKey: 'area_id' });
User.hasMany(Task, { foreignKey: 'user_id' });
Task.belongsTo(User, { foreignKey: 'user_id' });
Task.belongsTo(Project, { foreignKey: 'project_id', allowNull: true });
Project.hasMany(Task, { foreignKey: 'project_id' });
User.hasMany(Tag, { foreignKey: 'user_id' });
Tag.belongsTo(User, { foreignKey: 'user_id' });
User.hasMany(Note, { foreignKey: 'user_id' });
Note.belongsTo(User, { foreignKey: 'user_id' });
Note.belongsTo(Project, { foreignKey: 'project_id', allowNull: true });
Project.hasMany(Note, { foreignKey: 'project_id' });
User.hasMany(InboxItem, { foreignKey: 'user_id' });
InboxItem.belongsTo(User, { foreignKey: 'user_id' });
// Many-to-many associations
Task.belongsToMany(Tag, { through: 'tasks_tags', foreignKey: 'task_id', otherKey: 'tag_id' });
Tag.belongsToMany(Task, { through: 'tasks_tags', foreignKey: 'tag_id', otherKey: 'task_id' });
Note.belongsToMany(Tag, { through: 'notes_tags', foreignKey: 'note_id', otherKey: 'tag_id' });
Tag.belongsToMany(Note, { through: 'notes_tags', foreignKey: 'tag_id', otherKey: 'note_id' });
Project.belongsToMany(Tag, { through: 'projects_tags', foreignKey: 'project_id', otherKey: 'tag_id' });
Tag.belongsToMany(Project, { through: 'projects_tags', foreignKey: 'tag_id', otherKey: 'project_id' });
module.exports = {
sequelize,
User,
Area,
Project,
Task,
Tag,
Note,
InboxItem
};

47
backend/models/note.js Normal file
View file

@ -0,0 +1,47 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const Note = sequelize.define('Note', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
title: {
type: DataTypes.STRING,
allowNull: true
},
content: {
type: DataTypes.TEXT,
allowNull: true
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id'
}
},
project_id: {
type: DataTypes.INTEGER,
allowNull: true,
references: {
model: 'projects',
key: 'id'
}
}
}, {
tableName: 'notes',
indexes: [
{
fields: ['user_id']
},
{
fields: ['project_id']
}
]
});
return Note;
};

69
backend/models/project.js Normal file
View file

@ -0,0 +1,69 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const Project = sequelize.define('Project', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
name: {
type: DataTypes.STRING,
allowNull: false
},
description: {
type: DataTypes.TEXT,
allowNull: true
},
active: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
pin_to_sidebar: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
priority: {
type: DataTypes.INTEGER,
allowNull: true,
validate: {
min: 0,
max: 2
}
},
due_date_at: {
type: DataTypes.DATE,
allowNull: true
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id'
}
},
area_id: {
type: DataTypes.INTEGER,
allowNull: true,
references: {
model: 'areas',
key: 'id'
}
}
}, {
tableName: 'projects',
indexes: [
{
fields: ['user_id']
},
{
fields: ['area_id']
}
]
});
return Project;
};

32
backend/models/tag.js Normal file
View file

@ -0,0 +1,32 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const Tag = sequelize.define('Tag', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
name: {
type: DataTypes.STRING,
allowNull: false
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id'
}
}
}, {
tableName: 'tags',
indexes: [
{
fields: ['user_id']
}
]
});
return Tag;
};

127
backend/models/task.js Normal file
View file

@ -0,0 +1,127 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const Task = sequelize.define('Task', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
name: {
type: DataTypes.STRING,
allowNull: false
},
description: {
type: DataTypes.TEXT,
allowNull: true
},
due_date: {
type: DataTypes.DATE,
allowNull: true
},
today: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
priority: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 0,
validate: {
min: 0,
max: 2
}
},
status: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
validate: {
min: 0,
max: 4
}
},
note: {
type: DataTypes.TEXT,
allowNull: true
},
recurrence_type: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'none'
},
recurrence_interval: {
type: DataTypes.INTEGER,
allowNull: true
},
recurrence_end_date: {
type: DataTypes.DATE,
allowNull: true
},
last_generated_date: {
type: DataTypes.DATE,
allowNull: true
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id'
}
},
project_id: {
type: DataTypes.INTEGER,
allowNull: true,
references: {
model: 'projects',
key: 'id'
}
}
}, {
tableName: 'tasks',
indexes: [
{
fields: ['user_id']
},
{
fields: ['project_id']
},
{
fields: ['recurrence_type']
},
{
fields: ['last_generated_date']
}
]
});
// Define enum constants
Task.PRIORITY = {
LOW: 0,
MEDIUM: 1,
HIGH: 2
};
Task.STATUS = {
NOT_STARTED: 0,
IN_PROGRESS: 1,
DONE: 2,
ARCHIVED: 3,
WAITING: 4
};
// Instance methods for priority and status
Task.prototype.getPriorityName = function() {
const priorities = ['low', 'medium', 'high'];
return priorities[this.priority] || 'low';
};
Task.prototype.getStatusName = function() {
const statuses = ['not_started', 'in_progress', 'done', 'archived', 'waiting'];
return statuses[this.status] || 'not_started';
};
return Task;
};

100
backend/models/user.js Normal file
View file

@ -0,0 +1,100 @@
const { DataTypes } = require('sequelize');
const bcrypt = require('bcrypt');
module.exports = (sequelize) => {
const User = sequelize.define('User', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
name: {
type: DataTypes.STRING,
allowNull: true
},
email: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
validate: {
isEmail: true
}
},
password_digest: {
type: DataTypes.STRING,
allowNull: false,
field: 'password_digest'
},
appearance: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'light',
validate: {
isIn: [['light', 'dark']]
}
},
language: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'en'
},
timezone: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'UTC'
},
avatar_image: {
type: DataTypes.STRING,
allowNull: true
},
telegram_bot_token: {
type: DataTypes.STRING,
allowNull: true
},
telegram_chat_id: {
type: DataTypes.STRING,
allowNull: true
},
task_summary_enabled: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
task_summary_frequency: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: 'daily',
validate: {
isIn: [['daily', 'weekdays', 'weekly', '1h', '2h', '4h', '8h', '12h']]
}
},
task_summary_last_run: {
type: DataTypes.DATE,
allowNull: true
},
task_summary_next_run: {
type: DataTypes.DATE,
allowNull: true
}
}, {
tableName: 'users',
hooks: {
beforeValidate: async (user) => {
if (user.password) {
user.password_digest = await bcrypt.hash(user.password, 10);
}
}
}
});
// Virtual field for password
User.prototype.setPassword = async function(password) {
this.password_digest = await bcrypt.hash(password, 10);
};
User.prototype.checkPassword = async function(password) {
return await bcrypt.compare(password, this.password_digest);
};
return User;
};

8045
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

52
backend/package.json Normal file
View file

@ -0,0 +1,52 @@
{
"name": "backend",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "node app.js",
"dev": "nodemon app.js",
"test": "cross-env NODE_ENV=test jest",
"test:watch": "cross-env NODE_ENV=test jest --watch",
"test:coverage": "cross-env NODE_ENV=test jest --coverage",
"test:unit": "cross-env NODE_ENV=test jest tests/unit",
"test:integration": "cross-env NODE_ENV=test jest tests/integration",
"db:init": "node scripts/db-init.js",
"db:sync": "node scripts/db-sync.js",
"db:migrate": "node scripts/db-migrate.js",
"db:reset": "node scripts/db-reset.js",
"db:status": "node scripts/db-status.js",
"user:create": "node scripts/user-create.js",
"migration:create": "node scripts/migration-create.js",
"migration:run": "npx sequelize-cli db:migrate",
"migration:undo": "npx sequelize-cli db:migrate:undo",
"migration:undo:all": "npx sequelize-cli db:migrate:undo:all",
"migration:status": "npx sequelize-cli db:migrate:status"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"bcrypt": "^6.0.0",
"compression": "^1.8.0",
"connect-session-sequelize": "^7.1.7",
"cors": "^2.8.5",
"dotenv": "^16.5.0",
"express": "^4.18.2",
"express-session": "^1.18.1",
"helmet": "^8.1.0",
"js-yaml": "^4.1.0",
"morgan": "^1.10.0",
"multer": "^2.0.1",
"node-cron": "^4.1.0",
"sequelize": "^6.37.7",
"sqlite3": "^5.1.7"
},
"devDependencies": {
"cross-env": "^7.0.3",
"jest": "^30.0.0",
"nodemon": "^3.0.1",
"sequelize-cli": "^6.6.2",
"supertest": "^7.1.1"
}
}

130
backend/routes/areas.js Normal file
View file

@ -0,0 +1,130 @@
const express = require('express');
const { Area } = require('../models');
const router = express.Router();
// GET /api/areas
router.get('/areas', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const areas = await Area.findAll({
where: { user_id: req.session.userId },
order: [['name', 'ASC']]
});
res.json(areas);
} catch (error) {
console.error('Error fetching areas:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// GET /api/areas/:id
router.get('/areas/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const area = await Area.findOne({
where: { id: req.params.id, user_id: req.session.userId }
});
if (!area) {
return res.status(404).json({ error: "Area not found or doesn't belong to the current user." });
}
res.json(area);
} catch (error) {
console.error('Error fetching area:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// POST /api/areas
router.post('/areas', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const { name, description } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({ error: 'Area name is required.' });
}
const area = await Area.create({
name: name.trim(),
description: description || '',
user_id: req.session.userId
});
res.status(201).json(area);
} catch (error) {
console.error('Error creating area:', error);
res.status(400).json({
error: 'There was a problem creating the area.',
details: error.errors ? error.errors.map(e => e.message) : [error.message]
});
}
});
// PATCH /api/areas/:id
router.patch('/areas/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const area = await Area.findOne({
where: { id: req.params.id, user_id: req.session.userId }
});
if (!area) {
return res.status(404).json({ error: 'Area not found.' });
}
const { name, description } = req.body;
const updateData = {};
if (name !== undefined) updateData.name = name;
if (description !== undefined) updateData.description = description;
await area.update(updateData);
res.json(area);
} catch (error) {
console.error('Error updating area:', error);
res.status(400).json({
error: 'There was a problem updating the area.',
details: error.errors ? error.errors.map(e => e.message) : [error.message]
});
}
});
// DELETE /api/areas/:id
router.delete('/areas/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const area = await Area.findOne({
where: { id: req.params.id, user_id: req.session.userId }
});
if (!area) {
return res.status(404).json({ error: 'Area not found.' });
}
await area.destroy();
res.status(204).send();
} catch (error) {
console.error('Error deleting area:', error);
res.status(400).json({ error: 'There was a problem deleting the area.' });
}
});
module.exports = router;

78
backend/routes/auth.js Normal file
View file

@ -0,0 +1,78 @@
const express = require('express');
const { User } = require('../models');
const router = express.Router();
// Get current user
router.get('/current_user', async (req, res) => {
try {
if (req.session && req.session.userId) {
const user = await User.findByPk(req.session.userId);
if (user) {
return res.json({
user: {
id: user.id,
email: user.email,
language: user.language,
appearance: user.appearance,
timezone: user.timezone
}
});
}
}
res.json({ user: null });
} catch (error) {
console.error('Error fetching current user:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Login
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Invalid login parameters.' });
}
const user = await User.findOne({ where: { email } });
if (!user) {
return res.status(401).json({ errors: ['Invalid credentials'] });
}
const isValidPassword = await user.checkPassword(password);
if (!isValidPassword) {
return res.status(401).json({ errors: ['Invalid credentials'] });
}
req.session.userId = user.id;
res.json({
user: {
id: user.id,
email: user.email,
language: user.language,
appearance: user.appearance,
timezone: user.timezone
}
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Logout
router.get('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
console.error('Logout error:', err);
return res.status(500).json({ error: 'Could not log out' });
}
res.json({ message: 'Logged out successfully' });
});
});
module.exports = router;

157
backend/routes/inbox.js Normal file
View file

@ -0,0 +1,157 @@
const express = require('express');
const { InboxItem } = require('../models');
const router = express.Router();
// GET /api/inbox
router.get('/inbox', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const items = await InboxItem.findAll({
where: {
user_id: req.session.userId,
status: 'added'
},
order: [['created_at', 'DESC']]
});
res.json(items);
} catch (error) {
console.error('Error fetching inbox items:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// POST /api/inbox
router.post('/inbox', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const { content, source } = req.body;
if (!content || !content.trim()) {
return res.status(400).json({ error: 'Content is required' });
}
const item = await InboxItem.create({
content: content.trim(),
source: source || 'tududi',
user_id: req.session.userId
});
res.status(201).json(item);
} catch (error) {
console.error('Error creating inbox item:', error);
res.status(400).json({
error: 'There was a problem creating the inbox item.',
details: error.errors ? error.errors.map(e => e.message) : [error.message]
});
}
});
// GET /api/inbox/:id
router.get('/inbox/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const item = await InboxItem.findOne({
where: { id: req.params.id, user_id: req.session.userId }
});
if (!item) {
return res.status(404).json({ error: 'Inbox item not found.' });
}
res.json(item);
} catch (error) {
console.error('Error fetching inbox item:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// PATCH /api/inbox/:id
router.patch('/inbox/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const item = await InboxItem.findOne({
where: { id: req.params.id, user_id: req.session.userId }
});
if (!item) {
return res.status(404).json({ error: 'Inbox item not found.' });
}
const { content, status } = req.body;
const updateData = {};
if (content !== undefined) updateData.content = content;
if (status !== undefined) updateData.status = status;
await item.update(updateData);
res.json(item);
} catch (error) {
console.error('Error updating inbox item:', error);
res.status(400).json({
error: 'There was a problem updating the inbox item.',
details: error.errors ? error.errors.map(e => e.message) : [error.message]
});
}
});
// DELETE /api/inbox/:id
router.delete('/inbox/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const item = await InboxItem.findOne({
where: { id: req.params.id, user_id: req.session.userId }
});
if (!item) {
return res.status(404).json({ error: 'Inbox item not found.' });
}
// Mark as deleted instead of actual deletion
await item.update({ status: 'deleted' });
res.json({ message: 'Inbox item successfully deleted' });
} catch (error) {
console.error('Error deleting inbox item:', error);
res.status(400).json({ error: 'There was a problem deleting the inbox item.' });
}
});
// PATCH /api/inbox/:id/process
router.patch('/inbox/:id/process', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const item = await InboxItem.findOne({
where: { id: req.params.id, user_id: req.session.userId }
});
if (!item) {
return res.status(404).json({ error: 'Inbox item not found.' });
}
await item.update({ status: 'processed' });
res.json(item);
} catch (error) {
console.error('Error processing inbox item:', error);
res.status(400).json({ error: 'There was a problem processing the inbox item.' });
}
});
module.exports = router;

242
backend/routes/notes.js Normal file
View file

@ -0,0 +1,242 @@
const express = require('express');
const { Note, Tag, Project, sequelize } = require('../models');
const { Op } = require('sequelize');
const router = express.Router();
// Helper function to update note tags
async function updateNoteTags(note, tagsArray, userId) {
if (!tagsArray || tagsArray.length === 0) {
await note.setTags([]);
return;
}
try {
const tagNames = tagsArray.filter((name, index, arr) => arr.indexOf(name) === index); // unique
const tags = await Promise.all(
tagNames.map(async (name) => {
const [tag] = await Tag.findOrCreate({
where: { name, user_id: userId },
defaults: { name, user_id: userId }
});
return tag;
})
);
await note.setTags(tags);
} catch (error) {
console.error('Failed to update tags:', error.message);
}
}
// GET /api/notes
router.get('/notes', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const orderBy = req.query.order_by || 'title:asc';
const [orderColumn, orderDirection] = orderBy.split(':');
let whereClause = { user_id: req.session.userId };
let includeClause = [
{ model: Tag, through: { attributes: [] } },
{ model: Project, required: false, attributes: ['id', 'name'] }
];
// Filter by tag
if (req.query.tag) {
includeClause[0].where = { name: req.query.tag };
includeClause[0].required = true;
}
const notes = await Note.findAll({
where: whereClause,
include: includeClause,
order: [[orderColumn, orderDirection.toUpperCase()]],
distinct: true
});
res.json(notes);
} catch (error) {
console.error('Error fetching notes:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// GET /api/note/:id
router.get('/note/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const note = await Note.findOne({
where: { id: req.params.id, user_id: req.session.userId },
include: [
{ model: Tag, through: { attributes: [] } },
{ model: Project, required: false, attributes: ['id', 'name'] }
]
});
if (!note) {
return res.status(404).json({ error: 'Note not found.' });
}
res.json(note);
} catch (error) {
console.error('Error fetching note:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// POST /api/note
router.post('/note', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const { title, content, project_id, tags } = req.body;
const noteAttributes = {
title,
content,
user_id: req.session.userId
};
// Handle project assignment
if (project_id && project_id.toString().trim()) {
const project = await Project.findOne({
where: { id: project_id, user_id: req.session.userId }
});
if (!project) {
return res.status(400).json({ error: 'Invalid project.' });
}
noteAttributes.project_id = project_id;
}
const note = await Note.create(noteAttributes);
// Handle tags - can be array of strings or array of objects with name property
let tagNames = [];
if (Array.isArray(tags)) {
if (tags.every(t => typeof t === 'string')) {
tagNames = tags;
} else if (tags.every(t => typeof t === 'object' && t.name)) {
tagNames = tags.map(t => t.name);
}
}
await updateNoteTags(note, tagNames, req.session.userId);
// Reload note with associations
const noteWithAssociations = await Note.findByPk(note.id, {
include: [
{ model: Tag, through: { attributes: [] } },
{ model: Project, required: false, attributes: ['id', 'name'] }
]
});
res.status(201).json(noteWithAssociations);
} catch (error) {
console.error('Error creating note:', error);
res.status(400).json({
error: 'There was a problem creating the note.',
details: error.errors ? error.errors.map(e => e.message) : [error.message]
});
}
});
// PATCH /api/note/:id
router.patch('/note/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const note = await Note.findOne({
where: { id: req.params.id, user_id: req.session.userId }
});
if (!note) {
return res.status(404).json({ error: 'Note not found.' });
}
const { title, content, project_id, tags } = req.body;
const updateData = {};
if (title !== undefined) updateData.title = title;
if (content !== undefined) updateData.content = content;
// Handle project assignment
if (project_id !== undefined) {
if (project_id && project_id.toString().trim()) {
const project = await Project.findOne({
where: { id: project_id, user_id: req.session.userId }
});
if (!project) {
return res.status(400).json({ error: 'Invalid project.' });
}
updateData.project_id = project_id;
} else {
updateData.project_id = null;
}
}
await note.update(updateData);
// Handle tags if provided
if (tags !== undefined) {
let tagNames = [];
if (Array.isArray(tags)) {
if (tags.every(t => typeof t === 'string')) {
tagNames = tags;
} else if (tags.every(t => typeof t === 'object' && t.name)) {
tagNames = tags.map(t => t.name);
}
}
await updateNoteTags(note, tagNames, req.session.userId);
}
// Reload note with associations
const noteWithAssociations = await Note.findByPk(note.id, {
include: [
{ model: Tag, through: { attributes: [] } },
{ model: Project, required: false, attributes: ['id', 'name'] }
]
});
res.json(noteWithAssociations);
} catch (error) {
console.error('Error updating note:', error);
res.status(400).json({
error: 'There was a problem updating the note.',
details: error.errors ? error.errors.map(e => e.message) : [error.message]
});
}
});
// DELETE /api/note/:id
router.delete('/note/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const note = await Note.findOne({
where: { id: req.params.id, user_id: req.session.userId }
});
if (!note) {
return res.status(404).json({ error: 'Note not found.' });
}
await note.destroy();
res.json({ message: 'Note deleted successfully.' });
} catch (error) {
console.error('Error deleting note:', error);
res.status(400).json({ error: 'There was a problem deleting the note.' });
}
});
module.exports = router;

275
backend/routes/projects.js Normal file
View file

@ -0,0 +1,275 @@
const express = require('express');
const { Project, Task, Tag, Area, sequelize } = require('../models');
const { Op } = require('sequelize');
const router = express.Router();
// Helper function to update project tags
async function updateProjectTags(project, tagsData, userId) {
if (!tagsData) return;
const tagNames = tagsData
.map(tag => tag.name)
.filter(name => name && name.trim())
.filter((name, index, arr) => arr.indexOf(name) === index); // unique
if (tagNames.length === 0) {
await project.setTags([]);
return;
}
// Find existing tags
const existingTags = await Tag.findAll({
where: { user_id: userId, name: tagNames }
});
// Create new tags
const existingTagNames = existingTags.map(tag => tag.name);
const newTagNames = tagNames.filter(name => !existingTagNames.includes(name));
const createdTags = await Promise.all(
newTagNames.map(name => Tag.create({ name, user_id: userId }))
);
// Set all tags to project
const allTags = [...existingTags, ...createdTags];
await project.setTags(allTags);
}
// GET /api/projects
router.get('/projects', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const { active, pin_to_sidebar, area_id } = req.query;
let whereClause = { user_id: req.session.userId };
// Filter by active status
if (active === 'true') {
whereClause.active = true;
} else if (active === 'false') {
whereClause.active = false;
}
// Filter by pinned status
if (pin_to_sidebar === 'true') {
whereClause.pin_to_sidebar = true;
} else if (pin_to_sidebar === 'false') {
whereClause.pin_to_sidebar = false;
}
// Filter by area
if (area_id && area_id !== '') {
whereClause.area_id = area_id;
}
const projects = await Project.findAll({
where: whereClause,
include: [
{
model: Task,
required: false,
attributes: ['id', 'status']
},
{
model: Area,
required: false,
attributes: ['name']
},
{
model: Tag,
attributes: ['id', 'name'],
through: { attributes: [] }
}
],
order: [['name', 'ASC']]
});
// Calculate task status counts for each project
const taskStatusCounts = {};
projects.forEach(project => {
const tasks = project.Tasks || [];
taskStatusCounts[project.id] = {
total: tasks.length,
done: tasks.filter(t => t.status === 2).length,
in_progress: tasks.filter(t => t.status === 1).length,
not_started: tasks.filter(t => t.status === 0).length
};
});
// Group projects by area
const groupedProjects = {};
projects.forEach(project => {
const areaName = project.Area ? project.Area.name : 'No Area';
if (!groupedProjects[areaName]) {
groupedProjects[areaName] = [];
}
groupedProjects[areaName].push(project);
});
res.json({
projects: projects.map(project => ({
...project.toJSON(),
due_date_at: project.due_date_at ? project.due_date_at.toISOString() : null
})),
task_status_counts: taskStatusCounts,
grouped_projects: groupedProjects
});
} catch (error) {
console.error('Error fetching projects:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// GET /api/project/:id
router.get('/project/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const project = await Project.findOne({
where: { id: req.params.id, user_id: req.session.userId },
include: [
{ model: Task, required: false },
{ model: Area, required: false, attributes: ['id', 'name'] },
{ model: Tag, attributes: ['id', 'name'], through: { attributes: [] } }
]
});
if (!project) {
return res.status(404).json({ error: 'Project not found' });
}
res.json({
...project.toJSON(),
due_date_at: project.due_date_at ? project.due_date_at.toISOString() : null
});
} catch (error) {
console.error('Error fetching project:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// POST /api/project
router.post('/project', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const { name, description, area_id, priority, due_date_at, tags } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({ error: 'Project name is required' });
}
const projectData = {
name: name.trim(),
description: description || '',
area_id: area_id || null,
active: true,
pin_to_sidebar: false,
priority: priority || null,
due_date_at: due_date_at || null,
user_id: req.session.userId
};
const project = await Project.create(projectData);
await updateProjectTags(project, tags, req.session.userId);
// Reload project with associations
const projectWithAssociations = await Project.findByPk(project.id, {
include: [
{ model: Tag, attributes: ['id', 'name'], through: { attributes: [] } }
]
});
res.status(201).json({
...projectWithAssociations.toJSON(),
due_date_at: projectWithAssociations.due_date_at ? projectWithAssociations.due_date_at.toISOString() : null
});
} catch (error) {
console.error('Error creating project:', error);
res.status(400).json({
error: 'There was a problem creating the project.',
details: error.errors ? error.errors.map(e => e.message) : [error.message]
});
}
});
// PATCH /api/project/:id
router.patch('/project/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const project = await Project.findOne({
where: { id: req.params.id, user_id: req.session.userId }
});
if (!project) {
return res.status(404).json({ error: 'Project not found.' });
}
const { name, description, area_id, active, pin_to_sidebar, priority, due_date_at, tags } = req.body;
const updateData = {};
if (name !== undefined) updateData.name = name;
if (description !== undefined) updateData.description = description;
if (area_id !== undefined) updateData.area_id = area_id;
if (active !== undefined) updateData.active = active;
if (pin_to_sidebar !== undefined) updateData.pin_to_sidebar = pin_to_sidebar;
if (priority !== undefined) updateData.priority = priority;
if (due_date_at !== undefined) updateData.due_date_at = due_date_at;
await project.update(updateData);
await updateProjectTags(project, tags, req.session.userId);
// Reload project with associations
const projectWithAssociations = await Project.findByPk(project.id, {
include: [
{ model: Tag, attributes: ['id', 'name'], through: { attributes: [] } }
]
});
res.json({
...projectWithAssociations.toJSON(),
due_date_at: projectWithAssociations.due_date_at ? projectWithAssociations.due_date_at.toISOString() : null
});
} catch (error) {
console.error('Error updating project:', error);
res.status(400).json({
error: 'There was a problem updating the project.',
details: error.errors ? error.errors.map(e => e.message) : [error.message]
});
}
});
// DELETE /api/project/:id
router.delete('/project/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const project = await Project.findOne({
where: { id: req.params.id, user_id: req.session.userId }
});
if (!project) {
return res.status(404).json({ error: 'Project not found.' });
}
await project.destroy();
res.json({ message: 'Project successfully deleted' });
} catch (error) {
console.error('Error deleting project:', error);
res.status(400).json({ error: 'There was a problem deleting the project.' });
}
});
module.exports = router;

30
backend/routes/quotes.js Normal file
View file

@ -0,0 +1,30 @@
const express = require('express');
const router = express.Router();
const quotesService = require('../services/quotesService');
// GET /api/quotes/random - Get a random quote
router.get('/quotes/random', (req, res) => {
try {
const quote = quotesService.getRandomQuote();
res.json({ quote });
} catch (error) {
console.error('Error getting random quote:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// GET /api/quotes - Get all quotes
router.get('/quotes', (req, res) => {
try {
const quotes = quotesService.getAllQuotes();
res.json({
quotes,
count: quotesService.getQuotesCount()
});
} catch (error) {
console.error('Error getting quotes:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
module.exports = router;

132
backend/routes/tags.js Normal file
View file

@ -0,0 +1,132 @@
const express = require('express');
const { Tag } = require('../models');
const router = express.Router();
// GET /api/tags
router.get('/tags', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const tags = await Tag.findAll({
where: { user_id: req.session.userId },
attributes: ['id', 'name'],
order: [['name', 'ASC']]
});
res.json(tags);
} catch (error) {
console.error('Error fetching tags:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// GET /api/tag/:id
router.get('/tag/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const tag = await Tag.findOne({
where: { id: req.params.id, user_id: req.session.userId },
attributes: ['id', 'name']
});
if (!tag) {
return res.status(404).json({ error: 'Tag not found' });
}
res.json(tag);
} catch (error) {
console.error('Error fetching tag:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// POST /api/tag
router.post('/tag', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const { name } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({ error: 'Tag name is required' });
}
const tag = await Tag.create({
name: name.trim(),
user_id: req.session.userId
});
res.status(201).json({
id: tag.id,
name: tag.name
});
} catch (error) {
console.error('Error creating tag:', error);
res.status(400).json({ error: 'There was a problem creating the tag.' });
}
});
// PATCH /api/tag/:id
router.patch('/tag/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const tag = await Tag.findOne({
where: { id: req.params.id, user_id: req.session.userId }
});
if (!tag) {
return res.status(404).json({ error: 'Tag not found' });
}
const { name } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({ error: 'Tag name is required' });
}
await tag.update({ name: name.trim() });
res.json({
id: tag.id,
name: tag.name
});
} catch (error) {
console.error('Error updating tag:', error);
res.status(400).json({ error: 'There was a problem updating the tag.' });
}
});
// DELETE /api/tag/:id
router.delete('/tag/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const tag = await Tag.findOne({
where: { id: req.params.id, user_id: req.session.userId }
});
if (!tag) {
return res.status(404).json({ error: 'Tag not found' });
}
await tag.destroy();
res.json({ message: 'Tag successfully deleted' });
} catch (error) {
console.error('Error deleting tag:', error);
res.status(400).json({ error: 'There was a problem deleting the tag.' });
}
});
module.exports = router;

394
backend/routes/tasks.js Normal file
View file

@ -0,0 +1,394 @@
const express = require('express');
const { Task, Tag, Project, sequelize } = require('../models');
const { Op } = require('sequelize');
const router = express.Router();
// Helper function to update task tags
async function updateTaskTags(task, tagsData, userId) {
if (!tagsData) return;
const tagNames = tagsData
.map(tag => tag.name)
.filter(name => name && name.trim())
.filter((name, index, arr) => arr.indexOf(name) === index); // unique
if (tagNames.length === 0) {
await task.setTags([]);
return;
}
// Find existing tags
const existingTags = await Tag.findAll({
where: { user_id: userId, name: tagNames }
});
// Create new tags
const existingTagNames = existingTags.map(tag => tag.name);
const newTagNames = tagNames.filter(name => !existingTagNames.includes(name));
const createdTags = await Promise.all(
newTagNames.map(name => Tag.create({ name, user_id: userId }))
);
// Set all tags to task
const allTags = [...existingTags, ...createdTags];
await task.setTags(allTags);
}
// Filter tasks by parameters
async function filterTasksByParams(params, userId) {
let whereClause = { user_id: userId };
let includeClause = [
{ model: Tag, attributes: ['id', 'name'], through: { attributes: [] } },
{ model: Project, attributes: ['name'], required: false }
];
// Filter by type
switch (params.type) {
case 'today':
// Just user tasks, no additional filtering
break;
case 'upcoming':
whereClause.due_date = {
[Op.between]: [new Date(), new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)]
};
whereClause.status = { [Op.ne]: Task.STATUS.DONE };
break;
case 'next':
whereClause.due_date = null;
whereClause.project_id = null;
whereClause.status = { [Op.ne]: Task.STATUS.DONE };
break;
case 'inbox':
whereClause[Op.or] = [
{ due_date: null },
{ project_id: null }
];
whereClause.status = { [Op.ne]: Task.STATUS.DONE };
break;
case 'someday':
whereClause.due_date = null;
whereClause.status = { [Op.ne]: Task.STATUS.DONE };
break;
case 'waiting':
whereClause.status = Task.STATUS.WAITING;
break;
default:
if (params.status === 'done') {
whereClause.status = Task.STATUS.DONE;
} else {
whereClause.status = { [Op.ne]: Task.STATUS.DONE };
}
}
// Filter by tag
if (params.tag) {
includeClause[0].where = { name: params.tag };
includeClause[0].required = true;
}
let orderClause = [['created_at', 'ASC']];
// Apply ordering
if (params.order_by) {
const [orderColumn, orderDirection = 'asc'] = params.order_by.split(':');
const allowedColumns = ['created_at', 'updated_at', 'name', 'priority', 'status', 'due_date'];
if (!allowedColumns.includes(orderColumn)) {
throw new Error('Invalid order column specified.');
}
if (orderColumn === 'due_date') {
orderClause = [
[sequelize.literal('CASE WHEN due_date IS NULL THEN 1 ELSE 0 END'), 'ASC'],
['due_date', orderDirection.toUpperCase()]
];
} else {
orderClause = [[orderColumn, orderDirection.toUpperCase()]];
}
}
return await Task.findAll({
where: whereClause,
include: includeClause,
order: orderClause,
distinct: true
});
}
// Compute task metrics
async function computeTaskMetrics(userId) {
const totalOpenTasks = await Task.count({
where: { user_id: userId, status: { [Op.ne]: Task.STATUS.DONE } }
});
const oneMonthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const tasksPendingOverMonth = await Task.count({
where: {
user_id: userId,
status: { [Op.ne]: Task.STATUS.DONE },
created_at: { [Op.lt]: oneMonthAgo }
}
});
const tasksInProgress = await Task.findAll({
where: {
user_id: userId,
status: Task.STATUS.IN_PROGRESS
},
include: [
{ model: Tag, attributes: ['id', 'name'], through: { attributes: [] } },
{ model: Project, attributes: ['name'], required: false }
],
order: [['priority', 'DESC']]
});
const today = new Date();
today.setHours(23, 59, 59, 999);
const tasksDueToday = await Task.findAll({
where: {
user_id: userId,
status: { [Op.ne]: Task.STATUS.DONE },
[Op.or]: [
{ due_date: { [Op.lte]: today } },
sequelize.literal(`EXISTS (
SELECT 1 FROM projects
WHERE projects.id = Task.project_id
AND projects.due_date_at <= '${today.toISOString()}'
)`)
]
},
include: [
{ model: Tag, attributes: ['id', 'name'], through: { attributes: [] } },
{ model: Project, attributes: ['name'], required: false }
]
});
// Get suggested tasks (simplified version)
const excludedTaskIds = [
...tasksInProgress.map(t => t.id),
...tasksDueToday.map(t => t.id)
];
const suggestedTasks = await Task.findAll({
where: {
user_id: userId,
status: Task.STATUS.NOT_STARTED,
id: { [Op.notIn]: excludedTaskIds }
},
include: [
{ model: Tag, attributes: ['id', 'name'], through: { attributes: [] } },
{ model: Project, attributes: ['name'], required: false }
],
order: [['priority', 'DESC']],
limit: 10
});
return {
total_open_tasks: totalOpenTasks,
tasks_pending_over_month: tasksPendingOverMonth,
tasks_in_progress_count: tasksInProgress.length,
tasks_in_progress: tasksInProgress,
tasks_due_today: tasksDueToday,
suggested_tasks: suggestedTasks
};
}
// GET /api/tasks
router.get('/tasks', async (req, res) => {
try {
const tasks = await filterTasksByParams(req.query, req.currentUser.id);
const metrics = await computeTaskMetrics(req.currentUser.id);
res.json({
tasks: tasks.map(task => ({
...task.toJSON(),
due_date: task.due_date ? task.due_date.toISOString().split('T')[0] : null
})),
metrics: {
total_open_tasks: metrics.total_open_tasks,
tasks_pending_over_month: metrics.tasks_pending_over_month,
tasks_in_progress_count: metrics.tasks_in_progress_count,
tasks_in_progress: metrics.tasks_in_progress.map(task => ({
...task.toJSON(),
due_date: task.due_date ? task.due_date.toISOString().split('T')[0] : null
})),
tasks_due_today: metrics.tasks_due_today.map(task => ({
...task.toJSON(),
due_date: task.due_date ? task.due_date.toISOString().split('T')[0] : null
})),
suggested_tasks: metrics.suggested_tasks.map(task => ({
...task.toJSON(),
due_date: task.due_date ? task.due_date.toISOString().split('T')[0] : null
}))
}
});
} catch (error) {
console.error('Error fetching tasks:', error);
if (error.message === 'Invalid order column specified.') {
return res.status(400).json({ error: error.message });
}
res.status(500).json({ error: 'Internal server error' });
}
});
// POST /api/task
router.post('/task', async (req, res) => {
try {
const { name, priority, due_date, status, note, project_id, tags } = req.body;
// Validate required fields
if (!name || name.trim() === '') {
return res.status(400).json({ error: 'Task name is required.' });
}
const taskAttributes = {
name: name.trim(),
priority: priority || Task.PRIORITY.LOW,
due_date: due_date || null,
status: status || Task.STATUS.NOT_STARTED,
note,
user_id: req.currentUser.id
};
// Handle project assignment
if (project_id && project_id.toString().trim()) {
const project = await Project.findOne({
where: { id: project_id, user_id: req.currentUser.id }
});
if (!project) {
return res.status(400).json({ error: 'Invalid project.' });
}
taskAttributes.project_id = project_id;
}
const task = await Task.create(taskAttributes);
await updateTaskTags(task, tags, req.currentUser.id);
// Reload task with associations
const taskWithAssociations = await Task.findByPk(task.id, {
include: [
{ model: Tag, attributes: ['name'], through: { attributes: [] } },
{ model: Project, attributes: ['name'], required: false }
]
});
res.status(201).json({
...taskWithAssociations.toJSON(),
due_date: taskWithAssociations.due_date ? taskWithAssociations.due_date.toISOString().split('T')[0] : null
});
} catch (error) {
console.error('Error creating task:', error);
res.status(400).json({
error: 'There was a problem creating the task.',
details: error.errors ? error.errors.map(e => e.message) : [error.message]
});
}
});
// PATCH /api/task/:id
router.patch('/task/:id', async (req, res) => {
try {
const { name, priority, status, note, due_date, project_id, tags } = req.body;
const task = await Task.findOne({
where: { id: req.params.id, user_id: req.currentUser.id }
});
if (!task) {
return res.status(404).json({ error: 'Task not found.' });
}
const taskAttributes = {
name,
priority,
status: status || Task.STATUS.NOT_STARTED,
note,
due_date: due_date || null
};
// Handle project assignment
if (project_id && project_id.toString().trim()) {
const project = await Project.findOne({
where: { id: project_id, user_id: req.currentUser.id }
});
if (!project) {
return res.status(400).json({ error: 'Invalid project.' });
}
taskAttributes.project_id = project_id;
} else {
taskAttributes.project_id = null;
}
await task.update(taskAttributes);
await updateTaskTags(task, tags, req.currentUser.id);
// Reload task with associations
const taskWithAssociations = await Task.findByPk(task.id, {
include: [
{ model: Tag, attributes: ['name'], through: { attributes: [] } },
{ model: Project, attributes: ['name'], required: false }
]
});
res.json({
...taskWithAssociations.toJSON(),
due_date: taskWithAssociations.due_date ? taskWithAssociations.due_date.toISOString().split('T')[0] : null
});
} catch (error) {
console.error('Error updating task:', error);
res.status(400).json({
error: 'There was a problem updating the task.',
details: error.errors ? error.errors.map(e => e.message) : [error.message]
});
}
});
// PATCH /api/task/:id/toggle_completion
router.patch('/task/:id/toggle_completion', async (req, res) => {
try {
const task = await Task.findOne({
where: { id: req.params.id, user_id: req.currentUser.id }
});
if (!task) {
return res.status(404).json({ error: 'Task not found.' });
}
const newStatus = task.status === Task.STATUS.DONE
? (task.note ? Task.STATUS.IN_PROGRESS : Task.STATUS.NOT_STARTED)
: Task.STATUS.DONE;
await task.update({ status: newStatus });
res.json({
...task.toJSON(),
due_date: task.due_date ? task.due_date.toISOString().split('T')[0] : null
});
} catch (error) {
console.error('Error toggling task completion:', error);
res.status(422).json({ error: 'Unable to update task' });
}
});
// DELETE /api/task/:id
router.delete('/task/:id', async (req, res) => {
try {
const task = await Task.findOne({
where: { id: req.params.id, user_id: req.currentUser.id }
});
if (!task) {
return res.status(404).json({ error: 'Task not found.' });
}
await task.destroy();
res.json({ message: 'Task successfully deleted' });
} catch (error) {
console.error('Error deleting task:', error);
res.status(400).json({ error: 'There was a problem deleting the task.' });
}
});
module.exports = router;

113
backend/routes/telegram.js Normal file
View file

@ -0,0 +1,113 @@
const express = require('express');
const { User } = require('../models');
const TelegramPoller = require('../services/telegramPoller');
const router = express.Router();
// POST /api/telegram/start-polling
router.post('/telegram/start-polling', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const user = await User.findByPk(req.session.userId);
if (!user || !user.telegram_bot_token) {
return res.status(400).json({ error: 'Telegram bot token not set.' });
}
const poller = TelegramPoller.getInstance();
const success = await poller.addUser(user);
if (success) {
res.json({
success: true,
message: 'Telegram polling started',
status: poller.getStatus()
});
} else {
res.status(500).json({ error: 'Failed to start Telegram polling.' });
}
} catch (error) {
console.error('Error starting Telegram polling:', error);
res.status(500).json({ error: 'Failed to start Telegram polling.' });
}
});
// POST /api/telegram/stop-polling
router.post('/telegram/stop-polling', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const poller = TelegramPoller.getInstance();
const success = poller.removeUser(req.session.userId);
res.json({
success: true,
message: 'Telegram polling stopped',
status: poller.getStatus()
});
} catch (error) {
console.error('Error stopping Telegram polling:', error);
res.status(500).json({ error: 'Failed to stop Telegram polling.' });
}
});
// GET /api/telegram/polling-status
router.get('/telegram/polling-status', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const poller = TelegramPoller.getInstance();
res.json({
success: true,
status: poller.getStatus()
});
} catch (error) {
console.error('Error getting Telegram polling status:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// POST /api/telegram/setup
router.post('/telegram/setup', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const { token } = req.body;
if (!token) {
return res.status(400).json({ error: 'Telegram bot token is required.' });
}
const user = await User.findByPk(req.session.userId);
if (!user) {
return res.status(404).json({ error: 'User not found.' });
}
// Basic token validation - check if it looks like a Telegram bot token
if (!/^\d+:[A-Za-z0-9_-]{35}$/.test(token)) {
return res.status(400).json({ error: 'Invalid Telegram bot token format.' });
}
// Update user's telegram bot token
await user.update({ telegram_bot_token: token });
res.json({
success: true,
message: 'Telegram bot token updated successfully',
token: token
});
} catch (error) {
console.error('Error setting up Telegram:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
module.exports = router;

156
backend/routes/url.js Normal file
View file

@ -0,0 +1,156 @@
const express = require('express');
const https = require('https');
const http = require('http');
const { URL } = require('url');
const router = express.Router();
// Helper function to extract title from HTML
function extractTitleFromHtml(html) {
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
if (titleMatch && titleMatch[1]) {
// Decode HTML entities and clean up
return titleMatch[1]
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.trim();
}
return null;
}
// Helper function to check if text is a URL
function isUrl(text) {
const urlRegex = /^(https?:\/\/)?[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/i;
return urlRegex.test(text.trim());
}
// Helper function to fetch URL title
async function fetchUrlTitle(url) {
return new Promise((resolve) => {
// Add protocol if missing
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'http://' + url;
}
try {
const urlObj = new URL(url);
const isHttps = urlObj.protocol === 'https:';
const client = isHttps ? https : http;
const options = {
hostname: urlObj.hostname,
port: urlObj.port || (isHttps ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method: 'GET',
timeout: 5000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
};
const req = client.request(options, (res) => {
let data = '';
let totalBytes = 0;
const maxBytes = 50000;
res.on('data', (chunk) => {
totalBytes += chunk.length;
if (totalBytes > maxBytes) {
req.destroy();
return;
}
data += chunk;
// Stop if we find the title tag
if (data.includes('</title>')) {
req.destroy();
}
});
res.on('end', () => {
const title = extractTitleFromHtml(data);
resolve(title);
});
});
req.on('error', () => {
resolve(null);
});
req.on('timeout', () => {
req.destroy();
resolve(null);
});
req.end();
} catch (error) {
resolve(null);
}
});
}
// GET /api/url/title
router.get('/url/title', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const { url } = req.query;
if (!url) {
return res.status(400).json({ error: 'URL parameter is required' });
}
const title = await fetchUrlTitle(url);
if (title) {
res.json({ url, title });
} else {
res.json({ url, title: null, error: 'Could not extract title' });
}
} catch (error) {
console.error('Error extracting URL title:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// POST /api/url/extract-from-text
router.post('/url/extract-from-text', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const { text } = req.body;
if (!text) {
return res.status(400).json({ error: 'Text parameter is required' });
}
// Simple URL extraction - look for URLs in text
const urlRegex = /(https?:\/\/[^\s]+)/gi;
const urls = text.match(urlRegex);
if (urls && urls.length > 0) {
const firstUrl = urls[0];
const title = await fetchUrlTitle(firstUrl);
res.json({
found: true,
url: firstUrl,
title: title,
originalText: text
});
} else {
res.json({ found: false });
}
} catch (error) {
console.error('Error extracting URL from text:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
module.exports = router;

206
backend/routes/users.js Normal file
View file

@ -0,0 +1,206 @@
const express = require('express');
const { User } = require('../models');
const TaskSummaryService = require('../services/taskSummaryService');
const router = express.Router();
const VALID_FREQUENCIES = ['daily', 'weekdays', 'weekly', '1h', '2h', '4h', '8h', '12h'];
// GET /api/profile
router.get('/profile', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const user = await User.findByPk(req.session.userId, {
attributes: [
'id', 'email', 'appearance', 'language', 'timezone',
'avatar_image', 'telegram_bot_token', 'telegram_chat_id',
'task_summary_enabled', 'task_summary_frequency'
]
});
if (!user) {
return res.status(404).json({ error: 'Profile not found.' });
}
res.json(user);
} catch (error) {
console.error('Error fetching profile:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// PATCH /api/profile
router.patch('/profile', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const user = await User.findByPk(req.session.userId);
if (!user) {
return res.status(404).json({ error: 'Profile not found.' });
}
const { appearance, language, timezone, avatar_image, telegram_bot_token } = req.body;
const allowedUpdates = {};
if (appearance !== undefined) allowedUpdates.appearance = appearance;
if (language !== undefined) allowedUpdates.language = language;
if (timezone !== undefined) allowedUpdates.timezone = timezone;
if (avatar_image !== undefined) allowedUpdates.avatar_image = avatar_image;
if (telegram_bot_token !== undefined) allowedUpdates.telegram_bot_token = telegram_bot_token;
await user.update(allowedUpdates);
// Return updated user with limited fields
const updatedUser = await User.findByPk(user.id, {
attributes: ['id', 'email', 'appearance', 'language', 'timezone', 'avatar_image', 'telegram_bot_token', 'telegram_chat_id']
});
res.json(updatedUser);
} catch (error) {
console.error('Error updating profile:', error);
res.status(400).json({
error: 'Failed to update profile.',
details: error.errors ? error.errors.map(e => e.message) : [error.message]
});
}
});
// POST /api/profile/task-summary/toggle
router.post('/profile/task-summary/toggle', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const user = await User.findByPk(req.session.userId);
if (!user) {
return res.status(404).json({ error: 'User not found.' });
}
const enabled = !user.task_summary_enabled;
await user.update({ task_summary_enabled: enabled });
// Note: Telegram integration would need to be implemented separately
const message = enabled
? 'Task summary notifications have been enabled.'
: 'Task summary notifications have been disabled.';
res.json({
success: true,
enabled: enabled,
message: message
});
} catch (error) {
console.error('Error toggling task summary:', error);
res.status(400).json({
error: 'Failed to update task summary settings.',
details: error.errors ? error.errors.map(e => e.message) : [error.message]
});
}
});
// POST /api/profile/task-summary/frequency
router.post('/profile/task-summary/frequency', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const { frequency } = req.body;
if (!frequency) {
return res.status(400).json({ error: 'Frequency is required.' });
}
if (!VALID_FREQUENCIES.includes(frequency)) {
return res.status(400).json({ error: 'Invalid frequency value.' });
}
const user = await User.findByPk(req.session.userId);
if (!user) {
return res.status(404).json({ error: 'User not found.' });
}
await user.update({ task_summary_frequency: frequency });
res.json({
success: true,
frequency: frequency,
message: `Task summary frequency has been set to ${frequency}.`
});
} catch (error) {
console.error('Error updating task summary frequency:', error);
res.status(400).json({
error: 'Failed to update task summary frequency.',
details: error.errors ? error.errors.map(e => e.message) : [error.message]
});
}
});
// POST /api/profile/task-summary/send-now
router.post('/profile/task-summary/send-now', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const user = await User.findByPk(req.session.userId);
if (!user) {
return res.status(404).json({ error: 'User not found.' });
}
if (!user.telegram_bot_token || !user.telegram_chat_id) {
return res.status(400).json({ error: 'Telegram bot is not properly configured.' });
}
// Send the task summary
const success = await TaskSummaryService.sendSummaryToUser(user.id);
if (success) {
res.json({
success: true,
message: 'Task summary was sent to your Telegram.'
});
} else {
res.status(400).json({ error: 'Failed to send message to Telegram.' });
}
} catch (error) {
console.error('Error sending task summary:', error);
res.status(400).json({
error: 'Error sending message to Telegram.',
details: error.message
});
}
});
// GET /api/profile/task-summary/status
router.get('/profile/task-summary/status', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const user = await User.findByPk(req.session.userId);
if (!user) {
return res.status(404).json({ error: 'User not found.' });
}
res.json({
success: true,
enabled: user.task_summary_enabled,
frequency: user.task_summary_frequency,
last_run: user.task_summary_last_run,
next_run: user.task_summary_next_run
});
} catch (error) {
console.error('Error fetching task summary status:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
module.exports = router;

27
backend/scripts/db-init.js Executable file
View file

@ -0,0 +1,27 @@
#!/usr/bin/env node
/**
* Database Initialization Script
* Initializes the database by creating all tables and dropping existing data
*/
require('dotenv').config();
const { sequelize } = require('../models');
async function initDatabase() {
try {
console.log('Initializing database...');
console.log('WARNING: This will drop all existing data!');
await sequelize.sync({ force: true });
console.log('✅ Database initialized successfully');
console.log('All tables have been created and existing data has been cleared');
process.exit(0);
} catch (error) {
console.error('❌ Error initializing database:', error.message);
process.exit(1);
}
}
initDatabase();

27
backend/scripts/db-migrate.js Executable file
View file

@ -0,0 +1,27 @@
#!/usr/bin/env node
/**
* Database Migration Script
* Migrates the database by altering existing tables to match current models
*/
require('dotenv').config();
const { sequelize } = require('../models');
async function migrateDatabase() {
try {
console.log('Migrating database...');
console.log('This will alter existing tables to match current models');
await sequelize.sync({ alter: true });
console.log('✅ Database migrated successfully');
console.log('All tables have been updated to match current models');
process.exit(0);
} catch (error) {
console.error('❌ Error migrating database:', error.message);
process.exit(1);
}
}
migrateDatabase();

27
backend/scripts/db-reset.js Executable file
View file

@ -0,0 +1,27 @@
#!/usr/bin/env node
/**
* Database Reset Script
* Resets the database by dropping and recreating all tables
*/
require('dotenv').config();
const { sequelize } = require('../models');
async function resetDatabase() {
try {
console.log('Resetting database...');
console.log('WARNING: This will permanently delete all data!');
await sequelize.sync({ force: true });
console.log('✅ Database reset successfully');
console.log('All tables have been dropped and recreated');
process.exit(0);
} catch (error) {
console.error('❌ Error resetting database:', error.message);
process.exit(1);
}
}
resetDatabase();

69
backend/scripts/db-status.js Executable file
View file

@ -0,0 +1,69 @@
#!/usr/bin/env node
/**
* Database Status Script
* Shows database connection status and basic information
*/
require('dotenv').config();
const { sequelize, User, Task, Project, Area, Note, Tag, InboxItem } = require('../models');
const fs = require('fs');
const path = require('path');
async function checkDatabaseStatus() {
try {
console.log('🔍 Checking database status...\n');
// Check database file
const dbConfig = sequelize.config || sequelize.options;
const dbPath = dbConfig.storage || sequelize.options.storage;
console.log('📂 Database Configuration:');
console.log(` Storage: ${dbPath}`);
console.log(` Dialect: ${dbConfig.dialect || sequelize.options.dialect || 'sqlite'}`);
console.log(` Environment: ${process.env.NODE_ENV || 'development'}`);
// Check if database file exists
if (fs.existsSync(dbPath)) {
const stats = fs.statSync(dbPath);
console.log(` File size: ${(stats.size / 1024).toFixed(2)} KB`);
console.log(` Last modified: ${stats.mtime.toISOString()}`);
} else {
console.log(' ⚠️ Database file does not exist');
}
console.log('\n🔌 Testing database connection...');
await sequelize.authenticate();
console.log('✅ Database connection successful\n');
// Get table information
console.log('📊 Table Statistics:');
const models = [
{ name: 'Users', model: User },
{ name: 'Areas', model: Area },
{ name: 'Projects', model: Project },
{ name: 'Tasks', model: Task },
{ name: 'Notes', model: Note },
{ name: 'Tags', model: Tag },
{ name: 'Inbox Items', model: InboxItem }
];
for (const { name, model } of models) {
try {
const count = await model.count();
console.log(` ${name}: ${count} records`);
} catch (error) {
console.log(` ${name}: ❌ Error (${error.message})`);
}
}
console.log('\n✅ Database status check completed');
process.exit(0);
} catch (error) {
console.error('\n❌ Database connection failed:', error.message);
console.error('\n💡 Try running: npm run db:init');
process.exit(1);
}
}
checkDatabaseStatus();

26
backend/scripts/db-sync.js Executable file
View file

@ -0,0 +1,26 @@
#!/usr/bin/env node
/**
* Database Sync Script
* Syncs the database by creating tables if they don't exist (without dropping existing data)
*/
require('dotenv').config();
const { sequelize } = require('../models');
async function syncDatabase() {
try {
console.log('Syncing database...');
await sequelize.sync();
console.log('✅ Database synchronized successfully');
console.log('All tables have been created (existing data preserved)');
process.exit(0);
} catch (error) {
console.error('❌ Error syncing database:', error.message);
process.exit(1);
}
}
syncDatabase();

View file

@ -0,0 +1,108 @@
#!/usr/bin/env node
/**
* Migration Creation Script
* Creates a new Sequelize migration file
* Usage: node scripts/migration-create.js <migration-name>
*/
const fs = require('fs');
const path = require('path');
function createMigration() {
const migrationName = process.argv[2];
if (!migrationName) {
console.error('❌ Usage: npm run migration:create <migration-name>');
console.error('Example: npm run migration:create add-description-to-tasks');
process.exit(1);
}
// Generate timestamp (YYYYMMDDHHMMSS format)
const now = new Date();
const timestamp = now.getFullYear().toString() +
(now.getMonth() + 1).toString().padStart(2, '0') +
now.getDate().toString().padStart(2, '0') +
now.getHours().toString().padStart(2, '0') +
now.getMinutes().toString().padStart(2, '0') +
now.getSeconds().toString().padStart(2, '0');
const fileName = `${timestamp}-${migrationName}.js`;
const filePath = path.join(__dirname, '..', 'migrations', fileName);
// Migration template
const template = `'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
// Add your migration logic here
// Examples:
// Add a new column:
// await queryInterface.addColumn('table_name', 'column_name', {
// type: Sequelize.STRING,
// allowNull: true
// });
// Create a new table:
// await queryInterface.createTable('table_name', {
// id: {
// allowNull: false,
// autoIncrement: true,
// primaryKey: true,
// type: Sequelize.INTEGER
// },
// created_at: {
// allowNull: false,
// type: Sequelize.DATE,
// defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
// },
// updated_at: {
// allowNull: false,
// type: Sequelize.DATE,
// defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
// }
// });
// Add an index:
// await queryInterface.addIndex('table_name', ['column_name']);
throw new Error('Migration not implemented yet!');
},
async down(queryInterface, Sequelize) {
// Add your rollback logic here
// Examples:
// Remove a column:
// await queryInterface.removeColumn('table_name', 'column_name');
// Drop a table:
// await queryInterface.dropTable('table_name');
// Remove an index:
// await queryInterface.removeIndex('table_name', ['column_name']);
throw new Error('Rollback not implemented yet!');
}
};`;
try {
fs.writeFileSync(filePath, template);
console.log('✅ Migration created successfully');
console.log(`📁 File: ${fileName}`);
console.log(`📂 Path: ${filePath}`);
console.log('');
console.log('📝 Next steps:');
console.log('1. Edit the migration file to add your schema changes');
console.log('2. Run: npm run migration:run');
console.log('3. To rollback: npm run migration:undo');
process.exit(0);
} catch (error) {
console.error('❌ Error creating migration:', error.message);
process.exit(1);
}
}
createMigration();

66
backend/scripts/user-create.js Executable file
View file

@ -0,0 +1,66 @@
#!/usr/bin/env node
/**
* User Creation Script
* Creates a new user with email and password
* Usage: node user-create.js <email> <password>
*/
require('dotenv').config();
const { User } = require('../models');
const bcrypt = require('bcrypt');
async function createUser() {
const [email, password] = process.argv.slice(2);
if (!email || !password) {
console.error('❌ Usage: npm run user:create <email> <password>');
console.error('Example: npm run user:create admin@example.com mypassword123');
process.exit(1);
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
console.error('❌ Invalid email format');
process.exit(1);
}
// Basic password validation
if (password.length < 6) {
console.error('❌ Password must be at least 6 characters long');
process.exit(1);
}
try {
console.log(`Creating user with email: ${email}`);
// Check if user already exists
const existingUser = await User.findOne({ where: { email } });
if (existingUser) {
console.error(`❌ User with email ${email} already exists`);
process.exit(1);
}
// Hash the password
const hashedPassword = await bcrypt.hash(password, 10);
// Create the user
const user = await User.create({
email,
password: hashedPassword
});
console.log('✅ User created successfully');
console.log(`📧 Email: ${user.email}`);
console.log(`🆔 User ID: ${user.id}`);
console.log(`📅 Created: ${user.created_at}`);
process.exit(0);
} catch (error) {
console.error('❌ Error creating user:', error.message);
process.exit(1);
}
}
createUser();

View file

@ -0,0 +1,65 @@
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
class QuotesService {
constructor() {
this.quotes = [];
this.loadQuotes();
}
loadQuotes() {
try {
const quotesPath = path.join(__dirname, '../config/quotes.yml');
if (fs.existsSync(quotesPath)) {
const fileContents = fs.readFileSync(quotesPath, 'utf8');
const data = yaml.load(fileContents);
if (data && data.quotes && Array.isArray(data.quotes)) {
this.quotes = data.quotes;
console.log(`Loaded ${this.quotes.length} quotes from configuration`);
} else {
console.warn('No quotes found in configuration file');
this.setDefaultQuotes();
}
} else {
console.warn('Quotes configuration file not found, using defaults');
this.setDefaultQuotes();
}
} catch (error) {
console.error('Error loading quotes:', error.message);
this.setDefaultQuotes();
}
}
setDefaultQuotes() {
this.quotes = [
"Believe you can and you're halfway there.",
"The only way to do great work is to love what you do.",
"It always seems impossible until it's done.",
"Focus on progress, not perfection.",
"One task at a time leads to great accomplishments."
];
}
getRandomQuote() {
if (this.quotes.length === 0) {
return "Stay focused and keep going!";
}
const randomIndex = Math.floor(Math.random() * this.quotes.length);
return this.quotes[randomIndex];
}
getAllQuotes() {
return this.quotes;
}
getQuotesCount() {
return this.quotes.length;
}
}
// Export singleton instance
module.exports = new QuotesService();

View file

@ -0,0 +1,181 @@
const cron = require('node-cron');
const { User } = require('../models');
const TaskSummaryService = require('./taskSummaryService');
class TaskScheduler {
constructor() {
this.jobs = new Map();
this.isInitialized = false;
}
static getInstance() {
if (!TaskScheduler.instance) {
TaskScheduler.instance = new TaskScheduler();
}
return TaskScheduler.instance;
}
async initialize() {
if (this.isInitialized) {
console.log('Task scheduler already initialized');
return;
}
// Don't schedule in test environment
if (process.env.NODE_ENV === 'test' || process.env.DISABLE_SCHEDULER === 'true') {
console.log('Task scheduler disabled for test environment');
return;
}
console.log('Initializing task scheduler...');
// Daily schedule at 7 AM (for users with daily frequency)
const dailyJob = cron.schedule('0 7 * * *', async () => {
console.log('Running scheduled task: Daily task summary');
await this.processSummariesForFrequency('daily');
}, {
scheduled: false,
timezone: 'UTC'
});
// Weekdays schedule at 7 AM (Monday through Friday)
const weekdaysJob = cron.schedule('0 7 * * 1-5', async () => {
console.log('Running scheduled task: Weekday task summary');
await this.processSummariesForFrequency('weekdays');
}, {
scheduled: false,
timezone: 'UTC'
});
// Weekly schedule at 7 AM on Monday
const weeklyJob = cron.schedule('0 7 * * 1', async () => {
console.log('Running scheduled task: Weekly task summary');
await this.processSummariesForFrequency('weekly');
}, {
scheduled: false,
timezone: 'UTC'
});
// Hourly schedules
const hourlyJob = cron.schedule('0 * * * *', async () => {
console.log('Running scheduled task: Hourly (1h) task summary');
await this.processSummariesForFrequency('1h');
}, {
scheduled: false,
timezone: 'UTC'
});
const twoHourlyJob = cron.schedule('0 */2 * * *', async () => {
console.log('Running scheduled task: 2-hour task summary');
await this.processSummariesForFrequency('2h');
}, {
scheduled: false,
timezone: 'UTC'
});
const fourHourlyJob = cron.schedule('0 */4 * * *', async () => {
console.log('Running scheduled task: 4-hour task summary');
await this.processSummariesForFrequency('4h');
}, {
scheduled: false,
timezone: 'UTC'
});
const eightHourlyJob = cron.schedule('0 */8 * * *', async () => {
console.log('Running scheduled task: 8-hour task summary');
await this.processSummariesForFrequency('8h');
}, {
scheduled: false,
timezone: 'UTC'
});
const twelveHourlyJob = cron.schedule('0 */12 * * *', async () => {
console.log('Running scheduled task: 12-hour task summary');
await this.processSummariesForFrequency('12h');
}, {
scheduled: false,
timezone: 'UTC'
});
// Store jobs for later management
this.jobs.set('daily', dailyJob);
this.jobs.set('weekdays', weekdaysJob);
this.jobs.set('weekly', weeklyJob);
this.jobs.set('1h', hourlyJob);
this.jobs.set('2h', twoHourlyJob);
this.jobs.set('4h', fourHourlyJob);
this.jobs.set('8h', eightHourlyJob);
this.jobs.set('12h', twelveHourlyJob);
// Start all jobs
this.jobs.forEach((job, frequency) => {
job.start();
console.log(`Started scheduler for frequency: ${frequency}`);
});
this.isInitialized = true;
console.log('Task scheduler initialized successfully');
}
async processSummariesForFrequency(frequency) {
try {
const users = await User.findAll({
where: {
telegram_bot_token: { [require('sequelize').Op.ne]: null },
telegram_chat_id: { [require('sequelize').Op.ne]: null },
task_summary_enabled: true,
task_summary_frequency: frequency
}
});
console.log(`Processing ${users.length} users for frequency: ${frequency}`);
for (const user of users) {
try {
const success = await TaskSummaryService.sendSummaryToUser(user.id);
if (success) {
console.log(`Sent ${frequency} summary to user ${user.id}`);
} else {
console.log(`Failed to send ${frequency} summary to user ${user.id}`);
}
} catch (error) {
console.error(`Error sending ${frequency} summary to user ${user.id}:`, error.message);
}
}
} catch (error) {
console.error(`Error processing summaries for frequency ${frequency}:`, error);
}
}
async stop() {
if (!this.isInitialized) {
console.log('Task scheduler not initialized, nothing to stop');
return;
}
console.log('Stopping task scheduler...');
this.jobs.forEach((job, frequency) => {
job.stop();
console.log(`Stopped scheduler for frequency: ${frequency}`);
});
this.jobs.clear();
this.isInitialized = false;
console.log('Task scheduler stopped');
}
async restart() {
await this.stop();
await this.initialize();
}
getStatus() {
return {
initialized: this.isInitialized,
jobCount: this.jobs.size,
jobs: Array.from(this.jobs.keys())
};
}
}
module.exports = TaskScheduler;

View file

@ -0,0 +1,238 @@
const { User, Task, Project, Tag } = require('../models');
const { Op } = require('sequelize');
const TelegramPoller = require('./telegramPoller');
class TaskSummaryService {
// Helper method to escape special characters for MarkdownV2
static escapeMarkdown(text) {
if (!text) return '';
// Characters that need to be escaped in MarkdownV2: _*[]()~`>#+-=|{}.!
return text.toString().replace(/([_*\[\]()~`>#+\-=|{}.!])/g, '\\$1');
}
static async generateSummaryForUser(userId) {
try {
const user = await User.findByPk(userId);
if (!user) return null;
// Get today's date
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
// Get today's tasks, in progress tasks, etc.
const dueToday = await Task.findAll({
where: {
user_id: userId,
due_date: {
[Op.gte]: today,
[Op.lt]: tomorrow
},
status: { [Op.ne]: 2 } // not done
},
include: [{ model: Project, attributes: ['name'] }],
order: [['name', 'ASC']]
});
const inProgress = await Task.findAll({
where: {
user_id: userId,
status: 1 // in_progress
},
include: [{ model: Project, attributes: ['name'] }],
order: [['name', 'ASC']]
});
const completedToday = await Task.findAll({
where: {
user_id: userId,
status: 2, // done
updated_at: {
[Op.gte]: today,
[Op.lt]: tomorrow
}
},
include: [{ model: Project, attributes: ['name'] }],
order: [['name', 'ASC']]
});
// Generate summary message
let message = "📋 *Today's Task Summary*\n\n";
message += "━━━━━━━━━━━━━━━━━━━━━━━━\n\n";
message += "✏️ *Today's Plan*\n\n";
// Add due today tasks
if (dueToday.length > 0) {
message += "🚀 *Tasks Due Today:*\n";
dueToday.forEach((task, index) => {
const priorityEmoji = this.getPriorityEmoji(task.priority);
const taskName = this.escapeMarkdown(task.name);
const projectInfo = task.Project ? ` \\[${this.escapeMarkdown(task.Project.name)}\\]` : '';
message += `${index + 1}\\. ${priorityEmoji} ${taskName}${projectInfo}\n`;
});
message += "\n";
}
// Add in progress tasks
if (inProgress.length > 0) {
message += "⚙️ *In Progress Tasks:*\n";
inProgress.forEach((task, index) => {
const priorityEmoji = this.getPriorityEmoji(task.priority);
const taskName = this.escapeMarkdown(task.name);
const projectInfo = task.Project ? ` \\[${this.escapeMarkdown(task.Project.name)}\\]` : '';
message += `${index + 1}\\. ${priorityEmoji} ${taskName}${projectInfo}\n`;
});
message += "\n";
}
// Get suggested tasks (not done, not in due today or in progress)
const excludedIds = [...dueToday.map(t => t.id), ...inProgress.map(t => t.id)];
const suggestedTasks = await Task.findAll({
where: {
user_id: userId,
status: { [Op.ne]: 2 }, // not done
id: { [Op.notIn]: excludedIds }
},
include: [{ model: Project, attributes: ['name'] }],
order: [['priority', 'DESC'], ['name', 'ASC']],
limit: 5
});
if (suggestedTasks.length > 0) {
message += "💡 *Suggested Tasks:*\n";
suggestedTasks.forEach((task, index) => {
const priorityEmoji = this.getPriorityEmoji(task.priority);
const taskName = this.escapeMarkdown(task.name);
const projectInfo = task.Project ? ` \\[${this.escapeMarkdown(task.Project.name)}\\]` : '';
message += `${index + 1}\\. ${priorityEmoji} ${taskName}${projectInfo}\n`;
});
message += "\n";
}
// Add completed tasks
if (completedToday.length > 0) {
message += "✅ *Completed Today:*\n";
completedToday.forEach((task, index) => {
const taskName = this.escapeMarkdown(task.name);
const projectInfo = task.Project ? ` \\[${this.escapeMarkdown(task.Project.name)}\\]` : '';
message += `${index + 1}\\. ✅ ${taskName}${projectInfo}\n`;
});
message += "\n";
}
// Add footer
message += "━━━━━━━━━━━━━━━━━━━━━━━━\n";
message += "🎯 *Stay focused and make it happen\\!*";
return message;
} catch (error) {
console.error('Error generating task summary:', error);
return null;
}
}
static getPriorityEmoji(priority) {
switch (priority) {
case 2: return '🔴'; // high
case 1: return '🟠'; // medium
case 0: return '🟢'; // low
default: return '⚪';
}
}
static async sendSummaryToUser(userId) {
try {
const user = await User.findByPk(userId);
if (!user || !user.telegram_bot_token || !user.telegram_chat_id) {
return false;
}
const summary = await this.generateSummaryForUser(userId);
if (!summary) return false;
// Send the message via Telegram
const poller = TelegramPoller.getInstance();
await poller.sendTelegramMessage(
user.telegram_bot_token,
user.telegram_chat_id,
summary
);
// Update the last run time and calculate the next run time
const now = new Date();
const nextRun = this.calculateNextRunTime(user, now);
// Update the user's tracking fields
await user.update({
task_summary_last_run: now,
task_summary_next_run: nextRun
});
return true;
} catch (error) {
console.error(`Error sending task summary to user ${userId}:`, error.message);
return false;
}
}
static calculateNextRunTime(user, fromTime = new Date()) {
const frequency = user.task_summary_frequency;
const from = new Date(fromTime);
switch (frequency) {
case 'daily':
// Next day at 7 AM
const nextDay = new Date(from);
nextDay.setDate(nextDay.getDate() + 1);
nextDay.setHours(7, 0, 0, 0);
return nextDay;
case 'weekdays':
// Next weekday at 7 AM
const currentDay = from.getDay(); // 0 = Sunday, 1 = Monday, ..., 6 = Saturday
let daysToAdd = 1;
if (currentDay === 5) { // Friday
daysToAdd = 3; // Skip to Monday
} else if (currentDay === 6) { // Saturday
daysToAdd = 2; // Skip to Monday
}
const nextWeekday = new Date(from);
nextWeekday.setDate(nextWeekday.getDate() + daysToAdd);
nextWeekday.setHours(7, 0, 0, 0);
return nextWeekday;
case 'weekly':
// Next Monday at 7 AM
const nextWeek = new Date(from);
nextWeek.setDate(nextWeek.getDate() + 7);
nextWeek.setHours(7, 0, 0, 0);
return nextWeek;
case '1h':
return new Date(from.getTime() + 60 * 60 * 1000);
case '2h':
return new Date(from.getTime() + 2 * 60 * 60 * 1000);
case '4h':
return new Date(from.getTime() + 4 * 60 * 60 * 1000);
case '8h':
return new Date(from.getTime() + 8 * 60 * 60 * 1000);
case '12h':
return new Date(from.getTime() + 12 * 60 * 60 * 1000);
default:
// Default to daily
const defaultNext = new Date(from);
defaultNext.setDate(defaultNext.getDate() + 1);
defaultNext.setHours(7, 0, 0, 0);
return defaultNext;
}
}
}
module.exports = TaskSummaryService;

View file

@ -0,0 +1,43 @@
const TelegramPoller = require('./telegramPoller');
const { User } = require('../models');
async function initializeTelegramPolling() {
if (process.env.NODE_ENV === 'test' || process.env.DISABLE_TELEGRAM === 'true') {
return;
}
console.log('Initializing Telegram polling for configured users...');
try {
// Get singleton instance of the poller
const poller = TelegramPoller.getInstance();
// Find users with configured Telegram tokens
const usersWithTelegram = await User.findAll({
where: {
telegram_bot_token: {
[require('sequelize').Op.ne]: null
}
}
});
if (usersWithTelegram.length > 0) {
console.log(`Found ${usersWithTelegram.length} users with Telegram configuration`);
// Add each user to the polling list
for (const user of usersWithTelegram) {
console.log(`Starting Telegram polling for user ${user.id}`);
await poller.addUser(user);
}
console.log('Telegram polling initialized successfully');
} else {
console.log('No users with Telegram configuration found');
}
} catch (error) {
console.error('Error initializing Telegram polling:', error.message);
console.error('Telegram polling will be initialized later when the database is available.');
}
}
module.exports = { initializeTelegramPolling };

View file

@ -0,0 +1,261 @@
const https = require('https');
const { User, InboxItem } = require('../models');
class TelegramPoller {
constructor() {
this.running = false;
this.interval = null;
this.pollInterval = 5000; // 5 seconds
this.usersToPool = [];
this.userStatus = {};
}
// Singleton pattern
static getInstance() {
if (!TelegramPoller.instance) {
TelegramPoller.instance = new TelegramPoller();
}
return TelegramPoller.instance;
}
// Add user to polling list
async addUser(user) {
if (!user || !user.telegram_bot_token) {
return false;
}
// Check if user already in list
const exists = this.usersToPool.find(u => u.id === user.id);
if (!exists) {
this.usersToPool.push(user);
}
// Start polling if not already running
if (this.usersToPool.length > 0 && !this.running) {
this.startPolling();
}
return true;
}
// Remove user from polling list
removeUser(userId) {
this.usersToPool = this.usersToPool.filter(u => u.id !== userId);
delete this.userStatus[userId];
// Stop polling if no users left
if (this.usersToPool.length === 0 && this.running) {
this.stopPolling();
}
return true;
}
// Start the polling process
startPolling() {
if (this.running) return;
console.log('Starting Telegram polling...');
this.running = true;
this.interval = setInterval(async () => {
try {
await this.pollUpdates();
} catch (error) {
console.error('Error polling Telegram:', error.message);
}
}, this.pollInterval);
}
// Stop the polling process
stopPolling() {
if (!this.running) return;
console.log('Stopping Telegram polling...');
this.running = false;
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
// Poll for updates from Telegram
async pollUpdates() {
for (const user of this.usersToPool) {
const token = user.telegram_bot_token;
if (!token) continue;
try {
const lastUpdateId = this.userStatus[user.id]?.lastUpdateId || 0;
const updates = await this.getTelegramUpdates(token, lastUpdateId + 1);
if (updates && updates.length > 0) {
await this.processUpdates(user, updates);
}
} catch (error) {
console.error(`Error getting updates for user ${user.id}:`, error.message);
}
}
}
// Get updates from Telegram API
getTelegramUpdates(token, offset) {
return new Promise((resolve, reject) => {
const url = `https://api.telegram.org/bot${token}/getUpdates?offset=${offset}&timeout=1`;
https.get(url, { timeout: 5000 }, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
const response = JSON.parse(data);
if (response.ok && Array.isArray(response.result)) {
resolve(response.result);
} else {
console.error('Telegram API error:', response);
resolve([]);
}
} catch (error) {
reject(error);
}
});
}).on('error', (error) => {
reject(error);
}).on('timeout', () => {
reject(new Error('Request timeout'));
});
});
}
// Process updates received from Telegram
async processUpdates(user, updates) {
if (!updates.length) return;
// Track the highest update_id
const highestUpdateId = Math.max(...updates.map(u => u.update_id));
// Save the last update ID for this user
if (!this.userStatus[user.id]) {
this.userStatus[user.id] = {};
}
this.userStatus[user.id].lastUpdateId = highestUpdateId;
for (const update of updates) {
try {
if (update.message && update.message.text) {
await this.processMessage(user, update);
}
} catch (error) {
console.error(`Error processing update ${update.update_id}:`, error.message);
}
}
}
// Process a single message
async processMessage(user, update) {
const message = update.message;
const text = message.text;
const chatId = message.chat.id.toString();
const messageId = message.message_id;
console.log(`Processing message from user ${user.id}: ${text}`);
// Save the chat_id if not already saved
if (!user.telegram_chat_id) {
console.log(`Updating user's telegram_chat_id to ${chatId}`);
await User.update(
{ telegram_chat_id: chatId },
{ where: { id: user.id } }
);
user.telegram_chat_id = chatId; // Update local object
}
try {
// Create an inbox item
const inboxItem = await InboxItem.create({
content: text,
source: 'telegram',
user_id: user.id
});
console.log(`Created inbox item ${inboxItem.id} from Telegram message`);
// Send confirmation
await this.sendTelegramMessage(
user.telegram_bot_token,
chatId,
`✅ Added to Tududi inbox: "${text}"`,
messageId
);
} catch (error) {
console.error('Failed to create inbox item:', error.message);
// Send error message
await this.sendTelegramMessage(
user.telegram_bot_token,
chatId,
`❌ Failed to add to inbox: ${error.message}`,
messageId
);
}
}
// Send a message to Telegram
sendTelegramMessage(token, chatId, text, replyToMessageId = null) {
return new Promise((resolve, reject) => {
const messageParams = {
chat_id: chatId,
text: text
};
if (replyToMessageId) {
messageParams.reply_to_message_id = replyToMessageId;
}
const postData = JSON.stringify(messageParams);
const url = `https://api.telegram.org/bot${token}/sendMessage`;
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData)
}
};
const req = https.request(url, options, (res) => {
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', () => {
try {
const response = JSON.parse(data);
resolve(response);
} catch (error) {
reject(error);
}
});
});
req.on('error', reject);
req.write(postData);
req.end();
});
}
// Get status of the poller
getStatus() {
return {
running: this.running,
usersCount: this.usersToPool.length,
pollInterval: this.pollInterval,
userStatus: this.userStatus
};
}
}
module.exports = TelegramPoller;

11
backend/start.sh Executable file
View file

@ -0,0 +1,11 @@
#!/bin/bash
# Start script for Express backend
echo "Starting Express backend..."
echo "Make sure to set environment variables if needed:"
echo " TUDUDI_SESSION_SECRET=your_secret_here"
echo " TUDUDI_USER_EMAIL=your_email@example.com"
echo " TUDUDI_USER_PASSWORD=your_password"
echo ""
PORT=3001 npm start

82
backend/tests/README.md Normal file
View file

@ -0,0 +1,82 @@
# Backend Test Suite
This directory contains the test suite for the tududi backend Express application.
## Structure
```
tests/
├── unit/ # Unit tests for individual components
│ ├── models/ # Model tests
│ ├── middleware/ # Middleware tests
│ └── services/ # Service tests
├── integration/ # Integration tests for API endpoints
├── fixtures/ # Test data fixtures
└── helpers/ # Test utilities and helpers
```
## Running Tests
### All Tests
```bash
npm test
```
### Unit Tests Only
```bash
npm run test:unit
```
### Integration Tests Only
```bash
npm run test:integration
```
### Watch Mode (for development)
```bash
npm run test:watch
```
### Coverage Report
```bash
npm run test:coverage
```
## Test Environment
Tests run in a separate test environment with:
- In-memory SQLite database (isolated from development data)
- Test-specific configuration from `.env.test`
- Automatic database cleanup between tests
## Writing Tests
### Unit Tests
- Test individual functions, models, or middleware in isolation
- Mock external dependencies
- Focus on business logic and edge cases
### Integration Tests
- Test complete API endpoints
- Use authenticated requests where needed
- Test real database interactions
- Verify response formats and status codes
### Test Utilities
- `tests/helpers/testUtils.js` provides utilities for creating test data
- `tests/helpers/setup.js` handles database setup and cleanup
- Use `createTestUser()` for creating authenticated test users
## Best Practices
1. **Isolation**: Each test should be independent and not rely on other tests
2. **Cleanup**: Database is automatically cleaned between tests
3. **Authentication**: Use test utilities for creating authenticated requests
4. **Descriptive Names**: Test names should clearly describe what is being tested
5. **Coverage**: Aim for high test coverage of critical business logic
## Dependencies
- **Jest**: Test framework
- **Supertest**: HTTP testing library for integration tests
- **cross-env**: Cross-platform environment variable setting

View file

@ -0,0 +1,39 @@
// Set test environment before importing models
process.env.NODE_ENV = 'test';
const { sequelize } = require('../../models');
beforeAll(async () => {
await sequelize.sync({ force: true });
}, 30000);
beforeEach(async () => {
// Clean all tables except Sessions to avoid conflicts
try {
const models = Object.values(sequelize.models);
const nonSessionModels = models.filter(model => model.name !== 'Session');
await Promise.all(nonSessionModels.map(model => model.destroy({ truncate: true, cascade: true })));
} catch (error) {
// Ignore errors during cleanup
}
});
afterEach(async () => {
// Clean up sessions after each test
try {
const Session = sequelize.models.Session;
if (Session) {
await Session.destroy({ truncate: true });
}
} catch (error) {
// Ignore errors during session cleanup
}
});
afterAll(async () => {
try {
await sequelize.close();
} catch (error) {
// Database may already be closed
}
}, 30000);

View file

@ -0,0 +1,28 @@
const bcrypt = require('bcrypt');
const { User } = require('../../models');
const createTestUser = async (userData = {}) => {
const defaultUser = {
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10),
...userData
};
return await User.create(defaultUser);
};
const authenticateUser = async (request, user) => {
const response = await request
.post('/api/login')
.send({
email: user.email,
password: 'password123'
});
return response.headers['set-cookie'];
};
module.exports = {
createTestUser,
authenticateUser
};

View file

@ -0,0 +1,280 @@
const request = require('supertest');
const app = require('../../app');
const { Area, User } = require('../../models');
const { createTestUser } = require('../helpers/testUtils');
describe('Areas Routes', () => {
let user, agent;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com'
});
// Create authenticated agent
agent = request.agent(app);
await agent
.post('/api/login')
.send({
email: 'test@example.com',
password: 'password123'
});
});
describe('POST /api/areas', () => {
it('should create a new area', async () => {
const areaData = {
name: 'Work',
description: 'Work related projects'
};
const response = await agent
.post('/api/areas')
.send(areaData);
expect(response.status).toBe(201);
expect(response.body.name).toBe(areaData.name);
expect(response.body.description).toBe(areaData.description);
expect(response.body.user_id).toBe(user.id);
});
it('should require authentication', async () => {
const areaData = {
name: 'Work'
};
const response = await request(app)
.post('/api/areas')
.send(areaData);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should require area name', async () => {
const areaData = {
description: 'Area without name'
};
const response = await agent
.post('/api/areas')
.send(areaData);
expect(response.status).toBe(400);
expect(response.body.error).toBe('Area name is required.');
});
});
describe('GET /api/areas', () => {
let area1, area2;
beforeEach(async () => {
area1 = await Area.create({
name: 'Work',
description: 'Work projects',
user_id: user.id
});
area2 = await Area.create({
name: 'Personal',
description: 'Personal projects',
user_id: user.id
});
});
it('should get all user areas', async () => {
const response = await agent.get('/api/areas');
expect(response.status).toBe(200);
expect(response.body).toHaveLength(2);
expect(response.body.map(a => a.id)).toContain(area1.id);
expect(response.body.map(a => a.id)).toContain(area2.id);
});
it('should order areas by name', async () => {
const response = await agent.get('/api/areas');
expect(response.status).toBe(200);
expect(response.body[0].name).toBe('Personal'); // P comes before W
expect(response.body[1].name).toBe('Work');
});
it('should require authentication', async () => {
const response = await request(app).get('/api/areas');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('GET /api/areas/:id', () => {
let area;
beforeEach(async () => {
area = await Area.create({
name: 'Work',
description: 'Work projects',
user_id: user.id
});
});
it('should get area by id', async () => {
const response = await agent.get(`/api/areas/${area.id}`);
expect(response.status).toBe(200);
expect(response.body.id).toBe(area.id);
expect(response.body.name).toBe(area.name);
expect(response.body.description).toBe(area.description);
});
it('should return 404 for non-existent area', async () => {
const response = await agent.get('/api/areas/999999');
expect(response.status).toBe(404);
expect(response.body.error).toBe("Area not found or doesn't belong to the current user.");
});
it('should not allow access to other user\'s areas', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const otherArea = await Area.create({
name: 'Other Area',
user_id: otherUser.id
});
const response = await agent.get(`/api/areas/${otherArea.id}`);
expect(response.status).toBe(404);
expect(response.body.error).toBe("Area not found or doesn't belong to the current user.");
});
it('should require authentication', async () => {
const response = await request(app).get(`/api/areas/${area.id}`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('PATCH /api/areas/:id', () => {
let area;
beforeEach(async () => {
area = await Area.create({
name: 'Work',
description: 'Work projects',
user_id: user.id
});
});
it('should update area', async () => {
const updateData = {
name: 'Updated Work',
description: 'Updated description'
};
const response = await agent
.patch(`/api/areas/${area.id}`)
.send(updateData);
expect(response.status).toBe(200);
expect(response.body.name).toBe(updateData.name);
expect(response.body.description).toBe(updateData.description);
});
it('should return 404 for non-existent area', async () => {
const response = await agent
.patch('/api/areas/999999')
.send({ name: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Area not found.');
});
it('should not allow updating other user\'s areas', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const otherArea = await Area.create({
name: 'Other Area',
user_id: otherUser.id
});
const response = await agent
.patch(`/api/areas/${otherArea.id}`)
.send({ name: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Area not found.');
});
it('should require authentication', async () => {
const response = await request(app)
.patch(`/api/areas/${area.id}`)
.send({ name: 'Updated' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('DELETE /api/areas/:id', () => {
let area;
beforeEach(async () => {
area = await Area.create({
name: 'Work',
user_id: user.id
});
});
it('should delete area', async () => {
const response = await agent.delete(`/api/areas/${area.id}`);
expect(response.status).toBe(204);
// Verify area is deleted
const deletedArea = await Area.findByPk(area.id);
expect(deletedArea).toBeNull();
});
it('should return 404 for non-existent area', async () => {
const response = await agent.delete('/api/areas/999999');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Area not found.');
});
it('should not allow deleting other user\'s areas', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const otherArea = await Area.create({
name: 'Other Area',
user_id: otherUser.id
});
const response = await agent.delete(`/api/areas/${otherArea.id}`);
expect(response.status).toBe(404);
expect(response.body.error).toBe('Area not found.');
});
it('should require authentication', async () => {
const response = await request(app).delete(`/api/areas/${area.id}`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
});

View file

@ -0,0 +1,155 @@
const request = require('supertest');
const app = require('../../app');
const { User } = require('../../models');
const { createTestUser } = require('../helpers/testUtils');
describe('Auth Routes', () => {
describe('POST /api/login', () => {
let user;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com'
});
});
it('should login with valid credentials', async () => {
const response = await request(app)
.post('/api/login')
.send({
email: 'test@example.com',
password: 'password123'
});
expect(response.status).toBe(200);
expect(response.body.user).toBeDefined();
expect(response.body.user.email).toBe('test@example.com');
expect(response.body.user.id).toBe(user.id);
expect(response.body.user.language).toBe('en');
expect(response.body.user.appearance).toBe('light');
expect(response.body.user.timezone).toBe('UTC');
});
it('should return 400 for missing email', async () => {
const response = await request(app)
.post('/api/login')
.send({
password: 'password123'
});
expect(response.status).toBe(400);
expect(response.body.error).toBe('Invalid login parameters.');
});
it('should return 400 for missing password', async () => {
const response = await request(app)
.post('/api/login')
.send({
email: 'test@example.com'
});
expect(response.status).toBe(400);
expect(response.body.error).toBe('Invalid login parameters.');
});
it('should return 401 for non-existent user', async () => {
const response = await request(app)
.post('/api/login')
.send({
email: 'nonexistent@example.com',
password: 'password123'
});
expect(response.status).toBe(401);
expect(response.body.errors).toEqual(['Invalid credentials']);
});
it('should return 401 for invalid password', async () => {
const response = await request(app)
.post('/api/login')
.send({
email: 'test@example.com',
password: 'wrongpassword'
});
expect(response.status).toBe(401);
expect(response.body.errors).toEqual(['Invalid credentials']);
});
});
describe('GET /api/current_user', () => {
let user;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com'
});
});
it('should return current user when logged in', async () => {
const agent = request.agent(app);
// Login first
await agent
.post('/api/login')
.send({
email: 'test@example.com',
password: 'password123'
});
// Check current user
const response = await agent.get('/api/current_user');
expect(response.status).toBe(200);
expect(response.body.user).toBeDefined();
expect(response.body.user.email).toBe('test@example.com');
expect(response.body.user.id).toBe(user.id);
});
it('should return null user when not logged in', async () => {
const response = await request(app).get('/api/current_user');
expect(response.status).toBe(200);
expect(response.body.user).toBeNull();
});
});
describe('GET /api/logout', () => {
let user;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com'
});
});
it('should logout successfully', async () => {
const agent = request.agent(app);
// Login first
await agent
.post('/api/login')
.send({
email: 'test@example.com',
password: 'password123'
});
// Logout
const response = await agent.get('/api/logout');
expect(response.status).toBe(200);
expect(response.body.message).toBe('Logged out successfully');
// Verify user is logged out
const currentUserResponse = await agent.get('/api/current_user');
expect(currentUserResponse.body.user).toBeNull();
});
it('should handle logout when not logged in', async () => {
const response = await request(app).get('/api/logout');
expect(response.status).toBe(200);
expect(response.body.message).toBe('Logged out successfully');
});
});
});

View file

@ -0,0 +1,275 @@
const request = require('supertest');
const app = require('../../app');
const { InboxItem, User } = require('../../models');
const { createTestUser } = require('../helpers/testUtils');
describe('Inbox Routes', () => {
let user, agent;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com'
});
// Create authenticated agent
agent = request.agent(app);
await agent
.post('/api/login')
.send({
email: 'test@example.com',
password: 'password123'
});
});
describe('POST /api/inbox', () => {
it('should create a new inbox item', async () => {
const inboxData = {
content: 'Remember to buy groceries',
source: 'web'
};
const response = await agent
.post('/api/inbox')
.send(inboxData);
expect(response.status).toBe(201);
expect(response.body.content).toBe(inboxData.content);
expect(response.body.source).toBe(inboxData.source);
expect(response.body.status).toBe('added');
expect(response.body.user_id).toBe(user.id);
});
it('should require authentication', async () => {
const inboxData = {
content: 'Test content'
};
const response = await request(app)
.post('/api/inbox')
.send(inboxData);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should require content', async () => {
const inboxData = {};
const response = await agent
.post('/api/inbox')
.send(inboxData);
expect(response.status).toBe(400);
expect(response.body.error).toBe('Content is required');
});
});
describe('GET /api/inbox', () => {
let inboxItem1, inboxItem2;
beforeEach(async () => {
inboxItem1 = await InboxItem.create({
content: 'First item',
status: 'added',
user_id: user.id
});
inboxItem2 = await InboxItem.create({
content: 'Second item',
status: 'processed',
user_id: user.id
});
});
it('should get all user inbox items', async () => {
const response = await agent.get('/api/inbox');
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBe(1); // Only items with status 'added' are returned
expect(response.body.map(i => i.id)).toContain(inboxItem1.id);
});
it('should only return items with added status', async () => {
const response = await agent.get('/api/inbox');
expect(response.status).toBe(200);
expect(response.body.length).toBe(1);
expect(response.body[0].id).toBe(inboxItem1.id);
expect(response.body[0].status).toBe('added');
});
it('should require authentication', async () => {
const response = await request(app).get('/api/inbox');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('GET /api/inbox/:id', () => {
let inboxItem;
beforeEach(async () => {
inboxItem = await InboxItem.create({
content: 'Test content',
user_id: user.id
});
});
it('should get inbox item by id', async () => {
const response = await agent.get(`/api/inbox/${inboxItem.id}`);
expect(response.status).toBe(200);
expect(response.body.id).toBe(inboxItem.id);
expect(response.body.content).toBe(inboxItem.content);
});
it('should return 404 for non-existent inbox item', async () => {
const response = await agent.get('/api/inbox/999999');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Inbox item not found.');
});
it('should not allow access to other user\'s inbox items', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const otherInboxItem = await InboxItem.create({
content: 'Other content',
user_id: otherUser.id
});
const response = await agent.get(`/api/inbox/${otherInboxItem.id}`);
expect(response.status).toBe(404);
expect(response.body.error).toBe('Inbox item not found.');
});
it('should require authentication', async () => {
const response = await request(app).get(`/api/inbox/${inboxItem.id}`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('PATCH /api/inbox/:id', () => {
let inboxItem;
beforeEach(async () => {
inboxItem = await InboxItem.create({
content: 'Test content',
status: 'added',
user_id: user.id
});
});
it('should update inbox item', async () => {
const updateData = {
content: 'Updated content',
status: 'processed'
};
const response = await agent
.patch(`/api/inbox/${inboxItem.id}`)
.send(updateData);
expect(response.status).toBe(200);
expect(response.body.content).toBe(updateData.content);
expect(response.body.status).toBe(updateData.status);
});
it('should return 404 for non-existent inbox item', async () => {
const response = await agent
.patch('/api/inbox/999999')
.send({ content: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Inbox item not found.');
});
it('should require authentication', async () => {
const response = await request(app)
.patch(`/api/inbox/${inboxItem.id}`)
.send({ content: 'Updated' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('DELETE /api/inbox/:id', () => {
let inboxItem;
beforeEach(async () => {
inboxItem = await InboxItem.create({
content: 'Test content',
user_id: user.id
});
});
it('should delete inbox item', async () => {
const response = await agent.delete(`/api/inbox/${inboxItem.id}`);
expect(response.status).toBe(200);
expect(response.body.message).toBe('Inbox item successfully deleted');
// Verify inbox item status is updated to deleted
const deletedItem = await InboxItem.findByPk(inboxItem.id);
expect(deletedItem).not.toBeNull();
expect(deletedItem.status).toBe('deleted');
});
it('should return 404 for non-existent inbox item', async () => {
const response = await agent.delete('/api/inbox/999999');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Inbox item not found.');
});
it('should require authentication', async () => {
const response = await request(app).delete(`/api/inbox/${inboxItem.id}`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('PATCH /api/inbox/:id/process', () => {
let inboxItem;
beforeEach(async () => {
inboxItem = await InboxItem.create({
content: 'Test content',
status: 'added',
user_id: user.id
});
});
it('should process inbox item', async () => {
const response = await agent.patch(`/api/inbox/${inboxItem.id}/process`);
expect(response.status).toBe(200);
expect(response.body.status).toBe('processed');
});
it('should return 404 for non-existent inbox item', async () => {
const response = await agent.patch('/api/inbox/999999/process');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Inbox item not found.');
});
it('should require authentication', async () => {
const response = await request(app).patch(`/api/inbox/${inboxItem.id}/process`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
});

View file

@ -0,0 +1,308 @@
const request = require('supertest');
const app = require('../../app');
const { Note, User, Project } = require('../../models');
const { createTestUser } = require('../helpers/testUtils');
describe('Notes Routes', () => {
let user, project, agent;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com'
});
project = await Project.create({
name: 'Test Project',
user_id: user.id
});
// Create authenticated agent
agent = request.agent(app);
await agent
.post('/api/login')
.send({
email: 'test@example.com',
password: 'password123'
});
});
describe('POST /api/note', () => {
it('should create a new note', async () => {
const noteData = {
title: 'Test Note',
content: 'This is a test note content',
project_id: project.id
};
const response = await agent
.post('/api/note')
.send(noteData);
expect(response.status).toBe(201);
expect(response.body.title).toBe(noteData.title);
expect(response.body.content).toBe(noteData.content);
expect(response.body.project_id).toBe(project.id);
expect(response.body.user_id).toBe(user.id);
});
it('should create note without project', async () => {
const noteData = {
title: 'Test Note',
content: 'This is a test note content'
};
const response = await agent
.post('/api/note')
.send(noteData);
expect(response.status).toBe(201);
expect(response.body.title).toBe(noteData.title);
expect(response.body.content).toBe(noteData.content);
expect(response.body.project_id).toBeNull();
expect(response.body.user_id).toBe(user.id);
});
it('should require authentication', async () => {
const noteData = {
title: 'Test Note',
content: 'Test content'
};
const response = await request(app)
.post('/api/note')
.send(noteData);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('GET /api/notes', () => {
let note1, note2;
beforeEach(async () => {
note1 = await Note.create({
title: 'Note 1',
content: 'First note content',
user_id: user.id,
project_id: project.id
});
note2 = await Note.create({
title: 'Note 2',
content: 'Second note content',
user_id: user.id
});
});
it('should get all user notes', async () => {
const response = await agent.get('/api/notes');
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBe(2);
expect(response.body.map(n => n.id)).toContain(note1.id);
expect(response.body.map(n => n.id)).toContain(note2.id);
});
it('should include project information', async () => {
const response = await agent.get('/api/notes');
expect(response.status).toBe(200);
const noteWithProject = response.body.find(n => n.id === note1.id);
expect(noteWithProject.Project).toBeDefined();
expect(noteWithProject.Project.name).toBe(project.name);
});
it('should return all notes when no filter is applied', async () => {
const response = await agent.get('/api/notes');
expect(response.status).toBe(200);
expect(response.body.length).toBe(2);
expect(response.body.map(n => n.id)).toContain(note1.id);
expect(response.body.map(n => n.id)).toContain(note2.id);
});
it('should require authentication', async () => {
const response = await request(app).get('/api/notes');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('GET /api/note/:id', () => {
let note;
beforeEach(async () => {
note = await Note.create({
title: 'Test Note',
content: 'Test content',
user_id: user.id,
project_id: project.id
});
});
it('should get note by id', async () => {
const response = await agent.get(`/api/note/${note.id}`);
expect(response.status).toBe(200);
expect(response.body.id).toBe(note.id);
expect(response.body.title).toBe(note.title);
expect(response.body.content).toBe(note.content);
});
it('should return 404 for non-existent note', async () => {
const response = await agent.get('/api/note/999999');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Note not found.');
});
it('should not allow access to other user\'s notes', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const otherNote = await Note.create({
title: 'Other Note',
user_id: otherUser.id
});
const response = await agent.get(`/api/note/${otherNote.id}`);
expect(response.status).toBe(404);
expect(response.body.error).toBe('Note not found.');
});
it('should require authentication', async () => {
const response = await request(app).get(`/api/note/${note.id}`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('PATCH /api/note/:id', () => {
let note;
beforeEach(async () => {
note = await Note.create({
title: 'Test Note',
content: 'Test content',
user_id: user.id
});
});
it('should update note', async () => {
const updateData = {
title: 'Updated Note',
content: 'Updated content',
project_id: project.id
};
const response = await agent
.patch(`/api/note/${note.id}`)
.send(updateData);
expect(response.status).toBe(200);
expect(response.body.title).toBe(updateData.title);
expect(response.body.content).toBe(updateData.content);
expect(response.body.project_id).toBe(project.id);
});
it('should return 404 for non-existent note', async () => {
const response = await agent
.patch('/api/note/999999')
.send({ title: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Note not found.');
});
it('should not allow updating other user\'s notes', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const otherNote = await Note.create({
title: 'Other Note',
user_id: otherUser.id
});
const response = await agent
.patch(`/api/note/${otherNote.id}`)
.send({ title: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Note not found.');
});
it('should require authentication', async () => {
const response = await request(app)
.patch(`/api/note/${note.id}`)
.send({ title: 'Updated' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('DELETE /api/note/:id', () => {
let note;
beforeEach(async () => {
note = await Note.create({
title: 'Test Note',
user_id: user.id
});
});
it('should delete note', async () => {
const response = await agent.delete(`/api/note/${note.id}`);
expect(response.status).toBe(200);
expect(response.body.message).toBe('Note deleted successfully.');
// Verify note is deleted
const deletedNote = await Note.findByPk(note.id);
expect(deletedNote).toBeNull();
});
it('should return 404 for non-existent note', async () => {
const response = await agent.delete('/api/note/999999');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Note not found.');
});
it('should not allow deleting other user\'s notes', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const otherNote = await Note.create({
title: 'Other Note',
user_id: otherUser.id
});
const response = await agent.delete(`/api/note/${otherNote.id}`);
expect(response.status).toBe(404);
expect(response.body.error).toBe('Note not found.');
});
it('should require authentication', async () => {
const response = await request(app).delete(`/api/note/${note.id}`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
});

View file

@ -0,0 +1,303 @@
const request = require('supertest');
const app = require('../../app');
const { Project, User, Area } = require('../../models');
const { createTestUser } = require('../helpers/testUtils');
describe('Projects Routes', () => {
let user, area, agent;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com'
});
area = await Area.create({
name: 'Work',
user_id: user.id
});
// Create authenticated agent
agent = request.agent(app);
await agent
.post('/api/login')
.send({
email: 'test@example.com',
password: 'password123'
});
});
describe('POST /api/project', () => {
it('should create a new project', async () => {
const projectData = {
name: 'Test Project',
description: 'Test Description',
active: true,
pin_to_sidebar: false,
priority: 1,
area_id: area.id
};
const response = await agent
.post('/api/project')
.send(projectData);
expect(response.status).toBe(201);
expect(response.body.name).toBe(projectData.name);
expect(response.body.description).toBe(projectData.description);
expect(response.body.active).toBe(projectData.active);
expect(response.body.pin_to_sidebar).toBe(projectData.pin_to_sidebar);
expect(response.body.priority).toBe(projectData.priority);
expect(response.body.area_id).toBe(area.id);
expect(response.body.user_id).toBe(user.id);
});
it('should require authentication', async () => {
const projectData = {
name: 'Test Project'
};
const response = await request(app)
.post('/api/project')
.send(projectData);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should require project name', async () => {
const projectData = {
description: 'Project without name'
};
const response = await agent
.post('/api/project')
.send(projectData);
expect(response.status).toBe(400);
});
});
describe('GET /api/projects', () => {
let project1, project2;
beforeEach(async () => {
project1 = await Project.create({
name: 'Project 1',
description: 'First project',
user_id: user.id,
area_id: area.id
});
project2 = await Project.create({
name: 'Project 2',
description: 'Second project',
user_id: user.id
});
});
it('should get all user projects', async () => {
const response = await agent.get('/api/projects');
expect(response.status).toBe(200);
expect(response.body.projects).toBeDefined();
expect(response.body.projects.length).toBe(2);
expect(response.body.projects.map(p => p.id)).toContain(project1.id);
expect(response.body.projects.map(p => p.id)).toContain(project2.id);
});
it('should include area information', async () => {
const response = await agent.get('/api/projects');
expect(response.status).toBe(200);
const projectWithArea = response.body.projects.find(p => p.id === project1.id);
expect(projectWithArea.Area).toBeDefined();
expect(projectWithArea.Area.name).toBe(area.name);
});
it('should require authentication', async () => {
const response = await request(app).get('/api/projects');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('GET /api/project/:id', () => {
let project;
beforeEach(async () => {
project = await Project.create({
name: 'Test Project',
description: 'Test Description',
user_id: user.id,
area_id: area.id
});
});
it('should get project by id', async () => {
const response = await agent.get(`/api/project/${project.id}`);
expect(response.status).toBe(200);
expect(response.body.id).toBe(project.id);
expect(response.body.name).toBe(project.name);
expect(response.body.description).toBe(project.description);
});
it('should return 404 for non-existent project', async () => {
const response = await agent.get('/api/project/999999');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Project not found');
});
it('should not allow access to other user\'s projects', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const otherProject = await Project.create({
name: 'Other Project',
user_id: otherUser.id
});
const response = await agent.get(`/api/project/${otherProject.id}`);
expect(response.status).toBe(404);
expect(response.body.error).toBe('Project not found');
});
it('should require authentication', async () => {
const response = await request(app).get(`/api/project/${project.id}`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('PATCH /api/project/:id', () => {
let project;
beforeEach(async () => {
project = await Project.create({
name: 'Test Project',
description: 'Test Description',
active: false,
priority: 0,
user_id: user.id
});
});
it('should update project', async () => {
const updateData = {
name: 'Updated Project',
description: 'Updated Description',
active: true,
priority: 2
};
const response = await agent
.patch(`/api/project/${project.id}`)
.send(updateData);
expect(response.status).toBe(200);
expect(response.body.name).toBe(updateData.name);
expect(response.body.description).toBe(updateData.description);
expect(response.body.active).toBe(updateData.active);
expect(response.body.priority).toBe(updateData.priority);
});
it('should return 404 for non-existent project', async () => {
const response = await agent
.patch('/api/project/999999')
.send({ name: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Project not found.');
});
it('should not allow updating other user\'s projects', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const otherProject = await Project.create({
name: 'Other Project',
user_id: otherUser.id
});
const response = await agent
.patch(`/api/project/${otherProject.id}`)
.send({ name: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Project not found.');
});
it('should require authentication', async () => {
const response = await request(app)
.patch(`/api/project/${project.id}`)
.send({ name: 'Updated' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('DELETE /api/project/:id', () => {
let project;
beforeEach(async () => {
project = await Project.create({
name: 'Test Project',
user_id: user.id
});
});
it('should delete project', async () => {
const response = await agent.delete(`/api/project/${project.id}`);
expect(response.status).toBe(200);
expect(response.body.message).toBe('Project successfully deleted');
// Verify project is deleted
const deletedProject = await Project.findByPk(project.id);
expect(deletedProject).toBeNull();
});
it('should return 404 for non-existent project', async () => {
const response = await agent.delete('/api/project/999999');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Project not found.');
});
it('should not allow deleting other user\'s projects', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const otherProject = await Project.create({
name: 'Other Project',
user_id: otherUser.id
});
const response = await agent.delete(`/api/project/${otherProject.id}`);
expect(response.status).toBe(404);
expect(response.body.error).toBe('Project not found.');
});
it('should require authentication', async () => {
const response = await request(app).delete(`/api/project/${project.id}`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
});

View file

@ -0,0 +1,168 @@
const request = require('supertest');
const app = require('../../app');
const { createTestUser } = require('../helpers/testUtils');
describe('Quotes Routes', () => {
let user, agent;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com'
});
// Create authenticated agent
agent = request.agent(app);
await agent
.post('/api/login')
.send({
email: 'test@example.com',
password: 'password123'
});
});
describe('GET /api/quotes/random', () => {
it('should return a random quote', async () => {
const response = await agent
.get('/api/quotes/random');
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('quote');
expect(typeof response.body.quote).toBe('string');
expect(response.body.quote.length).toBeGreaterThan(0);
});
it('should return different quotes on multiple requests', async () => {
const responses = await Promise.all([
agent.get('/api/quotes/random'),
agent.get('/api/quotes/random'),
agent.get('/api/quotes/random'),
agent.get('/api/quotes/random'),
agent.get('/api/quotes/random')
]);
// All responses should be successful
responses.forEach(response => {
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('quote');
expect(typeof response.body.quote).toBe('string');
});
// With multiple requests, we should get at least some variety
// (though it's possible to get the same quote multiple times due to randomness)
const quotes = responses.map(r => r.body.quote);
const uniqueQuotes = new Set(quotes);
// We expect at least 1 unique quote, but likely more
expect(uniqueQuotes.size).toBeGreaterThanOrEqual(1);
});
it('should return valid quote structure', async () => {
const response = await agent
.get('/api/quotes/random');
expect(response.status).toBe(200);
expect(Object.keys(response.body)).toEqual(['quote']);
expect(response.body.quote).toBeTruthy();
expect(response.body.quote.trim()).toBe(response.body.quote);
});
});
describe('GET /api/quotes', () => {
it('should return all quotes with count', async () => {
const response = await agent
.get('/api/quotes');
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('quotes');
expect(response.body).toHaveProperty('count');
expect(Array.isArray(response.body.quotes)).toBe(true);
expect(typeof response.body.count).toBe('number');
expect(response.body.quotes.length).toBe(response.body.count);
expect(response.body.count).toBeGreaterThan(0);
});
it('should return valid quote array', async () => {
const response = await agent
.get('/api/quotes');
expect(response.status).toBe(200);
// All quotes should be non-empty strings
response.body.quotes.forEach(quote => {
expect(typeof quote).toBe('string');
expect(quote.length).toBeGreaterThan(0);
expect(quote.trim()).toBe(quote);
});
});
it('should return consistent data across requests', async () => {
const response1 = await agent.get('/api/quotes');
const response2 = await agent.get('/api/quotes');
expect(response1.status).toBe(200);
expect(response2.status).toBe(200);
// The quotes array should be the same across requests
expect(response1.body.quotes.length).toBe(response2.body.quotes.length);
expect(response1.body.count).toBe(response2.body.count);
// Verify the actual content is the same
expect(response1.body.quotes).toEqual(response2.body.quotes);
});
it('should return expected quote count', async () => {
const response = await agent
.get('/api/quotes');
expect(response.status).toBe(200);
// Based on the configuration, we expect 20 quotes, but allow for fallback quotes
expect(response.body.count).toBeGreaterThanOrEqual(5);
expect(response.body.quotes.length).toBe(response.body.count);
});
it('should contain productivity-focused quotes', async () => {
const response = await agent
.get('/api/quotes');
expect(response.status).toBe(200);
// Look for some productivity-related keywords in the quotes
const allQuotesText = response.body.quotes.join(' ').toLowerCase();
// These are common themes in productivity quotes
const productivityKeywords = [
'progress', 'task', 'goal', 'focus', 'accomplish',
'success', 'work', 'effort', 'achieve', 'time'
];
// At least some quotes should contain productivity-related terms
const hasProductivityContent = productivityKeywords.some(keyword =>
allQuotesText.includes(keyword)
);
expect(hasProductivityContent).toBe(true);
});
});
describe('Quote randomness and consistency', () => {
it('should have random quotes that are part of the full quote set', async () => {
// Get all quotes first
const allQuotesResponse = await agent.get('/api/quotes');
const allQuotes = allQuotesResponse.body.quotes;
// Get several random quotes
const randomQuoteResponses = await Promise.all([
agent.get('/api/quotes/random'),
agent.get('/api/quotes/random'),
agent.get('/api/quotes/random')
]);
// Each random quote should be from the full set
randomQuoteResponses.forEach(response => {
expect(response.status).toBe(200);
expect(allQuotes).toContain(response.body.quote);
});
});
});
});

View file

@ -0,0 +1,270 @@
const request = require('supertest');
const app = require('../../app');
const { Tag, User } = require('../../models');
const { createTestUser } = require('../helpers/testUtils');
describe('Tags Routes', () => {
let user, agent;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com'
});
// Create authenticated agent
agent = request.agent(app);
await agent
.post('/api/login')
.send({
email: 'test@example.com',
password: 'password123'
});
});
describe('POST /api/tag', () => {
it('should create a new tag', async () => {
const tagData = {
name: 'work'
};
const response = await agent
.post('/api/tag')
.send(tagData);
expect(response.status).toBe(201);
expect(response.body.name).toBe(tagData.name);
expect(response.body.id).toBeDefined();
});
it('should require authentication', async () => {
const tagData = {
name: 'work'
};
const response = await request(app)
.post('/api/tag')
.send(tagData);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should require tag name', async () => {
const tagData = {};
const response = await agent
.post('/api/tag')
.send(tagData);
expect(response.status).toBe(400);
expect(response.body.error).toBe('Tag name is required');
});
});
describe('GET /api/tags', () => {
let tag1, tag2;
beforeEach(async () => {
tag1 = await Tag.create({
name: 'work',
user_id: user.id
});
tag2 = await Tag.create({
name: 'personal',
user_id: user.id
});
});
it('should get all user tags', async () => {
const response = await agent.get('/api/tags');
expect(response.status).toBe(200);
expect(response.body).toHaveLength(2);
expect(response.body.map(t => t.id)).toContain(tag1.id);
expect(response.body.map(t => t.id)).toContain(tag2.id);
});
it('should order tags by name', async () => {
const response = await agent.get('/api/tags');
expect(response.status).toBe(200);
expect(response.body[0].name).toBe('personal'); // P comes before W
expect(response.body[1].name).toBe('work');
});
it('should require authentication', async () => {
const response = await request(app).get('/api/tags');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('GET /api/tag/:id', () => {
let tag;
beforeEach(async () => {
tag = await Tag.create({
name: 'work',
user_id: user.id
});
});
it('should get tag by id', async () => {
const response = await agent.get(`/api/tag/${tag.id}`);
expect(response.status).toBe(200);
expect(response.body.id).toBe(tag.id);
expect(response.body.name).toBe(tag.name);
});
it('should return 404 for non-existent tag', async () => {
const response = await agent.get('/api/tag/999999');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Tag not found');
});
it('should not allow access to other user\'s tags', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const otherTag = await Tag.create({
name: 'other-tag',
user_id: otherUser.id
});
const response = await agent.get(`/api/tag/${otherTag.id}`);
expect(response.status).toBe(404);
expect(response.body.error).toBe('Tag not found');
});
it('should require authentication', async () => {
const response = await request(app).get(`/api/tag/${tag.id}`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('PATCH /api/tag/:id', () => {
let tag;
beforeEach(async () => {
tag = await Tag.create({
name: 'work',
user_id: user.id
});
});
it('should update tag', async () => {
const updateData = {
name: 'updated-work'
};
const response = await agent
.patch(`/api/tag/${tag.id}`)
.send(updateData);
expect(response.status).toBe(200);
expect(response.body.name).toBe(updateData.name);
});
it('should return 404 for non-existent tag', async () => {
const response = await agent
.patch('/api/tag/999999')
.send({ name: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Tag not found');
});
it('should not allow updating other user\'s tags', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const otherTag = await Tag.create({
name: 'other-tag',
user_id: otherUser.id
});
const response = await agent
.patch(`/api/tag/${otherTag.id}`)
.send({ name: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Tag not found');
});
it('should require authentication', async () => {
const response = await request(app)
.patch(`/api/tag/${tag.id}`)
.send({ name: 'Updated' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('DELETE /api/tag/:id', () => {
let tag;
beforeEach(async () => {
tag = await Tag.create({
name: 'work',
user_id: user.id
});
});
it('should delete tag', async () => {
const response = await agent.delete(`/api/tag/${tag.id}`);
expect(response.status).toBe(200);
expect(response.body.message).toBe('Tag successfully deleted');
// Verify tag is deleted
const deletedTag = await Tag.findByPk(tag.id);
expect(deletedTag).toBeNull();
});
it('should return 404 for non-existent tag', async () => {
const response = await agent.delete('/api/tag/999999');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Tag not found');
});
it('should not allow deleting other user\'s tags', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const otherTag = await Tag.create({
name: 'other-tag',
user_id: otherUser.id
});
const response = await agent.delete(`/api/tag/${otherTag.id}`);
expect(response.status).toBe(404);
expect(response.body.error).toBe('Tag not found');
});
it('should require authentication', async () => {
const response = await request(app).delete(`/api/tag/${tag.id}`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
});

View file

@ -0,0 +1,274 @@
const request = require('supertest');
const app = require('../../app');
const { Task, User } = require('../../models');
const { createTestUser } = require('../helpers/testUtils');
describe('Tasks Routes', () => {
let user, agent;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com'
});
// Create authenticated agent
agent = request.agent(app);
await agent
.post('/api/login')
.send({
email: 'test@example.com',
password: 'password123'
});
});
describe('POST /api/task', () => {
it('should create a new task', async () => {
const taskData = {
name: 'Test Task',
note: 'Test Note',
priority: 1,
status: 0
};
const response = await agent
.post('/api/task')
.send(taskData);
expect(response.status).toBe(201);
expect(response.body.id).toBeDefined();
expect(response.body.name).toBe(taskData.name);
expect(response.body.note).toBe(taskData.note);
expect(response.body.priority).toBe(taskData.priority);
expect(response.body.status).toBe(taskData.status);
expect(response.body.user_id).toBe(user.id);
});
it('should require authentication', async () => {
const taskData = {
name: 'Test Task'
};
const response = await request(app)
.post('/api/task')
.send(taskData);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should require task name', async () => {
// Mock console.error to suppress expected error log in test output
const originalConsoleError = console.error;
console.error = jest.fn();
const taskData = {
description: 'Test Description'
};
const response = await agent
.post('/api/task')
.send(taskData);
expect(response.status).toBe(400);
// Restore original console.error
console.error = originalConsoleError;
});
});
describe('GET /api/tasks', () => {
let task1, task2;
beforeEach(async () => {
task1 = await Task.create({
name: 'Task 1',
description: 'Description 1',
user_id: user.id,
today: true
});
task2 = await Task.create({
name: 'Task 2',
description: 'Description 2',
user_id: user.id,
today: false
});
});
it('should get all user tasks', async () => {
const response = await agent.get('/api/tasks');
expect(response.status).toBe(200);
expect(response.body.tasks).toBeDefined();
expect(response.body.tasks.length).toBe(2);
expect(response.body.tasks.map(t => t.id)).toContain(task1.id);
expect(response.body.tasks.map(t => t.id)).toContain(task2.id);
});
it('should filter today tasks (returns all user tasks)', async () => {
const response = await agent.get('/api/tasks?type=today');
expect(response.status).toBe(200);
expect(response.body.tasks).toBeDefined();
expect(response.body.tasks.length).toBe(2);
// Both tasks should be returned as "today" doesn't filter by the today field
});
it('should require authentication', async () => {
const response = await request(app).get('/api/tasks');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
// Note: No individual task GET route exists in the current API
describe('PATCH /api/task/:id', () => {
let task;
beforeEach(async () => {
task = await Task.create({
name: 'Test Task',
description: 'Test Description',
priority: 0,
status: 0,
user_id: user.id
});
});
it('should update task', async () => {
const updateData = {
name: 'Updated Task',
note: 'Updated Note',
priority: 2,
status: 1
};
const response = await agent
.patch(`/api/task/${task.id}`)
.send(updateData);
expect(response.status).toBe(200);
expect(response.body.id).toBeDefined();
expect(response.body.name).toBe(updateData.name);
expect(response.body.note).toBe(updateData.note);
expect(response.body.priority).toBe(updateData.priority);
expect(response.body.status).toBe(updateData.status);
});
it('should return 404 for non-existent task', async () => {
const response = await agent
.patch('/api/task/999999')
.send({ name: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Task not found.');
});
it('should not allow updating other user\'s tasks', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const otherTask = await Task.create({
name: 'Other Task',
user_id: otherUser.id
});
const response = await agent
.patch(`/api/task/${otherTask.id}`)
.send({ name: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Task not found.');
});
it('should require authentication', async () => {
const response = await request(app)
.patch(`/api/task/${task.id}`)
.send({ name: 'Updated' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('DELETE /api/task/:id', () => {
let task;
beforeEach(async () => {
task = await Task.create({
name: 'Test Task',
user_id: user.id
});
});
it('should delete task', async () => {
const response = await agent.delete(`/api/task/${task.id}`);
expect(response.status).toBe(200);
expect(response.body.message).toBe('Task successfully deleted');
// Verify task is deleted
const deletedTask = await Task.findByPk(task.id);
expect(deletedTask).toBeNull();
});
it('should return 404 for non-existent task', async () => {
const response = await agent.delete('/api/task/999999');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Task not found.');
});
it('should not allow deleting other user\'s tasks', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const otherTask = await Task.create({
name: 'Other Task',
user_id: otherUser.id
});
const response = await agent.delete(`/api/task/${otherTask.id}`);
expect(response.status).toBe(404);
expect(response.body.error).toBe('Task not found.');
});
it('should require authentication', async () => {
const response = await request(app).delete(`/api/task/${task.id}`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('Task with tags', () => {
it('should create task with tags', async () => {
const taskData = {
name: 'Test Task',
tags: [
{ name: 'work' },
{ name: 'urgent' }
]
};
const response = await agent
.post('/api/task')
.send(taskData);
expect(response.status).toBe(201);
expect(response.body.Tags).toBeDefined();
expect(response.body.Tags.length).toBe(2);
expect(response.body.Tags.map(t => t.name)).toContain('work');
expect(response.body.Tags.map(t => t.name)).toContain('urgent');
});
});
});

View file

@ -0,0 +1,152 @@
const request = require('supertest');
const app = require('../../app');
const { User } = require('../../models');
const { createTestUser } = require('../helpers/testUtils');
describe('Telegram Routes', () => {
let user, agent;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com'
});
// Create authenticated agent
agent = request.agent(app);
await agent
.post('/api/login')
.send({
email: 'test@example.com',
password: 'password123'
});
});
describe('POST /api/telegram/setup', () => {
it('should setup telegram bot token', async () => {
const botToken = '123456789:ABCdefGHIjklMNOPQRSTUVwxyz-12345678';
const response = await agent
.post('/api/telegram/setup')
.send({ token: botToken });
expect(response.status).toBe(200);
expect(response.body.message).toBe('Telegram bot token updated successfully');
// Verify token was saved to user
const updatedUser = await User.findByPk(user.id);
expect(updatedUser.telegram_bot_token).toBe(botToken);
});
it('should require authentication', async () => {
const response = await request(app)
.post('/api/telegram/setup')
.send({ token: '123456789:ABCdefGHIjklMNOPQRSTUVwxyz-1234567890' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should require token parameter', async () => {
const response = await agent
.post('/api/telegram/setup')
.send({});
expect(response.status).toBe(400);
expect(response.body.error).toBe('Telegram bot token is required.');
});
it('should validate token format', async () => {
const response = await agent
.post('/api/telegram/setup')
.send({ token: 'invalid-token-format' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Invalid Telegram bot token format.');
});
it('should validate token format with correct pattern', async () => {
// Test various invalid formats
const invalidTokens = [
'123456:short',
'notnum:ABCdefGHIjklMNOPQRSTUVwxyz-12345678',
'123456789-ABCdefGHIjklMNOPQRSTUVwxyz-12345678',
'123456789:',
':ABCdefGHIjklMNOPQRSTUVwxyz-12345678'
];
for (const token of invalidTokens) {
const response = await agent
.post('/api/telegram/setup')
.send({ token });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Invalid Telegram bot token format.');
}
});
it('should accept valid token formats', async () => {
const validTokens = [
'123456789:ABCdefGHIjklMNOPQRSTUVwxyz-12345678',
'987654321:XYZabcDEFghiJKLmnoPQRstUVW_09876543',
'555555555:abcdefghijklmnopqrstuvwxyzABCDEFGHI'
];
for (const token of validTokens) {
const response = await agent
.post('/api/telegram/setup')
.send({ token });
expect(response.status).toBe(200);
expect(response.body.message).toBe('Telegram bot token updated successfully');
}
});
});
describe('POST /api/telegram/start-polling', () => {
beforeEach(async () => {
// Setup bot token first
await user.update({
telegram_bot_token: '123456789:ABCdefGHIjklMNOPQRSTUVwxyz-12345678'
});
});
it('should require authentication', async () => {
const response = await request(app)
.post('/api/telegram/start-polling');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should require bot token to be configured', async () => {
// Remove bot token
await user.update({ telegram_bot_token: null });
const response = await agent
.post('/api/telegram/start-polling');
expect(response.status).toBe(400);
expect(response.body.error).toBe('Telegram bot token not set.');
});
});
describe('POST /api/telegram/stop-polling', () => {
it('should require authentication', async () => {
const response = await request(app)
.post('/api/telegram/stop-polling');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('GET /api/telegram/polling-status', () => {
it('should require authentication', async () => {
const response = await request(app)
.get('/api/telegram/polling-status');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
});

View file

@ -0,0 +1,178 @@
const request = require('supertest');
const app = require('../../app');
const { createTestUser } = require('../helpers/testUtils');
describe('URL Routes', () => {
let user, agent;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com'
});
// Create authenticated agent
agent = request.agent(app);
await agent
.post('/api/login')
.send({
email: 'test@example.com',
password: 'password123'
});
});
describe('GET /api/url/title', () => {
it('should require authentication', async () => {
const response = await request(app)
.get('/api/url/title')
.query({ url: 'https://example.com' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should require url parameter', async () => {
const response = await agent
.get('/api/url/title');
expect(response.status).toBe(400);
expect(response.body.error).toBe('URL parameter is required');
});
it('should return title for valid URL', async () => {
const response = await agent
.get('/api/url/title')
.query({ url: 'https://httpbin.org/html' });
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('url');
expect(response.body).toHaveProperty('title');
expect(response.body.url).toBe('https://httpbin.org/html');
// Title could be extracted or null depending on network conditions
expect(typeof response.body.title === 'string' || response.body.title === null).toBe(true);
}, 10000);
it('should handle URL without protocol', async () => {
const response = await agent
.get('/api/url/title')
.query({ url: 'httpbin.org/html' });
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('url');
expect(response.body).toHaveProperty('title');
expect(response.body.url).toBe('httpbin.org/html');
// Title could be extracted or null depending on network conditions
expect(typeof response.body.title === 'string' || response.body.title === null).toBe(true);
}, 10000);
it('should handle invalid URL gracefully', async () => {
const response = await agent
.get('/api/url/title')
.query({ url: 'not-a-valid-url' });
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('url');
expect(response.body).toHaveProperty('title');
expect(response.body.url).toBe('not-a-valid-url');
// Title could be null or error message
expect(response.body.title === null || typeof response.body.title === 'string').toBe(true);
});
it('should handle unreachable URL', async () => {
const response = await agent
.get('/api/url/title')
.query({ url: 'https://nonexistent-domain-12345.com' });
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('url');
expect(response.body).toHaveProperty('title');
expect(response.body.url).toBe('https://nonexistent-domain-12345.com');
expect(response.body.title).toBe(null);
});
});
describe('POST /api/url/extract-from-text', () => {
it('should require authentication', async () => {
const response = await request(app)
.post('/api/url/extract-from-text')
.send({ text: 'Check out https://example.com' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should require text parameter', async () => {
const response = await agent
.post('/api/url/extract-from-text')
.send({});
expect(response.status).toBe(400);
expect(response.body.error).toBe('Text parameter is required');
});
it('should extract URL from text and get title', async () => {
const testText = 'Check out this interesting site: https://httpbin.org/html';
const response = await agent
.post('/api/url/extract-from-text')
.send({ text: testText });
expect(response.status).toBe(200);
expect(response.body.found).toBe(true);
expect(response.body.url).toBe('https://httpbin.org/html');
expect(response.body.originalText).toBe(testText);
expect(response.body).toHaveProperty('title');
// Title could be extracted or null depending on network conditions
expect(typeof response.body.title === 'string' || response.body.title === null).toBe(true);
}, 10000);
it('should extract first URL when multiple URLs in text', async () => {
const testText = 'Check out https://httpbin.org/html and also https://example.com';
const response = await agent
.post('/api/url/extract-from-text')
.send({ text: testText });
expect(response.status).toBe(200);
expect(response.body.found).toBe(true);
expect(response.body.url).toBe('https://httpbin.org/html');
expect(response.body.originalText).toBe(testText);
expect(response.body).toHaveProperty('title');
}, 10000);
it('should return found false for URL without protocol', async () => {
const testText = 'Visit httpbin.org/html for testing';
const response = await agent
.post('/api/url/extract-from-text')
.send({ text: testText });
expect(response.status).toBe(200);
expect(response.body.found).toBe(false);
});
it('should return found false when no URL in text', async () => {
const testText = 'This text has no URLs in it at all';
const response = await agent
.post('/api/url/extract-from-text')
.send({ text: testText });
expect(response.status).toBe(200);
expect(response.body.found).toBe(false);
});
it('should handle empty text', async () => {
const response = await agent
.post('/api/url/extract-from-text')
.send({ text: '' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Text parameter is required');
});
it('should handle text with only whitespace', async () => {
const response = await agent
.post('/api/url/extract-from-text')
.send({ text: ' \n\t ' });
expect(response.status).toBe(200);
expect(response.body.found).toBe(false);
});
});
});

View file

@ -0,0 +1,283 @@
const request = require('supertest');
const app = require('../../app');
const { User } = require('../../models');
const { createTestUser } = require('../helpers/testUtils');
describe('Users Routes', () => {
let user, agent;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com'
});
// Create authenticated agent
agent = request.agent(app);
await agent
.post('/api/login')
.send({
email: 'test@example.com',
password: 'password123'
});
});
describe('GET /api/profile', () => {
it('should get user profile', async () => {
const response = await agent.get('/api/profile');
expect(response.status).toBe(200);
expect(response.body.id).toBe(user.id);
expect(response.body.email).toBe(user.email);
expect(response.body).toHaveProperty('appearance');
expect(response.body).toHaveProperty('language');
expect(response.body).toHaveProperty('timezone');
expect(response.body).toHaveProperty('avatar_image');
expect(response.body).toHaveProperty('telegram_bot_token');
expect(response.body).toHaveProperty('telegram_chat_id');
expect(response.body).toHaveProperty('task_summary_enabled');
expect(response.body).toHaveProperty('task_summary_frequency');
});
it('should require authentication', async () => {
const response = await request(app).get('/api/profile');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should return 401 when session user no longer exists', async () => {
await User.destroy({ where: { id: user.id } });
const response = await agent.get('/api/profile');
expect(response.status).toBe(401);
expect(response.body.error).toBe('User not found');
});
});
describe('PATCH /api/profile', () => {
it('should update user profile', async () => {
const updateData = {
appearance: 'dark',
language: 'es',
timezone: 'UTC',
avatar_image: 'new-avatar.png',
telegram_bot_token: 'new-token'
};
const response = await agent
.patch('/api/profile')
.send(updateData);
expect(response.status).toBe(200);
expect(response.body.appearance).toBe(updateData.appearance);
expect(response.body.language).toBe(updateData.language);
expect(response.body.timezone).toBe(updateData.timezone);
expect(response.body.avatar_image).toBe(updateData.avatar_image);
expect(response.body.telegram_bot_token).toBe(updateData.telegram_bot_token);
});
it('should allow partial updates', async () => {
const updateData = {
appearance: 'dark'
};
const response = await agent
.patch('/api/profile')
.send(updateData);
expect(response.status).toBe(200);
expect(response.body.appearance).toBe(updateData.appearance);
expect(response.body.language).toBe(user.language);
});
it('should require authentication', async () => {
const updateData = {
appearance: 'dark'
};
const response = await request(app)
.patch('/api/profile')
.send(updateData);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should return 401 when session user no longer exists', async () => {
await User.destroy({ where: { id: user.id } });
const response = await agent
.patch('/api/profile')
.send({ appearance: 'dark' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('User not found');
});
});
describe('POST /api/profile/task-summary/toggle', () => {
beforeEach(async () => {
await user.update({ task_summary_enabled: false });
});
it('should toggle task summary on', async () => {
const response = await agent.post('/api/profile/task-summary/toggle');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.enabled).toBe(true);
expect(response.body.message).toBe('Task summary notifications have been enabled.');
});
it('should toggle task summary off', async () => {
await user.update({ task_summary_enabled: true });
const response = await agent.post('/api/profile/task-summary/toggle');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.enabled).toBe(false);
expect(response.body.message).toBe('Task summary notifications have been disabled.');
});
it('should require authentication', async () => {
const response = await request(app).post('/api/profile/task-summary/toggle');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should return 401 when session user no longer exists', async () => {
await User.destroy({ where: { id: user.id } });
const response = await agent.post('/api/profile/task-summary/toggle');
expect(response.status).toBe(401);
expect(response.body.error).toBe('User not found');
});
});
describe('POST /api/profile/task-summary/frequency', () => {
it('should update task summary frequency', async () => {
const response = await agent
.post('/api/profile/task-summary/frequency')
.send({ frequency: 'daily' });
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.frequency).toBe('daily');
expect(response.body.message).toBe('Task summary frequency has been set to daily.');
});
it('should require frequency parameter', async () => {
const response = await agent
.post('/api/profile/task-summary/frequency')
.send({});
expect(response.status).toBe(400);
expect(response.body.error).toBe('Frequency is required.');
});
it('should validate frequency value', async () => {
const response = await agent
.post('/api/profile/task-summary/frequency')
.send({ frequency: 'invalid' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Invalid frequency value.');
});
it('should accept valid frequencies', async () => {
const validFrequencies = ['daily', 'weekdays', 'weekly', '1h', '2h', '4h', '8h', '12h'];
for (const frequency of validFrequencies) {
const response = await agent
.post('/api/profile/task-summary/frequency')
.send({ frequency });
expect(response.status).toBe(200);
expect(response.body.frequency).toBe(frequency);
}
});
it('should require authentication', async () => {
const response = await request(app)
.post('/api/profile/task-summary/frequency')
.send({ frequency: 'daily' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should return 401 when session user no longer exists', async () => {
await User.destroy({ where: { id: user.id } });
const response = await agent
.post('/api/profile/task-summary/frequency')
.send({ frequency: 'daily' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('User not found');
});
});
describe('POST /api/profile/task-summary/send-now', () => {
it('should require telegram configuration', async () => {
const response = await agent.post('/api/profile/task-summary/send-now');
expect(response.status).toBe(400);
expect(response.body.error).toBe('Telegram bot is not properly configured.');
});
it('should require authentication', async () => {
const response = await request(app).post('/api/profile/task-summary/send-now');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should return 401 when session user no longer exists', async () => {
await User.destroy({ where: { id: user.id } });
const response = await agent.post('/api/profile/task-summary/send-now');
expect(response.status).toBe(401);
expect(response.body.error).toBe('User not found');
});
});
describe('GET /api/profile/task-summary/status', () => {
it('should get task summary status', async () => {
await user.update({
task_summary_enabled: true,
task_summary_frequency: 'daily'
});
const response = await agent.get('/api/profile/task-summary/status');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.enabled).toBe(true);
expect(response.body.frequency).toBe('daily');
expect(response.body).toHaveProperty('last_run');
expect(response.body).toHaveProperty('next_run');
});
it('should require authentication', async () => {
const response = await request(app).get('/api/profile/task-summary/status');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should return 401 when session user no longer exists', async () => {
await User.destroy({ where: { id: user.id } });
const response = await agent.get('/api/profile/task-summary/status');
expect(response.status).toBe(401);
expect(response.body.error).toBe('User not found');
});
});
});

View file

@ -0,0 +1,130 @@
const { requireAuth } = require('../../../middleware/auth');
const { User } = require('../../../models');
describe('Auth Middleware', () => {
let req, res, next;
beforeEach(() => {
req = {
path: '/api/tasks',
session: {}
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};
next = jest.fn();
});
it('should skip authentication for health check', async () => {
req.path = '/api/health';
await requireAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should skip authentication for login route', async () => {
req.path = '/api/login';
await requireAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should skip authentication for current_user route', async () => {
req.path = '/api/current_user';
await requireAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should return 401 if no session', async () => {
req.session = null;
await requireAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({ error: 'Authentication required' });
expect(next).not.toHaveBeenCalled();
});
it('should return 401 if no userId in session', async () => {
req.session = {};
await requireAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({ error: 'Authentication required' });
expect(next).not.toHaveBeenCalled();
});
it('should return 401 and destroy session if user not found', async () => {
const bcrypt = require('bcrypt');
const user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
req.session = {
userId: user.id + 1, // Non-existent user ID
destroy: jest.fn()
};
await requireAuth(req, res, next);
expect(req.session.destroy).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({ error: 'User not found' });
expect(next).not.toHaveBeenCalled();
});
it('should set currentUser and call next for valid session', async () => {
const bcrypt = require('bcrypt');
const user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
req.session = {
userId: user.id
};
await requireAuth(req, res, next);
expect(req.currentUser).toBeDefined();
expect(req.currentUser.id).toBe(user.id);
expect(req.currentUser.email).toBe(user.email);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should handle database errors', async () => {
// Mock console.error to suppress expected error log in test output
const originalConsoleError = console.error;
console.error = jest.fn();
// Mock User.findByPk to throw an error
const originalFindByPk = User.findByPk;
User.findByPk = jest.fn().mockRejectedValue(new Error('Database connection error'));
req.session = {
userId: 123,
destroy: jest.fn()
};
await requireAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ error: 'Authentication error' });
expect(next).not.toHaveBeenCalled();
// Restore original methods
User.findByPk = originalFindByPk;
console.error = originalConsoleError;
});
});

View file

@ -0,0 +1,74 @@
const { Area, User } = require('../../../models');
describe('Area Model', () => {
let user;
beforeEach(async () => {
const bcrypt = require('bcrypt');
user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
});
describe('validation', () => {
it('should create an area with valid data', async () => {
const areaData = {
name: 'Work',
description: 'Work related projects',
user_id: user.id
};
const area = await Area.create(areaData);
expect(area.name).toBe(areaData.name);
expect(area.description).toBe(areaData.description);
expect(area.user_id).toBe(user.id);
});
it('should require name', async () => {
const areaData = {
description: 'Area without name',
user_id: user.id
};
await expect(Area.create(areaData)).rejects.toThrow();
});
it('should require user_id', async () => {
const areaData = {
name: 'Test Area'
};
await expect(Area.create(areaData)).rejects.toThrow();
});
it('should allow null description', async () => {
const areaData = {
name: 'Test Area',
user_id: user.id,
description: null
};
const area = await Area.create(areaData);
expect(area.description).toBeNull();
});
});
describe('associations', () => {
it('should belong to a user', async () => {
const area = await Area.create({
name: 'Test Area',
user_id: user.id
});
const areaWithUser = await Area.findByPk(area.id, {
include: [{ model: User }]
});
expect(areaWithUser.User).toBeDefined();
expect(areaWithUser.User.id).toBe(user.id);
expect(areaWithUser.User.email).toBe(user.email);
});
});
});

View file

@ -0,0 +1,96 @@
const { InboxItem, User } = require('../../../models');
describe('InboxItem Model', () => {
let user;
beforeEach(async () => {
const bcrypt = require('bcrypt');
user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
});
describe('validation', () => {
it('should create an inbox item with valid data', async () => {
const inboxData = {
content: 'Remember to buy groceries',
status: 'added',
source: 'web',
user_id: user.id
};
const inboxItem = await InboxItem.create(inboxData);
expect(inboxItem.content).toBe(inboxData.content);
expect(inboxItem.status).toBe(inboxData.status);
expect(inboxItem.source).toBe(inboxData.source);
expect(inboxItem.user_id).toBe(user.id);
});
it('should require content', async () => {
const inboxData = {
user_id: user.id
};
await expect(InboxItem.create(inboxData)).rejects.toThrow();
});
it('should require user_id', async () => {
const inboxData = {
content: 'Test content'
};
await expect(InboxItem.create(inboxData)).rejects.toThrow();
});
it('should require status', async () => {
const inboxData = {
content: 'Test content',
user_id: user.id,
status: null
};
await expect(InboxItem.create(inboxData)).rejects.toThrow();
});
it('should require source', async () => {
const inboxData = {
content: 'Test content',
user_id: user.id,
source: null
};
await expect(InboxItem.create(inboxData)).rejects.toThrow();
});
});
describe('default values', () => {
it('should set correct default values', async () => {
const inboxItem = await InboxItem.create({
content: 'Test content',
user_id: user.id
});
expect(inboxItem.status).toBe('added');
expect(inboxItem.source).toBe('tududi');
});
});
describe('associations', () => {
it('should belong to a user', async () => {
const inboxItem = await InboxItem.create({
content: 'Test content',
user_id: user.id
});
const inboxItemWithUser = await InboxItem.findByPk(inboxItem.id, {
include: [{ model: User }]
});
expect(inboxItemWithUser.User).toBeDefined();
expect(inboxItemWithUser.User.id).toBe(user.id);
expect(inboxItemWithUser.User.email).toBe(user.email);
});
});
});

View file

@ -0,0 +1,102 @@
const { Note, User, Project } = require('../../../models');
describe('Note Model', () => {
let user, project;
beforeEach(async () => {
const bcrypt = require('bcrypt');
user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
project = await Project.create({
name: 'Test Project',
user_id: user.id
});
});
describe('validation', () => {
it('should create a note with valid data', async () => {
const noteData = {
title: 'Test Note',
content: 'This is a test note content',
user_id: user.id,
project_id: project.id
};
const note = await Note.create(noteData);
expect(note.title).toBe(noteData.title);
expect(note.content).toBe(noteData.content);
expect(note.user_id).toBe(user.id);
expect(note.project_id).toBe(project.id);
});
it('should require user_id', async () => {
const noteData = {
title: 'Test Note',
content: 'Test content'
};
await expect(Note.create(noteData)).rejects.toThrow();
});
it('should allow title and content to be null', async () => {
const noteData = {
title: null,
content: null,
user_id: user.id
};
const note = await Note.create(noteData);
expect(note.title).toBeNull();
expect(note.content).toBeNull();
});
it('should allow project_id to be null', async () => {
const noteData = {
title: 'Test Note',
content: 'Test content',
user_id: user.id,
project_id: null
};
const note = await Note.create(noteData);
expect(note.project_id).toBeNull();
});
});
describe('associations', () => {
it('should belong to a user', async () => {
const note = await Note.create({
title: 'Test Note',
user_id: user.id
});
const noteWithUser = await Note.findByPk(note.id, {
include: [{ model: User }]
});
expect(noteWithUser.User).toBeDefined();
expect(noteWithUser.User.id).toBe(user.id);
expect(noteWithUser.User.email).toBe(user.email);
});
it('should belong to a project', async () => {
const note = await Note.create({
title: 'Test Note',
user_id: user.id,
project_id: project.id
});
const noteWithProject = await Note.findByPk(note.id, {
include: [{ model: Project }]
});
expect(noteWithProject.Project).toBeDefined();
expect(noteWithProject.Project.id).toBe(project.id);
expect(noteWithProject.Project.name).toBe(project.name);
});
});
});

View file

@ -0,0 +1,141 @@
const { Project, User, Area } = require('../../../models');
describe('Project Model', () => {
let user, area;
beforeEach(async () => {
const bcrypt = require('bcrypt');
user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
area = await Area.create({
name: 'Work',
user_id: user.id
});
});
describe('validation', () => {
it('should create a project with valid data', async () => {
const projectData = {
name: 'Test Project',
description: 'Test Description',
active: true,
pin_to_sidebar: false,
priority: 1,
user_id: user.id,
area_id: area.id
};
const project = await Project.create(projectData);
expect(project.name).toBe(projectData.name);
expect(project.description).toBe(projectData.description);
expect(project.active).toBe(projectData.active);
expect(project.pin_to_sidebar).toBe(projectData.pin_to_sidebar);
expect(project.priority).toBe(projectData.priority);
expect(project.user_id).toBe(user.id);
expect(project.area_id).toBe(area.id);
});
it('should require name', async () => {
const projectData = {
description: 'Project without name',
user_id: user.id
};
await expect(Project.create(projectData)).rejects.toThrow();
});
it('should require user_id', async () => {
const projectData = {
name: 'Test Project'
};
await expect(Project.create(projectData)).rejects.toThrow();
});
it('should validate priority range', async () => {
const projectData = {
name: 'Test Project',
user_id: user.id,
priority: 5
};
await expect(Project.create(projectData)).rejects.toThrow();
});
it('should allow valid priority values', async () => {
for (let priority of [0, 1, 2]) {
const project = await Project.create({
name: `Test Project ${priority}`,
user_id: user.id,
priority: priority
});
expect(project.priority).toBe(priority);
}
});
});
describe('default values', () => {
it('should set correct default values', async () => {
const project = await Project.create({
name: 'Test Project',
user_id: user.id
});
expect(project.active).toBe(false);
expect(project.pin_to_sidebar).toBe(false);
});
});
describe('optional fields', () => {
it('should allow optional fields to be null', async () => {
const project = await Project.create({
name: 'Test Project',
user_id: user.id,
description: null,
priority: null,
due_date_at: null,
area_id: null
});
expect(project.description).toBeNull();
expect(project.priority).toBeNull();
expect(project.due_date_at).toBeNull();
expect(project.area_id).toBeNull();
});
});
describe('associations', () => {
it('should belong to a user', async () => {
const project = await Project.create({
name: 'Test Project',
user_id: user.id
});
const projectWithUser = await Project.findByPk(project.id, {
include: [{ model: User }]
});
expect(projectWithUser.User).toBeDefined();
expect(projectWithUser.User.id).toBe(user.id);
});
it('should belong to an area', async () => {
const project = await Project.create({
name: 'Test Project',
user_id: user.id,
area_id: area.id
});
const projectWithArea = await Project.findByPk(project.id, {
include: [{ model: Area }]
});
expect(projectWithArea.Area).toBeDefined();
expect(projectWithArea.Area.id).toBe(area.id);
});
});
});

View file

@ -0,0 +1,83 @@
const { Tag, User } = require('../../../models');
describe('Tag Model', () => {
let user;
beforeEach(async () => {
const bcrypt = require('bcrypt');
user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
});
describe('validation', () => {
it('should create a tag with valid data', async () => {
const tagData = {
name: 'work',
user_id: user.id
};
const tag = await Tag.create(tagData);
expect(tag.name).toBe(tagData.name);
expect(tag.user_id).toBe(user.id);
});
it('should require name', async () => {
const tagData = {
user_id: user.id
};
await expect(Tag.create(tagData)).rejects.toThrow();
});
it('should require user_id', async () => {
const tagData = {
name: 'work'
};
await expect(Tag.create(tagData)).rejects.toThrow();
});
it('should allow multiple tags with same name for different users', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const tag1 = await Tag.create({
name: 'work',
user_id: user.id
});
const tag2 = await Tag.create({
name: 'work',
user_id: otherUser.id
});
expect(tag1.name).toBe('work');
expect(tag2.name).toBe('work');
expect(tag1.user_id).toBe(user.id);
expect(tag2.user_id).toBe(otherUser.id);
});
});
describe('associations', () => {
it('should belong to a user', async () => {
const tag = await Tag.create({
name: 'work',
user_id: user.id
});
const tagWithUser = await Tag.findByPk(tag.id, {
include: [{ model: User }]
});
expect(tagWithUser.User).toBeDefined();
expect(tagWithUser.User.id).toBe(user.id);
expect(tagWithUser.User.email).toBe(user.email);
});
});
});

View file

@ -0,0 +1,183 @@
const { Task, User } = require('../../../models');
describe('Task Model', () => {
let user;
beforeEach(async () => {
const bcrypt = require('bcrypt');
user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
});
describe('validation', () => {
it('should create a task with valid data', async () => {
const taskData = {
name: 'Test Task',
description: 'Test Description',
user_id: user.id
};
const task = await Task.create(taskData);
expect(task.name).toBe(taskData.name);
expect(task.description).toBe(taskData.description);
expect(task.user_id).toBe(user.id);
expect(task.today).toBe(false);
expect(task.priority).toBe(0);
expect(task.status).toBe(0);
expect(task.recurrence_type).toBe('none');
});
it('should require name', async () => {
const taskData = {
user_id: user.id
};
await expect(Task.create(taskData)).rejects.toThrow();
});
it('should require user_id', async () => {
const taskData = {
name: 'Test Task'
};
await expect(Task.create(taskData)).rejects.toThrow();
});
it('should validate priority range', async () => {
const taskData = {
name: 'Test Task',
user_id: user.id,
priority: 5
};
await expect(Task.create(taskData)).rejects.toThrow();
});
it('should validate status range', async () => {
const taskData = {
name: 'Test Task',
user_id: user.id,
status: 10
};
await expect(Task.create(taskData)).rejects.toThrow();
});
});
describe('constants', () => {
it('should have correct priority constants', () => {
expect(Task.PRIORITY.LOW).toBe(0);
expect(Task.PRIORITY.MEDIUM).toBe(1);
expect(Task.PRIORITY.HIGH).toBe(2);
});
it('should have correct status constants', () => {
expect(Task.STATUS.NOT_STARTED).toBe(0);
expect(Task.STATUS.IN_PROGRESS).toBe(1);
expect(Task.STATUS.DONE).toBe(2);
expect(Task.STATUS.ARCHIVED).toBe(3);
expect(Task.STATUS.WAITING).toBe(4);
});
});
describe('instance methods', () => {
let task;
beforeEach(async () => {
task = await Task.create({
name: 'Test Task',
user_id: user.id
});
});
it('should return correct priority name', async () => {
task.priority = Task.PRIORITY.LOW;
expect(task.getPriorityName()).toBe('low');
task.priority = Task.PRIORITY.MEDIUM;
expect(task.getPriorityName()).toBe('medium');
task.priority = Task.PRIORITY.HIGH;
expect(task.getPriorityName()).toBe('high');
});
it('should return correct status name', async () => {
task.status = Task.STATUS.NOT_STARTED;
expect(task.getStatusName()).toBe('not_started');
task.status = Task.STATUS.IN_PROGRESS;
expect(task.getStatusName()).toBe('in_progress');
task.status = Task.STATUS.DONE;
expect(task.getStatusName()).toBe('done');
task.status = Task.STATUS.ARCHIVED;
expect(task.getStatusName()).toBe('archived');
task.status = Task.STATUS.WAITING;
expect(task.getStatusName()).toBe('waiting');
});
});
describe('default values', () => {
it('should set correct default values', async () => {
const task = await Task.create({
name: 'Test Task',
user_id: user.id
});
expect(task.today).toBe(false);
expect(task.priority).toBe(0);
expect(task.status).toBe(0);
expect(task.recurrence_type).toBe('none');
});
});
describe('optional fields', () => {
it('should allow optional fields to be null', async () => {
const task = await Task.create({
name: 'Test Task',
user_id: user.id,
description: null,
due_date: null,
note: null,
recurrence_interval: null,
recurrence_end_date: null,
last_generated_date: null,
project_id: null
});
expect(task.description).toBeNull();
expect(task.due_date).toBeNull();
expect(task.note).toBeNull();
expect(task.recurrence_interval).toBeNull();
expect(task.recurrence_end_date).toBeNull();
expect(task.last_generated_date).toBeNull();
expect(task.project_id).toBeNull();
});
it('should accept optional field values', async () => {
const dueDate = new Date();
const task = await Task.create({
name: 'Test Task',
description: 'Test Description',
due_date: dueDate,
today: true,
priority: Task.PRIORITY.HIGH,
status: Task.STATUS.IN_PROGRESS,
note: 'Test Note',
user_id: user.id
});
expect(task.description).toBe('Test Description');
expect(task.due_date).toEqual(dueDate);
expect(task.today).toBe(true);
expect(task.priority).toBe(Task.PRIORITY.HIGH);
expect(task.status).toBe(Task.STATUS.IN_PROGRESS);
expect(task.note).toBe('Test Note');
});
});
});

View file

@ -0,0 +1,135 @@
const { User } = require('../../../models');
describe('User Model', () => {
describe('validation', () => {
it('should create a user with valid data', async () => {
const bcrypt = require('bcrypt');
const userData = {
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10)
};
const user = await User.create(userData);
expect(user.email).toBe(userData.email);
expect(user.password_digest).toBeDefined();
expect(user.password_digest).toBe(userData.password_digest);
expect(user.appearance).toBe('light');
expect(user.language).toBe('en');
expect(user.timezone).toBe('UTC');
});
it('should require email', async () => {
const userData = {
password: 'password123'
};
await expect(User.create(userData)).rejects.toThrow();
});
it('should require valid email format', async () => {
const userData = {
email: 'invalid-email',
password: 'password123'
};
await expect(User.create(userData)).rejects.toThrow();
});
it('should require unique email', async () => {
const bcrypt = require('bcrypt');
const userData = {
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10)
};
await User.create(userData);
await expect(User.create(userData)).rejects.toThrow();
});
it('should validate appearance values', async () => {
const userData = {
email: 'test@example.com',
password: 'password123',
appearance: 'invalid'
};
await expect(User.create(userData)).rejects.toThrow();
});
it('should validate task_summary_frequency values', async () => {
const userData = {
email: 'test@example.com',
password: 'password123',
task_summary_frequency: 'invalid'
};
await expect(User.create(userData)).rejects.toThrow();
});
});
describe('password methods', () => {
let user;
beforeEach(async () => {
const bcrypt = require('bcrypt');
user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
});
it('should hash password on creation', async () => {
expect(user.password_digest).toBeDefined();
expect(user.password_digest).not.toBe('password123');
});
it('should check password correctly', async () => {
const isValid = await user.checkPassword('password123');
expect(isValid).toBe(true);
const isInvalid = await user.checkPassword('wrongpassword');
expect(isInvalid).toBe(false);
});
it('should set new password using setPassword method', async () => {
const oldPasswordDigest = user.password_digest;
await user.setPassword('newpassword');
expect(user.password_digest).not.toBe(oldPasswordDigest);
const isValidNew = await user.checkPassword('newpassword');
expect(isValidNew).toBe(true);
const isValidOld = await user.checkPassword('password123');
expect(isValidOld).toBe(false);
});
it('should hash password on update', async () => {
const oldPasswordDigest = user.password_digest;
user.password = 'newpassword';
await user.save();
expect(user.password_digest).not.toBe(oldPasswordDigest);
const isValidNew = await user.checkPassword('newpassword');
expect(isValidNew).toBe(true);
});
});
describe('default values', () => {
it('should set correct default values', async () => {
const bcrypt = require('bcrypt');
const user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
expect(user.appearance).toBe('light');
expect(user.language).toBe('en');
expect(user.timezone).toBe('UTC');
expect(user.task_summary_enabled).toBe(false);
expect(user.task_summary_frequency).toBe('daily');
});
});
});

View file

@ -1,2 +0,0 @@
require './app'
run Sinatra::Application

Some files were not shown because too many files have changed in this diff Show more