623 lines
21 KiB
JavaScript
623 lines
21 KiB
JavaScript
const express = require('express');
|
|
const { Task, Tag, Project, Area, Note, sequelize } = require('../models');
|
|
const { Op } = require('sequelize');
|
|
const moment = require('moment-timezone');
|
|
const { serializeTasks } = require('./tasks/core/serializers');
|
|
const router = express.Router();
|
|
|
|
// Helper function to convert priority string to integer
|
|
const priorityToInt = (priorityStr) => {
|
|
const priorityMap = {
|
|
low: 0,
|
|
medium: 1,
|
|
high: 2,
|
|
};
|
|
return priorityMap[priorityStr] !== undefined
|
|
? priorityMap[priorityStr]
|
|
: null;
|
|
};
|
|
|
|
/**
|
|
* Universal search endpoint
|
|
* GET /api/search
|
|
* Query params:
|
|
* - q: search query string
|
|
* - filters: comma-separated list of entity types (Task,Project,Area,Note,Tag)
|
|
* - priority: filter by priority (low,medium,high)
|
|
* - due: filter by due date (today,tomorrow,next_week,next_month)
|
|
* - tags: comma-separated list of tag names to filter by
|
|
* - recurring: filter by recurrence type (recurring,non_recurring,instances)
|
|
* - extras: comma-separated list of extra filters (recurring,overdue,has_content,deferred,has_tags,assigned_to_project)
|
|
* - limit: number of results to return (default: 20)
|
|
* - offset: number of results to skip (default: 0)
|
|
* - excludeSubtasks: if 'true', exclude tasks that have a parent_task_id or recurring_parent_id
|
|
*/
|
|
router.get('/', async (req, res) => {
|
|
try {
|
|
const userId = req.currentUser?.id;
|
|
if (!userId) {
|
|
return res.status(401).json({ error: 'Unauthorized' });
|
|
}
|
|
|
|
const {
|
|
q: query,
|
|
filters,
|
|
priority,
|
|
due,
|
|
defer,
|
|
tags: tagsParam,
|
|
recurring,
|
|
extras: extrasParam,
|
|
limit: limitParam,
|
|
offset: offsetParam,
|
|
excludeSubtasks,
|
|
} = req.query;
|
|
const searchQuery = query ? query.trim() : '';
|
|
const filterTypes = filters
|
|
? filters.split(',').map((f) => f.trim())
|
|
: ['Task', 'Project', 'Area', 'Note', 'Tag'];
|
|
const tagNames = tagsParam
|
|
? tagsParam.split(',').map((t) => t.trim())
|
|
: [];
|
|
const extras =
|
|
extrasParam && typeof extrasParam === 'string'
|
|
? extrasParam
|
|
.split(',')
|
|
.map((extra) => extra.trim())
|
|
.filter(Boolean)
|
|
: [];
|
|
const extrasSet = new Set(extras);
|
|
const userTimezone = req.currentUser?.timezone || 'UTC';
|
|
const nowMoment = moment().tz(userTimezone);
|
|
const startOfToday = nowMoment.clone().startOf('day');
|
|
const nowDate = nowMoment.toDate();
|
|
|
|
// Pagination support
|
|
const hasPagination =
|
|
limitParam !== undefined || offsetParam !== undefined;
|
|
const limit = hasPagination ? parseInt(limitParam, 10) || 20 : 20;
|
|
const offset = hasPagination ? parseInt(offsetParam, 10) || 0 : 0;
|
|
|
|
const results = [];
|
|
let totalCount = 0;
|
|
|
|
// If tags are specified, find their IDs first
|
|
let tagIds = [];
|
|
if (tagNames.length > 0) {
|
|
const tags = await Tag.findAll({
|
|
where: {
|
|
user_id: userId,
|
|
name: { [Op.in]: tagNames },
|
|
},
|
|
attributes: ['id'],
|
|
});
|
|
tagIds = tags.map((tag) => tag.id);
|
|
|
|
// If no matching tags found, return empty results
|
|
if (tagIds.length === 0) {
|
|
return res.json({ results: [] });
|
|
}
|
|
}
|
|
|
|
// Calculate due date range based on filter
|
|
let dueDateCondition = null;
|
|
if (due) {
|
|
let startDate, endDate;
|
|
|
|
switch (due) {
|
|
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;
|
|
}
|
|
|
|
if (startDate && endDate) {
|
|
dueDateCondition = {
|
|
due_date: {
|
|
[Op.between]: [startDate.toDate(), endDate.toDate()],
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
// Calculate defer until date range based on filter
|
|
let deferDateCondition = null;
|
|
if (defer) {
|
|
let startDate, endDate;
|
|
|
|
switch (defer) {
|
|
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;
|
|
}
|
|
|
|
if (startDate && endDate) {
|
|
deferDateCondition = {
|
|
defer_until: {
|
|
[Op.between]: [startDate.toDate(), endDate.toDate()],
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
// Search Tasks
|
|
if (filterTypes.includes('Task')) {
|
|
const taskConditions = {
|
|
user_id: userId,
|
|
};
|
|
const taskExtraConditions = [];
|
|
|
|
// Exclude subtasks and recurring instances if requested
|
|
if (excludeSubtasks === 'true') {
|
|
taskConditions.parent_task_id = null;
|
|
taskConditions.recurring_parent_id = null;
|
|
}
|
|
|
|
// Add search query filter if specified
|
|
if (searchQuery) {
|
|
const lowerQuery = searchQuery.toLowerCase();
|
|
taskConditions[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}%` }
|
|
),
|
|
];
|
|
}
|
|
|
|
// Add priority filter if specified (convert string to integer)
|
|
if (priority) {
|
|
const priorityInt = priorityToInt(priority);
|
|
if (priorityInt !== null) {
|
|
taskConditions.priority = priorityInt;
|
|
}
|
|
}
|
|
|
|
// Add due date filter if specified
|
|
if (dueDateCondition) {
|
|
taskExtraConditions.push(dueDateCondition);
|
|
}
|
|
|
|
// Add defer until filter if specified
|
|
if (deferDateCondition) {
|
|
taskExtraConditions.push(deferDateCondition);
|
|
}
|
|
|
|
// Add recurring filter if specified
|
|
if (recurring) {
|
|
switch (recurring) {
|
|
case 'recurring':
|
|
// Show only recurring templates (not instances)
|
|
taskConditions.recurrence_type = { [Op.ne]: 'none' };
|
|
taskConditions.recurring_parent_id = null;
|
|
break;
|
|
case 'non_recurring':
|
|
// Show only non-recurring tasks (not templates or instances)
|
|
taskConditions[Op.or] = [
|
|
{ recurrence_type: 'none' },
|
|
{ recurrence_type: null },
|
|
];
|
|
taskConditions.recurring_parent_id = null;
|
|
break;
|
|
case 'instances':
|
|
// Show only recurring instances (spawned from templates)
|
|
taskConditions.recurring_parent_id = { [Op.ne]: null };
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (extrasSet.has('recurring')) {
|
|
taskExtraConditions.push({
|
|
[Op.or]: [
|
|
{ recurrence_type: { [Op.ne]: 'none' } },
|
|
{ recurring_parent_id: { [Op.ne]: null } },
|
|
],
|
|
});
|
|
}
|
|
|
|
if (extrasSet.has('overdue')) {
|
|
taskExtraConditions.push({
|
|
due_date: { [Op.lt]: nowDate },
|
|
});
|
|
taskExtraConditions.push({
|
|
completed_at: null,
|
|
});
|
|
}
|
|
|
|
if (extrasSet.has('has_content')) {
|
|
const noteHasContent = sequelize.where(
|
|
sequelize.fn(
|
|
'LENGTH',
|
|
sequelize.fn('TRIM', sequelize.col('Task.note'))
|
|
),
|
|
{ [Op.gt]: 0 }
|
|
);
|
|
const descriptionHasContent = sequelize.where(
|
|
sequelize.fn(
|
|
'LENGTH',
|
|
sequelize.fn('TRIM', sequelize.col('Task.description'))
|
|
),
|
|
{ [Op.gt]: 0 }
|
|
);
|
|
taskExtraConditions.push({
|
|
[Op.or]: [noteHasContent, descriptionHasContent],
|
|
});
|
|
}
|
|
|
|
if (extrasSet.has('deferred')) {
|
|
taskExtraConditions.push({
|
|
defer_until: { [Op.gt]: nowDate },
|
|
});
|
|
}
|
|
|
|
if (extrasSet.has('assigned_to_project')) {
|
|
taskExtraConditions.push({
|
|
project_id: { [Op.ne]: null },
|
|
});
|
|
}
|
|
|
|
if (taskExtraConditions.length > 0) {
|
|
if (taskConditions[Op.and]) {
|
|
taskConditions[Op.and].push(...taskExtraConditions);
|
|
} else {
|
|
taskConditions[Op.and] = taskExtraConditions;
|
|
}
|
|
}
|
|
|
|
const taskInclude = [
|
|
{
|
|
model: Project,
|
|
attributes: ['id', 'uid', 'name'],
|
|
},
|
|
{
|
|
model: Task,
|
|
as: 'Subtasks',
|
|
include: [
|
|
{
|
|
model: Tag,
|
|
attributes: ['id', 'name', 'uid'],
|
|
through: { attributes: [] },
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
const requireTags = tagIds.length > 0 || extrasSet.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 },
|
|
};
|
|
}
|
|
|
|
taskInclude.push(tagInclude);
|
|
|
|
// Count total tasks if pagination is requested
|
|
if (hasPagination) {
|
|
const countInclude = requireTags ? [tagInclude] : undefined;
|
|
totalCount += await Task.count({
|
|
where: taskConditions,
|
|
include: countInclude,
|
|
distinct: true,
|
|
});
|
|
}
|
|
|
|
const tasks = await Task.findAll({
|
|
where: taskConditions,
|
|
include: taskInclude,
|
|
limit: limit,
|
|
offset: offset,
|
|
order: [['updated_at', 'DESC']],
|
|
});
|
|
|
|
// Use proper serialization to include all task data
|
|
const serializedTasks = await serializeTasks(
|
|
tasks,
|
|
req.currentUser?.timezone || 'UTC'
|
|
);
|
|
|
|
results.push(
|
|
...serializedTasks.map((task) => ({
|
|
type: 'Task',
|
|
...task,
|
|
description: task.note,
|
|
}))
|
|
);
|
|
}
|
|
|
|
// Search Projects
|
|
if (filterTypes.includes('Project')) {
|
|
const projectConditions = {
|
|
user_id: userId,
|
|
};
|
|
|
|
if (searchQuery) {
|
|
const lowerQuery = searchQuery.toLowerCase();
|
|
projectConditions[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) {
|
|
projectConditions.priority = priority;
|
|
}
|
|
|
|
// Add due date filter if specified (projects use due_date_at field)
|
|
if (dueDateCondition) {
|
|
const projectDueCondition = {
|
|
due_date_at: dueDateCondition.due_date,
|
|
};
|
|
Object.assign(projectConditions, projectDueCondition);
|
|
}
|
|
|
|
const requireProjectTags =
|
|
tagIds.length > 0 || extrasSet.has('has_tags');
|
|
const projectInclude = [];
|
|
|
|
if (requireProjectTags) {
|
|
const projectTagInclude = {
|
|
model: Tag,
|
|
through: { attributes: [] },
|
|
attributes: [],
|
|
required: true,
|
|
};
|
|
|
|
if (tagIds.length > 0) {
|
|
projectTagInclude.where = {
|
|
id: { [Op.in]: tagIds },
|
|
};
|
|
}
|
|
|
|
projectInclude.push(projectTagInclude);
|
|
}
|
|
|
|
// Count total projects if pagination is requested
|
|
if (hasPagination) {
|
|
totalCount += await Project.count({
|
|
where: projectConditions,
|
|
include:
|
|
projectInclude.length > 0 ? projectInclude : undefined,
|
|
distinct: true,
|
|
});
|
|
}
|
|
|
|
const projects = await Project.findAll({
|
|
where: projectConditions,
|
|
include: projectInclude.length > 0 ? projectInclude : undefined,
|
|
limit: limit,
|
|
offset: offset,
|
|
order: [['updated_at', 'DESC']],
|
|
});
|
|
|
|
results.push(
|
|
...projects.map((project) => ({
|
|
type: 'Project',
|
|
id: project.id,
|
|
uid: project.uid,
|
|
name: project.name,
|
|
description: project.description,
|
|
priority: project.priority,
|
|
status: project.state,
|
|
}))
|
|
);
|
|
}
|
|
|
|
// Search Areas
|
|
if (filterTypes.includes('Area')) {
|
|
const areaConditions = {
|
|
user_id: userId,
|
|
};
|
|
|
|
if (searchQuery) {
|
|
const lowerQuery = searchQuery.toLowerCase();
|
|
areaConditions[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}%` }
|
|
),
|
|
];
|
|
}
|
|
|
|
// Count total areas if pagination is requested
|
|
if (hasPagination) {
|
|
totalCount += await Area.count({
|
|
where: areaConditions,
|
|
});
|
|
}
|
|
|
|
const areas = await Area.findAll({
|
|
where: areaConditions,
|
|
limit: limit,
|
|
offset: offset,
|
|
order: [['updated_at', 'DESC']],
|
|
});
|
|
|
|
results.push(
|
|
...areas.map((area) => ({
|
|
type: 'Area',
|
|
id: area.id,
|
|
uid: area.uid,
|
|
name: area.name,
|
|
description: area.description,
|
|
}))
|
|
);
|
|
}
|
|
|
|
// Search Notes
|
|
if (filterTypes.includes('Note')) {
|
|
const noteConditions = {
|
|
user_id: userId,
|
|
};
|
|
|
|
if (searchQuery) {
|
|
const lowerQuery = searchQuery.toLowerCase();
|
|
noteConditions[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 noteInclude = [];
|
|
|
|
// Add tag filter if specified
|
|
if (tagIds.length > 0) {
|
|
noteInclude.push({
|
|
model: Tag,
|
|
where: {
|
|
id: { [Op.in]: tagIds },
|
|
},
|
|
through: { attributes: [] },
|
|
attributes: [],
|
|
required: true,
|
|
});
|
|
}
|
|
|
|
// Count total notes if pagination is requested
|
|
if (hasPagination) {
|
|
totalCount += await Note.count({
|
|
where: noteConditions,
|
|
include: noteInclude.length > 0 ? noteInclude : undefined,
|
|
distinct: true,
|
|
});
|
|
}
|
|
|
|
const notes = await Note.findAll({
|
|
where: noteConditions,
|
|
include: noteInclude.length > 0 ? noteInclude : undefined,
|
|
limit: limit,
|
|
offset: offset,
|
|
order: [['updated_at', 'DESC']],
|
|
});
|
|
|
|
results.push(
|
|
...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
|
|
if (filterTypes.includes('Tag')) {
|
|
const tagConditions = {
|
|
user_id: userId,
|
|
};
|
|
|
|
if (searchQuery) {
|
|
const lowerQuery = searchQuery.toLowerCase();
|
|
tagConditions[Op.and] = [
|
|
sequelize.where(
|
|
sequelize.fn('LOWER', sequelize.col('Tag.name')),
|
|
{ [Op.like]: `%${lowerQuery}%` }
|
|
),
|
|
];
|
|
}
|
|
|
|
// Count total tags if pagination is requested
|
|
if (hasPagination) {
|
|
totalCount += await Tag.count({
|
|
where: tagConditions,
|
|
});
|
|
}
|
|
|
|
const tags = await Tag.findAll({
|
|
where: tagConditions,
|
|
limit: limit,
|
|
offset: offset,
|
|
order: [['name', 'ASC']],
|
|
});
|
|
|
|
results.push(
|
|
...tags.map((tag) => ({
|
|
type: 'Tag',
|
|
id: tag.id,
|
|
uid: tag.uid,
|
|
name: tag.name,
|
|
}))
|
|
);
|
|
}
|
|
|
|
// Return results with pagination metadata if requested
|
|
if (hasPagination) {
|
|
res.json({
|
|
results,
|
|
pagination: {
|
|
total: totalCount,
|
|
limit: limit,
|
|
offset: offset,
|
|
hasMore: offset + results.length < totalCount,
|
|
},
|
|
});
|
|
} else {
|
|
res.json({ results });
|
|
}
|
|
} catch (error) {
|
|
console.error('Search error:', error);
|
|
res.status(500).json({ error: 'Search failed' });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|