'use strict'; const { Op } = require('sequelize'); const moment = require('moment-timezone'); const { Task, Tag, Project, sequelize } = require('../../models'); const searchRepository = require('./repository'); const { parseSearchParams, priorityToInt } = require('./validation'); const { serializeTasks } = require('../tasks/core/serializers'); const { UnauthorizedError } = require('../../shared/errors'); class SearchService { /** * Build date range condition for due/defer filters. */ buildDateCondition(filterValue, startOfToday, fieldName) { if (!filterValue) return null; let startDate, endDate; switch (filterValue) { case 'today': startDate = startOfToday.clone(); endDate = startOfToday.clone().endOf('day'); break; case 'tomorrow': startDate = startOfToday.clone().add(1, 'day'); endDate = startOfToday.clone().add(1, 'day').endOf('day'); break; case 'next_week': startDate = startOfToday.clone(); endDate = startOfToday.clone().add(7, 'days').endOf('day'); break; case 'next_month': startDate = startOfToday.clone(); endDate = startOfToday.clone().add(1, 'month').endOf('day'); break; default: return null; } return { [fieldName]: { [Op.between]: [startDate.toDate(), endDate.toDate()], }, }; } /** * Build task search conditions. */ buildTaskConditions( userId, params, dueDateCondition, deferDateCondition, nowDate ) { const { searchQuery, priority, recurring, extras, excludeSubtasks } = params; const conditions = { user_id: userId }; const extraConditions = []; if (excludeSubtasks) { conditions.parent_task_id = null; conditions.recurring_parent_id = null; } if (searchQuery) { const lowerQuery = searchQuery.toLowerCase(); conditions[Op.or] = [ sequelize.where( sequelize.fn('LOWER', sequelize.col('Task.name')), { [Op.like]: `%${lowerQuery}%`, } ), sequelize.where( sequelize.fn('LOWER', sequelize.col('Task.note')), { [Op.like]: `%${lowerQuery}%`, } ), ]; } if (priority) { const priorityInt = priorityToInt(priority); if (priorityInt !== null) { conditions.priority = priorityInt; } } if (dueDateCondition) { extraConditions.push(dueDateCondition); } if (deferDateCondition) { extraConditions.push(deferDateCondition); } if (recurring) { switch (recurring) { case 'recurring': conditions.recurrence_type = { [Op.ne]: 'none' }; conditions.recurring_parent_id = null; break; case 'non_recurring': conditions[Op.or] = [ { recurrence_type: 'none' }, { recurrence_type: null }, ]; conditions.recurring_parent_id = null; break; case 'instances': conditions.recurring_parent_id = { [Op.ne]: null }; break; } } if (extras.has('recurring')) { extraConditions.push({ [Op.or]: [ { recurrence_type: { [Op.ne]: 'none' } }, { recurring_parent_id: { [Op.ne]: null } }, ], }); } if (extras.has('overdue')) { extraConditions.push({ due_date: { [Op.lt]: nowDate } }); extraConditions.push({ completed_at: null }); } if (extras.has('has_content')) { extraConditions.push( sequelize.where( sequelize.fn( 'LENGTH', sequelize.fn('TRIM', sequelize.col('Task.note')) ), { [Op.gt]: 0 } ) ); } if (extras.has('deferred')) { extraConditions.push({ defer_until: { [Op.gt]: nowDate } }); } if (extras.has('assigned_to_project')) { extraConditions.push({ project_id: { [Op.ne]: null } }); } if (extraConditions.length > 0) { conditions[Op.and] = extraConditions; } return conditions; } /** * Build task include config. */ buildTaskInclude(tagIds, extras) { const include = [ { model: Project, attributes: ['id', 'uid', 'name'] }, { model: Task, as: 'Subtasks', include: [ { model: Tag, attributes: ['id', 'name', 'uid'], through: { attributes: [] }, }, ], }, ]; const requireTags = tagIds.length > 0 || extras.has('has_tags'); const tagInclude = { model: Tag, through: { attributes: [] }, attributes: ['id', 'name', 'uid'], required: requireTags, }; if (tagIds.length > 0) { tagInclude.where = { id: { [Op.in]: tagIds } }; } include.push(tagInclude); return { include, tagInclude: requireTags ? tagInclude : undefined }; } /** * Search tasks. */ async searchTasks( userId, params, tagIds, dueDateCondition, deferDateCondition, nowDate, timezone ) { const conditions = this.buildTaskConditions( userId, params, dueDateCondition, deferDateCondition, nowDate ); const { include, tagInclude } = this.buildTaskInclude( tagIds, params.extras ); let count = 0; if (params.hasPagination) { count = await searchRepository.countTasks( conditions, tagInclude ? [tagInclude] : undefined ); } const tasks = await searchRepository.findTasks( conditions, include, params.limit, params.offset ); const serializedTasks = await serializeTasks(tasks, timezone); return { count, results: serializedTasks.map((task) => ({ type: 'Task', ...task, description: task.note, })), }; } /** * Search projects. */ async searchProjects(userId, params, tagIds, dueDateCondition) { const { searchQuery, priority, extras, hasPagination, limit, offset } = params; const conditions = { user_id: userId }; if (searchQuery) { const lowerQuery = searchQuery.toLowerCase(); conditions[Op.or] = [ sequelize.where( sequelize.fn('LOWER', sequelize.col('Project.name')), { [Op.like]: `%${lowerQuery}%`, } ), sequelize.where( sequelize.fn('LOWER', sequelize.col('Project.description')), { [Op.like]: `%${lowerQuery}%`, } ), ]; } if (priority) { conditions.priority = priority; } if (dueDateCondition) { conditions.due_date_at = dueDateCondition.due_date; } const requireTags = tagIds.length > 0 || extras.has('has_tags'); const include = []; if (requireTags) { const tagInclude = { model: Tag, through: { attributes: [] }, attributes: [], required: true, }; if (tagIds.length > 0) { tagInclude.where = { id: { [Op.in]: tagIds } }; } include.push(tagInclude); } let count = 0; if (hasPagination) { count = await searchRepository.countProjects(conditions, include); } const projects = await searchRepository.findProjects( conditions, include, limit, offset ); return { count, results: projects.map((project) => ({ type: 'Project', id: project.id, uid: project.uid, name: project.name, description: project.description, priority: project.priority, status: project.status, })), }; } /** * Search areas. */ async searchAreas(userId, params) { const { searchQuery, hasPagination, limit, offset } = params; const conditions = { user_id: userId }; if (searchQuery) { const lowerQuery = searchQuery.toLowerCase(); conditions[Op.or] = [ sequelize.where( sequelize.fn('LOWER', sequelize.col('Area.name')), { [Op.like]: `%${lowerQuery}%`, } ), sequelize.where( sequelize.fn('LOWER', sequelize.col('Area.description')), { [Op.like]: `%${lowerQuery}%`, } ), ]; } let count = 0; if (hasPagination) { count = await searchRepository.countAreas(conditions); } const areas = await searchRepository.findAreas( conditions, limit, offset ); return { count, results: areas.map((area) => ({ type: 'Area', id: area.id, uid: area.uid, name: area.name, description: area.description, })), }; } /** * Search notes. */ async searchNotes(userId, params, tagIds) { const { searchQuery, hasPagination, limit, offset } = params; const conditions = { user_id: userId }; if (searchQuery) { const lowerQuery = searchQuery.toLowerCase(); conditions[Op.or] = [ sequelize.where( sequelize.fn('LOWER', sequelize.col('Note.title')), { [Op.like]: `%${lowerQuery}%`, } ), sequelize.where( sequelize.fn('LOWER', sequelize.col('Note.content')), { [Op.like]: `%${lowerQuery}%`, } ), ]; } const include = []; if (tagIds.length > 0) { include.push({ model: Tag, where: { id: { [Op.in]: tagIds } }, through: { attributes: [] }, attributes: [], required: true, }); } let count = 0; if (hasPagination) { count = await searchRepository.countNotes(conditions, include); } const notes = await searchRepository.findNotes( conditions, include, limit, offset ); return { count, results: notes.map((note) => ({ type: 'Note', id: note.id, uid: note.uid, name: note.title, title: note.title, description: note.content ? note.content.substring(0, 100) : '', })), }; } /** * Search tags. */ async searchTags(userId, params) { const { searchQuery, hasPagination, limit, offset } = params; const conditions = { user_id: userId }; if (searchQuery) { const lowerQuery = searchQuery.toLowerCase(); conditions[Op.and] = [ sequelize.where( sequelize.fn('LOWER', sequelize.col('Tag.name')), { [Op.like]: `%${lowerQuery}%`, } ), ]; } let count = 0; if (hasPagination) { count = await searchRepository.countTags(conditions); } const tags = await searchRepository.findTags(conditions, limit, offset); return { count, results: tags.map((tag) => ({ type: 'Tag', id: tag.id, uid: tag.uid, name: tag.name, })), }; } /** * Universal search across all entity types. */ async search(userId, query, timezone = 'UTC') { if (!userId) { throw new UnauthorizedError('Unauthorized'); } const params = parseSearchParams(query); const { filterTypes, tagNames, due, defer, hasPagination, limit, offset, } = params; // Find tag IDs if filtering by tags const tagIds = await searchRepository.findTagIdsByNames( userId, tagNames ); if (tagNames.length > 0 && tagIds.length === 0) { return { results: [] }; } // Calculate date conditions const nowMoment = moment().tz(timezone); const startOfToday = nowMoment.clone().startOf('day'); const nowDate = nowMoment.toDate(); const dueDateCondition = this.buildDateCondition( due, startOfToday, 'due_date' ); const deferDateCondition = this.buildDateCondition( defer, startOfToday, 'defer_until' ); const results = []; let totalCount = 0; // Search each entity type if (filterTypes.includes('Task')) { const taskResults = await this.searchTasks( userId, params, tagIds, dueDateCondition, deferDateCondition, nowDate, timezone ); results.push(...taskResults.results); totalCount += taskResults.count; } if (filterTypes.includes('Project')) { const projectResults = await this.searchProjects( userId, params, tagIds, dueDateCondition ); results.push(...projectResults.results); totalCount += projectResults.count; } if (filterTypes.includes('Area')) { const areaResults = await this.searchAreas(userId, params); results.push(...areaResults.results); totalCount += areaResults.count; } if (filterTypes.includes('Note')) { const noteResults = await this.searchNotes(userId, params, tagIds); results.push(...noteResults.results); totalCount += noteResults.count; } if (filterTypes.includes('Tag')) { const tagResults = await this.searchTags(userId, params); results.push(...tagResults.results); totalCount += tagResults.count; } if (hasPagination) { return { results, pagination: { total: totalCount, limit, offset, hasMore: offset + results.length < totalCount, }, }; } return { results }; } } module.exports = new SearchService();