tududi/backend/routes/search.js
Chris b0b613f7bd
Reorder elements (#687)
* Reorder elements

* Enhance global search
2025-12-09 10:51:51 +02:00

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;