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:
parent
7a5fe2b11c
commit
3c1209a5a9
167 changed files with 24985 additions and 9335 deletions
8
.babelrc
8
.babelrc
|
|
@ -1,8 +0,0 @@
|
||||||
{
|
|
||||||
"presets": [
|
|
||||||
"@babel/preset-env",
|
|
||||||
"@babel/preset-react",
|
|
||||||
"@babel/preset-typescript"
|
|
||||||
],
|
|
||||||
"plugins": ["react-refresh/babel"]
|
|
||||||
}
|
|
||||||
116
.dockerignore
116
.dockerignore
|
|
@ -1,6 +1,110 @@
|
||||||
db/*.sqlite3
|
# ============================================================================
|
||||||
*.sqlite3
|
# Optimized .dockerignore for multi-stage build
|
||||||
*.sqlite3-shm
|
# ============================================================================
|
||||||
*.sqlite3-wal
|
|
||||||
certs/
|
# Node modules (installed in container)
|
||||||
.DS_Store
|
**/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
2
.gitignore
vendored
|
|
@ -10,3 +10,5 @@ node_modules
|
||||||
|
|
||||||
public/js/bundle.js
|
public/js/bundle.js
|
||||||
.aider*
|
.aider*
|
||||||
|
|
||||||
|
backend/coverage/
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
Metrics/ClassLength:
|
|
||||||
Max: 500
|
|
||||||
Metrics/BlockLength:
|
|
||||||
Max: 50
|
|
||||||
Metrics/MethodLength:
|
|
||||||
Max: 50
|
|
||||||
Style/Documentation:
|
|
||||||
Enabled: false
|
|
||||||
232
Dockerfile
232
Dockerfile
|
|
@ -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
|
# Stage 1: Frontend Build Environment (optimized)
|
||||||
RUN apt-get update -qq && \
|
FROM node:20-alpine AS frontend-builder
|
||||||
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
|
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install Ruby dependencies first
|
# Copy frontend package files
|
||||||
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 package*.json ./
|
COPY package*.json ./
|
||||||
COPY webpack.config.js ./
|
COPY webpack.config.js babel.config.js tsconfig.json postcss.config.js tailwind.config.js ./
|
||||||
COPY babel.config.js ./
|
|
||||||
COPY tsconfig.json ./
|
|
||||||
COPY postcss.config.js ./
|
|
||||||
COPY tailwind.config.js ./
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
# Remove any existing development databases
|
# Install frontend dependencies (including dev deps for build)
|
||||||
RUN rm -f db/development*
|
RUN npm install --ignore-scripts --no-audit --no-fund && \
|
||||||
|
npm cache clean --force && \
|
||||||
|
rm -rf ~/.npm
|
||||||
|
|
||||||
# Copy application files
|
# Copy frontend source code
|
||||||
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/ frontend/
|
COPY frontend/ frontend/
|
||||||
COPY public/ public/
|
COPY public/ public/
|
||||||
COPY src/ src/
|
|
||||||
|
|
||||||
# Create non-root user for security
|
# Build frontend assets with optimizations
|
||||||
RUN useradd -m -U app && \
|
RUN NODE_ENV=production npm run build && \
|
||||||
chown -R app:app /usr/src/app
|
# 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
|
USER app
|
||||||
|
|
||||||
# Expose ports for both frontend (8080) and backend (9292)
|
# Expose port
|
||||||
EXPOSE 8080 9292
|
EXPOSE 3002
|
||||||
|
|
||||||
# Set production environment variables
|
# Set optimized production environment variables
|
||||||
ENV RACK_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
NODE_ENV=production \
|
PORT=3002 \
|
||||||
TUDUDI_INTERNAL_SSL_ENABLED=false \
|
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" \
|
TUDUDI_ALLOWED_ORIGINS="http://localhost:8080,http://localhost:3002,http://127.0.0.1:8080,http://127.0.0.1:3002" \
|
||||||
LANG=C.UTF-8 \
|
TUDUDI_SESSION_SECRET="" \
|
||||||
TZ=UTC
|
TUDUDI_USER_EMAIL="" \
|
||||||
|
TUDUDI_USER_PASSWORD="" \
|
||||||
|
DISABLE_TELEGRAM=false \
|
||||||
|
DISABLE_SCHEDULER=false
|
||||||
|
|
||||||
# Generate SSL certificates if needed
|
# Minimal healthcheck
|
||||||
RUN mkdir -p certs && \
|
HEALTHCHECK --interval=60s --timeout=3s --start-period=10s --retries=2 \
|
||||||
if [ "$TUDUDI_INTERNAL_SSL_ENABLED" = "true" ]; then \
|
CMD curl -sf http://localhost:3002/api/health || exit 1
|
||||||
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
|
|
||||||
|
|
||||||
# Add healthcheck for backend
|
# Use dumb-init for proper signal handling
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
CMD curl -f http://localhost:9292/api/health || exit 1
|
CMD ["/app/start.sh"]
|
||||||
|
|
||||||
# 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"]
|
|
||||||
28
Gemfile
28
Gemfile
|
|
@ -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
|
|
||||||
146
Gemfile.lock
146
Gemfile.lock
|
|
@ -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
181
README.md
|
|
@ -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 they’re managing individual tasks, larger projects, or keeping detailed notes.
|
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 they’re managing individual tasks, larger projects, or keeping detailed notes.
|
||||||
|
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ 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.
|
- **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
|
## 🛠️ 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:
|
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_INTERNAL_SSL_ENABLED` - Set to 'true' if using HTTPS internally (default: false)
|
||||||
- `TUDUDI_ALLOWED_ORIGINS` - Controls CORS access for different deployment scenarios:
|
- `TUDUDI_ALLOWED_ORIGINS` - Controls CORS access for different deployment scenarios:
|
||||||
- Not set: Only allows localhost origins
|
- 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 `""`
|
- Allow all (development only): Set to empty string `""`
|
||||||
|
|
||||||
#### Common Configuration Examples:
|
#### Common Configuration Examples:
|
||||||
|
|
@ -89,23 +90,31 @@ docker run \
|
||||||
-e TUDUDI_USER_PASSWORD=mysecurepassword \
|
-e TUDUDI_USER_PASSWORD=mysecurepassword \
|
||||||
-e TUDUDI_SESSION_SECRET=$(openssl rand -hex 64) \
|
-e TUDUDI_SESSION_SECRET=$(openssl rand -hex 64) \
|
||||||
-e TUDUDI_INTERNAL_SSL_ENABLED=false \
|
-e TUDUDI_INTERNAL_SSL_ENABLED=false \
|
||||||
-e TUDUDI_ALLOWED_ORIGINS=https://tududi,http://tududi:9292 \
|
-e TUDUDI_ALLOWED_ORIGINS=https://tududi,http://tududi:3002 \
|
||||||
-v ~/tududi_db:/usr/src/app/tududi_db \
|
-v ~/tududi_db:/usr/src/app/backend/db \
|
||||||
-p 9292:9292 \
|
-p 3002:3002 \
|
||||||
-d chrisvel/tududi:latest
|
-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
|
## 🚧 Development
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
Before you begin, ensure you have the following installed:
|
Before you begin, ensure you have the following installed:
|
||||||
- Ruby (version 3.2.2 or higher)
|
- Node.js (version 20 or higher)
|
||||||
- Sinatra
|
- Express.js
|
||||||
- SQLite3
|
- SQLite3
|
||||||
- Puma
|
- npm
|
||||||
- ReactJS
|
- ReactJS
|
||||||
|
|
||||||
### 🏗 Installation
|
### 🏗 Installation
|
||||||
|
|
@ -120,59 +129,165 @@ To install `tududi`, follow these steps:
|
||||||
```bash
|
```bash
|
||||||
cd tududi
|
cd tududi
|
||||||
```
|
```
|
||||||
3. Install the required gems:
|
3. Install the required dependencies:
|
||||||
```bash
|
```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:
|
1. Create and enter the directory:
|
||||||
```bash
|
```bash
|
||||||
mkdir certs
|
mkdir backend/certs
|
||||||
cd certs
|
cd backend/certs
|
||||||
```
|
```
|
||||||
2. Create the key and cert:
|
2. Create the key and cert:
|
||||||
```bash
|
```bash
|
||||||
openssl genrsa -out server.key 2048
|
openssl genrsa -out server.key 2048
|
||||||
openssl req -new -x509 -key server.key -out server.crt -days 365
|
openssl req -new -x509 -key server.key -out server.crt -days 365
|
||||||
|
cd ../..
|
||||||
```
|
```
|
||||||
|
|
||||||
### 📂 Database Setup
|
### 📂 Database Setup
|
||||||
|
|
||||||
Execute the migrations:
|
The database will be automatically initialized when you start the Express backend. For manual database operations:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rake db:migrate
|
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:
|
For schema changes, use Sequelize migrations (similar to Rails/Ruby migrations):
|
||||||
```bash
|
|
||||||
rake console
|
```bash
|
||||||
```
|
cd backend
|
||||||
2. Add the user:
|
|
||||||
```ruby
|
# Create a new migration
|
||||||
User.create(email: "myemail@somewhere.com", password: "awes0meHax0Rp4ssword")
|
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
|
### 🚀 Usage
|
||||||
|
|
||||||
To start the application, run:
|
To start the application for development:
|
||||||
|
|
||||||
```bash
|
1. **Start the Express backend** (in one terminal):
|
||||||
puma -C app/config/puma.rb
|
```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
|
### 🔍 Testing
|
||||||
|
|
||||||
To run tests, execute:
|
To run tests:
|
||||||
|
|
||||||
```bash
|
```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
|
## 🤝 Contributing
|
||||||
|
|
||||||
Contributions to `tududi` are welcome. To contribute:
|
Contributions to `tududi` are welcome. To contribute:
|
||||||
|
|
|
||||||
12
Rakefile
12
Rakefile
|
|
@ -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
208
app.rb
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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
4
backend/.env.test
Normal 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
8
backend/.sequelizerc
Normal 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
167
backend/app.js
Normal 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;
|
||||||
42
backend/config/database.js
Normal file
42
backend/config/database.js
Normal 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
25
backend/jest.config.js
Normal 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
|
||||||
|
};
|
||||||
30
backend/middleware/auth.js
Normal file
30
backend/middleware/auth.js
Normal 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
|
||||||
|
};
|
||||||
61
backend/migrations/20250615000001-create-users.js
Normal file
61
backend/migrations/20250615000001-create-users.js
Normal 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
36
backend/models/area.js
Normal 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;
|
||||||
|
};
|
||||||
42
backend/models/inbox_item.js
Normal file
42
backend/models/inbox_item.js
Normal 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
93
backend/models/index.js
Normal 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
47
backend/models/note.js
Normal 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
69
backend/models/project.js
Normal 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
32
backend/models/tag.js
Normal 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
127
backend/models/task.js
Normal 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
100
backend/models/user.js
Normal 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
8045
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
52
backend/package.json
Normal file
52
backend/package.json
Normal 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
130
backend/routes/areas.js
Normal 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
78
backend/routes/auth.js
Normal 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
157
backend/routes/inbox.js
Normal 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
242
backend/routes/notes.js
Normal 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
275
backend/routes/projects.js
Normal 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
30
backend/routes/quotes.js
Normal 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
132
backend/routes/tags.js
Normal 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
394
backend/routes/tasks.js
Normal 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
113
backend/routes/telegram.js
Normal 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
156
backend/routes/url.js
Normal 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(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/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
206
backend/routes/users.js
Normal 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
27
backend/scripts/db-init.js
Executable 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
27
backend/scripts/db-migrate.js
Executable 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
27
backend/scripts/db-reset.js
Executable 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
69
backend/scripts/db-status.js
Executable 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
26
backend/scripts/db-sync.js
Executable 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();
|
||||||
108
backend/scripts/migration-create.js
Executable file
108
backend/scripts/migration-create.js
Executable 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
66
backend/scripts/user-create.js
Executable 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();
|
||||||
65
backend/services/quotesService.js
Normal file
65
backend/services/quotesService.js
Normal 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();
|
||||||
181
backend/services/taskScheduler.js
Normal file
181
backend/services/taskScheduler.js
Normal 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;
|
||||||
238
backend/services/taskSummaryService.js
Normal file
238
backend/services/taskSummaryService.js
Normal 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;
|
||||||
43
backend/services/telegramInitializer.js
Normal file
43
backend/services/telegramInitializer.js
Normal 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 };
|
||||||
261
backend/services/telegramPoller.js
Normal file
261
backend/services/telegramPoller.js
Normal 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
11
backend/start.sh
Executable 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
82
backend/tests/README.md
Normal 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
|
||||||
39
backend/tests/helpers/setup.js
Normal file
39
backend/tests/helpers/setup.js
Normal 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);
|
||||||
28
backend/tests/helpers/testUtils.js
Normal file
28
backend/tests/helpers/testUtils.js
Normal 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
|
||||||
|
};
|
||||||
280
backend/tests/integration/areas.test.js
Normal file
280
backend/tests/integration/areas.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
155
backend/tests/integration/auth.test.js
Normal file
155
backend/tests/integration/auth.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
275
backend/tests/integration/inbox.test.js
Normal file
275
backend/tests/integration/inbox.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
308
backend/tests/integration/notes.test.js
Normal file
308
backend/tests/integration/notes.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
303
backend/tests/integration/projects.test.js
Normal file
303
backend/tests/integration/projects.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
168
backend/tests/integration/quotes.test.js
Normal file
168
backend/tests/integration/quotes.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
270
backend/tests/integration/tags.test.js
Normal file
270
backend/tests/integration/tags.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
274
backend/tests/integration/tasks.test.js
Normal file
274
backend/tests/integration/tasks.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
152
backend/tests/integration/telegram.test.js
Normal file
152
backend/tests/integration/telegram.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
178
backend/tests/integration/url.test.js
Normal file
178
backend/tests/integration/url.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
283
backend/tests/integration/users.test.js
Normal file
283
backend/tests/integration/users.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
130
backend/tests/unit/middleware/auth.test.js
Normal file
130
backend/tests/unit/middleware/auth.test.js
Normal 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;
|
||||||
|
});
|
||||||
|
});
|
||||||
74
backend/tests/unit/models/area.test.js
Normal file
74
backend/tests/unit/models/area.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
96
backend/tests/unit/models/inbox_item.test.js
Normal file
96
backend/tests/unit/models/inbox_item.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
102
backend/tests/unit/models/note.test.js
Normal file
102
backend/tests/unit/models/note.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
141
backend/tests/unit/models/project.test.js
Normal file
141
backend/tests/unit/models/project.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
83
backend/tests/unit/models/tag.test.js
Normal file
83
backend/tests/unit/models/tag.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
183
backend/tests/unit/models/task.test.js
Normal file
183
backend/tests/unit/models/task.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
135
backend/tests/unit/models/user.test.js
Normal file
135
backend/tests/unit/models/user.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue