feat: version 2.10.0 -- Notas en tarjetas, crear/editar en popup, compartir de solo lectura
Rediseno completo del modulo Notas (era lista inline, sin popup, sin compartir):
- Tarjetas con resumen del contenido en vez de lista vertical; la primera tarjeta siempre es
un "+" para crear.
- Crear y editar se hacen en un DetailPopup (antes: textarea fijo arriba + edicion inline por
tarjeta).
- Nueva tabla note_shares (migracion 019): compartir una nota con otro usuario ya registrado,
siempre de solo lectura -- el dueño sigue siendo el unico que edita/borra/comparte (mismo
patron que project_members pero sin rol ni invitacion por correo, ya que acá no hace falta).
Quien recibe la nota la ve con "Compartida por {nombre}", sin ningun control de edicion.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
-- Compartir una nota con otro usuario, siempre de solo lectura (el dueño sigue siendo el unico
|
||||
-- que edita/borra/comparte). Analogo simplificado de project_members (sin columna de rol, sin
|
||||
-- invitacion por correo). Idempotente.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS note_shares (
|
||||
note_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
shared_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (note_id, user_id),
|
||||
CONSTRAINT fk_note_shares_note FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_note_shares_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "project-linkedcorp-backend",
|
||||
"version": "2.9.1",
|
||||
"version": "2.10.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/server.js",
|
||||
|
||||
@@ -17,3 +17,15 @@ export const deleteNote = asyncHandler(async (req, res) => {
|
||||
await service.removeNote(Number(req.params.id), req.currentUser);
|
||||
ok(res, { deleted: true });
|
||||
});
|
||||
|
||||
export const getNoteShares = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.listNoteShares(Number(req.params.id), req.currentUser));
|
||||
});
|
||||
|
||||
export const postNoteShare = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.shareNote(Number(req.params.id), Number(req.body.userId), req.currentUser), 201);
|
||||
});
|
||||
|
||||
export const deleteNoteShare = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.unshareNote(Number(req.params.id), Number(req.params.userId), req.currentUser));
|
||||
});
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { pool } from '../../config/db.js';
|
||||
|
||||
export async function findByUser(userId) {
|
||||
// Notas propias MAS las compartidas conmigo. is_owner permite al frontend decidir si se puede
|
||||
// editar/borrar/compartir (siempre false para una nota compartida: el dueno nunca cambia).
|
||||
export async function findVisibleToUser(userId) {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, user_id, content, created_at, updated_at FROM notes WHERE user_id = ? ORDER BY updated_at DESC',
|
||||
[userId]
|
||||
`SELECT n.id, n.user_id, n.content, n.created_at, n.updated_at,
|
||||
(n.user_id = ?) AS is_owner, u.name AS owner_name
|
||||
FROM notes n
|
||||
JOIN users u ON u.id = n.user_id
|
||||
WHERE n.user_id = ? OR n.id IN (SELECT note_id FROM note_shares WHERE user_id = ?)
|
||||
ORDER BY n.updated_at DESC`,
|
||||
[userId, userId, userId]
|
||||
);
|
||||
return rows;
|
||||
return rows.map((r) => ({ ...r, is_owner: !!r.is_owner }));
|
||||
}
|
||||
|
||||
export async function findById(id) {
|
||||
@@ -26,3 +33,28 @@ export async function update(id, content) {
|
||||
export async function remove(id) {
|
||||
await pool.query('DELETE FROM notes WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
export async function listShares(noteId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT ns.user_id, u.name, u.email, ns.shared_at
|
||||
FROM note_shares ns JOIN users u ON u.id = ns.user_id
|
||||
WHERE ns.note_id = ? ORDER BY u.name`,
|
||||
[noteId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function isSharedWith(noteId, userId) {
|
||||
const [rows] = await pool.query('SELECT 1 FROM note_shares WHERE note_id = ? AND user_id = ?', [noteId, userId]);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
export async function addShare(noteId, userId) {
|
||||
await pool.query('INSERT IGNORE INTO note_shares (note_id, user_id) VALUES (?, ?)', [noteId, userId]);
|
||||
return listShares(noteId);
|
||||
}
|
||||
|
||||
export async function removeShare(noteId, userId) {
|
||||
await pool.query('DELETE FROM note_shares WHERE note_id = ? AND user_id = ?', [noteId, userId]);
|
||||
return listShares(noteId);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../../middleware/auth.js';
|
||||
import { getNotes, postNote, putNote, deleteNote } from './controller.js';
|
||||
import { getNotes, postNote, putNote, deleteNote, getNoteShares, postNoteShare, deleteNoteShare } from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Sin restriccion de rol: cualquier usuario autenticado tiene sus propias notas.
|
||||
// Sin restriccion de rol: cualquier usuario autenticado tiene sus propias notas (y ve las que
|
||||
// otros compartieron con el). Los permisos de compartir se resuelven en el service (dueño-only).
|
||||
router.use(requireAuth);
|
||||
router.get('/', getNotes);
|
||||
router.post('/', postNote);
|
||||
router.put('/:id', putNote);
|
||||
router.delete('/:id', deleteNote);
|
||||
router.get('/:id/shares', getNoteShares);
|
||||
router.post('/:id/shares', postNoteShare);
|
||||
router.delete('/:id/shares/:userId', deleteNoteShare);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import * as repo from './repository.js';
|
||||
import * as usersRepo from '../users/repository.js';
|
||||
import { HttpError } from '../../utils/http.js';
|
||||
import { logAudit } from '../../utils/audit.js';
|
||||
|
||||
// Siempre privadas: cada quien ve y toca solo sus propias notas, sin importar el rol. Una nota
|
||||
// de otro usuario responde 404 (no 403) para no revelar que existe.
|
||||
// Siempre privadas para editar/borrar/compartir: cada quien solo toca sus propias notas, sin
|
||||
// importar el rol. Una nota de otro usuario responde 404 (no 403) para no revelar que existe.
|
||||
async function getOwnNoteOr404(id, currentUser) {
|
||||
const note = await repo.findById(id);
|
||||
if (!note || note.user_id !== currentUser.id) throw new HttpError(404, 'Nota no encontrada');
|
||||
return note;
|
||||
}
|
||||
|
||||
export const listNotes = (currentUser) => repo.findByUser(currentUser.id);
|
||||
export const listNotes = (currentUser) => repo.findVisibleToUser(currentUser.id);
|
||||
|
||||
export async function createNote(content, currentUser) {
|
||||
const trimmed = (content || '').trim();
|
||||
@@ -28,3 +30,26 @@ export async function removeNote(id, currentUser) {
|
||||
await getOwnNoteOr404(id, currentUser);
|
||||
await repo.remove(id);
|
||||
}
|
||||
|
||||
export async function listNoteShares(id, currentUser) {
|
||||
await getOwnNoteOr404(id, currentUser);
|
||||
return repo.listShares(id);
|
||||
}
|
||||
|
||||
export async function shareNote(id, targetUserId, currentUser) {
|
||||
await getOwnNoteOr404(id, currentUser);
|
||||
if (!targetUserId) throw new HttpError(400, 'Indica un usuario');
|
||||
if (targetUserId === currentUser.id) throw new HttpError(400, 'No puedes compartir una nota contigo mismo');
|
||||
const targetUser = await usersRepo.findById(targetUserId);
|
||||
if (!targetUser) throw new HttpError(404, 'Usuario no encontrado');
|
||||
const shares = await repo.addShare(id, targetUserId);
|
||||
await logAudit({ userId: currentUser.id, action: 'share', entityType: 'note', entityId: id, detail: { userId: targetUserId } });
|
||||
return shares;
|
||||
}
|
||||
|
||||
export async function unshareNote(id, targetUserId, currentUser) {
|
||||
await getOwnNoteOr404(id, currentUser);
|
||||
const shares = await repo.removeShare(id, targetUserId);
|
||||
await logAudit({ userId: currentUser.id, action: 'unshare', entityType: 'note', entityId: id, detail: { userId: targetUserId } });
|
||||
return shares;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user