tududi/backend/routes/notes.js
Chris 6fb87ac80a
Feat refactor tasks pt1 (#536)
* Refactor swagger docs

* Scaffold refactor

* Refactor crud tasks

* fixup! Refactor crud tasks

* Break down task layout

* fixup! Break down task layout

* fixup! fixup! Break down task layout

* Cleanup comments

* fixup! Cleanup comments

* Cleanup obsolete code

* Remove helpers
2025-11-15 14:02:06 +02:00

410 lines
13 KiB
JavaScript

const express = require('express');
const { Note, Tag, Project } = require('../models');
const { extractUidFromSlug, isValidUid } = require('../utils/slug-utils');
const { validateTagName } = require('../services/tagsService');
const router = express.Router();
const { getAuthenticatedUserId } = require('../utils/request-utils');
router.use((req, res, next) => {
const userId = getAuthenticatedUserId(req);
if (!userId) {
return res.status(401).json({ error: 'Authentication required' });
}
req.authUserId = userId;
next();
});
const permissionsService = require('../services/permissionsService');
const { hasAccess } = require('../middleware/authorize');
const _ = require('lodash');
const { logError } = require('../services/logService');
// Helper function to update note tags
async function updateNoteTags(note, tagsArray, userId) {
if (_.isEmpty(tagsArray)) {
await note.setTags([]);
return;
}
try {
// Validate and filter tag names
const validTagNames = [];
const invalidTags = [];
for (const name of tagsArray) {
const validation = validateTagName(name);
if (validation.valid) {
// Check for duplicates
if (!validTagNames.includes(validation.name)) {
validTagNames.push(validation.name);
}
} else {
invalidTags.push({ name, error: validation.error });
}
}
if (invalidTags.length > 0) {
throw new Error(
`Invalid tag names: ${invalidTags.map((t) => `"${t.name}" (${t.error})`).join(', ')}`
);
}
const tags = await Promise.all(
validTagNames.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) {
logError('Failed to update tags:', error.message);
throw error; // Re-throw to handle at route level
}
}
router.get('/notes', async (req, res) => {
try {
const orderBy = req.query.order_by || 'title:asc';
const [orderColumn, orderDirection] = orderBy.split(':');
const whereClause = await permissionsService.ownershipOrPermissionWhere(
'note',
req.authUserId
);
let includeClause = [
{
model: Tag,
attributes: ['name', 'uid'],
through: { attributes: [] },
},
{
model: Project,
required: false,
attributes: ['name', 'uid'],
},
];
// 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) {
logError('Error fetching notes:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.get(
'/note/:uidSlug',
hasAccess(
'ro',
'note',
async (req) => {
const uid = extractUidFromSlug(req.params.uidSlug);
// Check if note exists - return null if it doesn't (triggers 404)
const note = await Note.findOne({
where: { uid },
attributes: ['uid'],
});
return note ? note.uid : null;
},
{ notFoundMessage: 'Note not found.' }
),
async (req, res) => {
try {
const note = await Note.findOne({
where: { uid: extractUidFromSlug(req.params.uidSlug) },
include: [
{
model: Tag,
attributes: ['name', 'uid'],
through: { attributes: [] },
},
{
model: Project,
required: false,
attributes: ['name', 'uid'],
},
],
});
res.json(note);
} catch (error) {
logError('Error fetching note:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post('/note', async (req, res) => {
try {
const { title, content, project_uid, project_id, tags, color } =
req.body;
const noteAttributes = {
title,
content,
user_id: req.authUserId,
};
// Add color if provided
if (color !== undefined) {
noteAttributes.color = color;
}
// Support both project_uid (new) and project_id (legacy)
const projectIdentifier = project_uid || project_id;
// If project identifier is provided, validate access and assign
if (
projectIdentifier &&
!_.isEmpty(projectIdentifier.toString().trim())
) {
let project;
// Try to find by UID first (new way), then by ID (legacy)
if (project_uid) {
const projectUidValue = project_uid.toString().trim();
project = await Project.findOne({
where: { uid: projectUidValue },
});
} else {
// Legacy: find by numeric ID
project = await Project.findByPk(project_id);
}
if (!project) {
return res
.status(404)
.json({ error: 'Note project not found' });
}
// Check if user has write access to the project
const projectAccess = await permissionsService.getAccess(
req.authUserId,
'project',
project.uid
);
const isOwner = project.user_id === req.authUserId;
const canWrite =
isOwner || projectAccess === 'rw' || projectAccess === 'admin';
if (!canWrite) {
return res.status(403).json({ error: 'Forbidden' });
}
noteAttributes.project_id = project.id;
}
const note = await Note.create(noteAttributes);
// Handle tags - can be an 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.authUserId);
// Reload note with associations
const noteWithAssociations = await Note.findByPk(note.id, {
include: [
{
model: Tag,
attributes: ['name', 'uid'],
through: { attributes: [] },
},
{
model: Project,
required: false,
attributes: ['name', 'uid'],
},
],
});
res.status(201).json({
...noteWithAssociations.toJSON(),
uid: noteWithAssociations.uid,
});
} catch (error) {
logError('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],
});
}
});
router.patch(
'/note/:uid',
hasAccess(
'rw',
'note',
async (req) => {
const uid = extractUidFromSlug(req.params.uid);
// Check if note exists - return null if it doesn't (triggers 404)
const note = await Note.findOne({
where: { uid },
attributes: ['uid'],
});
return note ? note.uid : null;
},
{ notFoundMessage: 'Note not found.' }
),
async (req, res) => {
try {
const note = await Note.findOne({
where: { uid: req.params.uid },
});
const { title, content, project_uid, project_id, tags, color } =
req.body;
const updateData = {};
if (title !== undefined) updateData.title = title;
if (content !== undefined) updateData.content = content;
if (color !== undefined) updateData.color = color;
// Handle project assignment - support both project_uid (new) and project_id (legacy)
const projectIdentifier =
project_uid !== undefined ? project_uid : project_id;
if (projectIdentifier !== undefined) {
if (projectIdentifier && projectIdentifier.toString().trim()) {
let project;
// Try to find by UID first (new way), then by ID (legacy)
if (
project_uid !== undefined &&
typeof project_uid === 'string'
) {
const projectUidValue = project_uid.trim();
project = await Project.findOne({
where: { uid: projectUidValue },
});
} else if (project_id !== undefined) {
// Legacy: find by numeric ID
project = await Project.findByPk(project_id);
}
if (!project) {
return res
.status(400)
.json({ error: 'Invalid project.' });
}
const projectAccess = await permissionsService.getAccess(
req.authUserId,
'project',
project.uid
);
const isOwner = project.user_id === req.authUserId;
const canWrite =
isOwner ||
projectAccess === 'rw' ||
projectAccess === 'admin';
if (!canWrite) {
return res.status(403).json({ error: 'Forbidden' });
}
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.authUserId);
}
// Reload note with associations
const noteWithAssociations = await Note.findByPk(note.id, {
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
},
{
model: Project,
required: false,
attributes: ['id', 'name', 'uid'],
},
],
});
res.json(noteWithAssociations);
} catch (error) {
logError('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],
});
}
}
);
router.delete(
'/note/:uid',
hasAccess(
'rw',
'note',
async (req) => {
const uid = extractUidFromSlug(req.params.uid);
// Check if note exists - return null if it doesn't (triggers 404)
const note = await Note.findOne({
where: { uid },
attributes: ['uid'],
});
return note ? note.uid : null;
},
{ notFoundMessage: 'Note not found.' }
),
async (req, res) => {
try {
const note = await Note.findOne({
where: { uid: req.params.uid },
});
await note.destroy();
res.json({ message: 'Note deleted successfully.' });
} catch (error) {
logError('Error deleting note:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
);
module.exports = router;