feat(fase-3): Tareas + Notas — TDD complete (148 tests green)
3.0 Tests primero
pgTAP: 005_tasks_rls.sql (5 — completion CHECK), 006_notes_trash_purge.sql (4 — purge SQL inline + pin/archive CHECK)
Vitest RLS: rls-tasks.test.ts (14), rls-notes.test.ts (15)
Playwright: tasks.test.ts (3), notes.test.ts (4)
3.1 Tareas
Migration 008_tasks.sql: task_lists + tasks + task_list_collective_id() helper
CHECK constraint: is_completed ⇔ completed_by ⇔ completed_at
RLS by role + cross-collective isolation
Store apps/web/src/lib/stores/tasks.ts (optimistic CRUD + reorder)
/tasks overview (grid + sticky create input + 5s polling)
/tasks/[id] detail with svelte-dnd-action reorder, flip animation,
inline edit (dblclick), "Completed by …" attribution via lazy-loaded
collective_members
3.2 Notas
Migration 009_notes.sql: notes + note_color enum (8) + pin/archive CHECK
+ 7d trash window in RLS + fn_notes_touch trigger + pg_cron purge job
Store apps/web/src/lib/stores/notes.ts (CRUD + pin/archive/trash/duplicate)
/notes board (pinned section testid notes-pinned + 30s polling)
/notes/[id] editor (title + textarea, 500ms debounced autosave,
color picker, pin/archive/duplicate/trash actions)
/notes/archive (restore + send to trash)
/notes/trash (restore + permanent delete)
3.Z Verification
just test-all → 148 verdes, 2 skipped:
25 pgTAP (was 16, +9)
88 Vitest integration (was 59, +29; 2 presence skipped)
6 Vitest unit (unchanged)
29 Playwright (was 22, +3 tasks +4 notes)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
15
CLAUDE.md
15
CLAUDE.md
@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Status
|
||||
|
||||
**Fase 0 complete. Fase 1 complete. Fase 2a complete. Fase 2b complete (Realtime + offline queue + Modo Compra). 103 tests green: 16 pgTAP + 59 Vitest integration + 6 Vitest unit + 22 Playwright. 2 skipped (Realtime presence — upstream bug in `supabase/realtime` `handle_out/3`, reactivate when fixed). ✅**
|
||||
**Fase 0 complete. Fase 1 complete. Fase 2a complete. Fase 2b complete (Realtime + offline queue + Modo Compra). Fase 3 complete (Tareas + Notas). 148 tests green: 25 pgTAP + 88 Vitest integration + 6 Vitest unit + 29 Playwright. 2 skipped (Realtime presence — upstream bug in `supabase/realtime` `handle_out/3`, reactivate when fixed). ✅**
|
||||
|
||||
- `README.md` — full development plan, confirmed tech stack, Justfile reference, and technical warnings
|
||||
- `analysis/analisis-funcional.md` — complete functional specification (domain model, use cases, data models, business rules)
|
||||
@@ -73,6 +73,19 @@ just test-e2e # Playwright E2E tests (requires just dev running)
|
||||
just test-e2e-headed # Playwright with visible browser (debugging)
|
||||
```
|
||||
|
||||
### Fase 3 — what has been built
|
||||
|
||||
- `supabase/migrations/008_tasks.sql` — `task_lists`, `tasks` with completion-consistency CHECK (`is_completed ⇔ completed_by ⇔ completed_at`), helper `task_list_collective_id()`, RLS by role
|
||||
- `supabase/migrations/009_notes.sql` — `notes` with `note_color` enum (8 values), `is_pinned`, `is_archived` (mutually exclusive via CHECK), `deleted_at` (7d trash window in RLS), `fn_notes_touch` trigger refreshing `updated_at`/`updated_by`, `pg_cron` job `purge-deleted-notes` at 03:10
|
||||
- `apps/web/src/lib/stores/tasks.ts` — task lists CRUD (optimistic) + task ops (`addTask`, `completeTask`, `renameTask`, `deleteTask`, `reorderTasks`)
|
||||
- `apps/web/src/lib/stores/notes.ts` — `loadNotes/loadArchivedNotes/loadTrashedNotes`, `createNote`, `patchNote` (always sets `updated_by`), `pinNote/unpinNote`, `archiveNote/unarchiveNote`, `trashNote/restoreNote`, `permanentDeleteNote`, `duplicateNote`, `NOTE_COLORS`
|
||||
- `/tasks` (overview) + `/tasks/[id]` (detail with `dndzone` reorder + `flip` animation; lazy-loads `collective_members` for "Completed by …" attribution; 5s polling per analysis §6)
|
||||
- `/notes` (board with pinned section testid `notes-pinned`) + `/notes/[id]` (editor: title input + textarea, 500ms debounced autosave, color picker, pin/archive/duplicate/trash) + `/notes/archive` + `/notes/trash` (30s polling on board)
|
||||
- `apps/web/messages/{en,es}.json` — full Tareas + Notas string set (`tasks_*`, `notes_*`)
|
||||
- pgTAP `005_tasks_rls.sql` (5 tests on the completion CHECK), `006_notes_trash_purge.sql` (4 tests: inline-runs the purge SQL + verifies pin/archive CHECK)
|
||||
- Vitest `rls-tasks.test.ts` (14 tests) + `rls-notes.test.ts` (15 tests)
|
||||
- Playwright `tasks.test.ts` (3 tests) + `notes.test.ts` (4 tests)
|
||||
|
||||
### Fase 2b — what has been built
|
||||
|
||||
- `supabase/migrations/007_realtime_publication.sql` — adds shopping_items + shopping_lists to `supabase_realtime` publication with `REPLICA IDENTITY FULL`
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
| Fase 1 — Auth y Colectivo | ✅ Completa |
|
||||
| Fase 2a — Lista de Compra CRUD | ✅ Completa |
|
||||
| Fase 2b — Realtime y Modo Compra | ✅ Completa (presence bloqueado por bug upstream de `supabase/realtime`) |
|
||||
| Suite de tests (pgTAP + Vitest + Playwright) | ✅ 103 tests verdes (16 pgTAP + 59 integración + 6 unit + 22 E2E), ejecutables con `just test-all` |
|
||||
| Fase 3 — Tareas y Notas | ⏳ Pendiente |
|
||||
| Fase 3 — Tareas y Notas | ✅ Completa |
|
||||
| Suite de tests (pgTAP + Vitest + Playwright) | ✅ 148 tests verdes (25 pgTAP + 88 integración + 6 unit + 29 E2E), ejecutables con `just test-all` |
|
||||
| Fase 4 — Búsqueda y Pulido | ⏳ Pendiente |
|
||||
|
||||
---
|
||||
|
||||
@@ -96,7 +96,44 @@
|
||||
"list_finish_confirm_yes": "Yes, finish",
|
||||
"list_finish_confirm_no": "Keep shopping",
|
||||
"tasks_title": "Tasks",
|
||||
"tasks_no_lists": "No task lists yet",
|
||||
"tasks_create_first": "Create your first task list",
|
||||
"tasks_new_list_placeholder": "New task list",
|
||||
"tasks_new_task_placeholder": "Add task",
|
||||
"tasks_empty": "No tasks yet",
|
||||
"tasks_empty_hint": "Add your first task below",
|
||||
"tasks_completed_section": "Completed",
|
||||
"tasks_pending_section": "To do",
|
||||
"tasks_completed_by": "Completed by {name}",
|
||||
"tasks_toggle": "Toggle task",
|
||||
"tasks_uncheck": "Uncheck task",
|
||||
"tasks_delete": "Delete task",
|
||||
"tasks_delete_list": "Delete list",
|
||||
"tasks_delete_list_confirm": "Delete this task list and all its tasks?",
|
||||
"notes_title": "Notes",
|
||||
"notes_new": "New note",
|
||||
"notes_pinned_section": "Pinned",
|
||||
"notes_active_section": "Notes",
|
||||
"notes_archive": "Archive",
|
||||
"notes_archive_view": "Archived notes",
|
||||
"notes_archived_empty": "No archived notes",
|
||||
"notes_trash_view": "Notes trash",
|
||||
"notes_trash_empty": "Trash is empty",
|
||||
"notes_pin": "Pin note",
|
||||
"notes_unpin": "Unpin note",
|
||||
"notes_unarchive": "Unarchive note",
|
||||
"notes_send_to_trash": "Move to trash",
|
||||
"notes_restore": "Restore",
|
||||
"notes_duplicate": "Duplicate",
|
||||
"notes_color": "Color",
|
||||
"notes_title_placeholder": "Title",
|
||||
"notes_content_placeholder": "Start writing…",
|
||||
"notes_content_aria": "Content",
|
||||
"notes_back_to_board": "Back to board",
|
||||
"notes_saved": "Saved",
|
||||
"notes_saving": "Saving…",
|
||||
"notes_empty_board": "No notes yet",
|
||||
"notes_empty_board_hint": "Click \"New note\" to start writing",
|
||||
"search_title": "Search",
|
||||
"search_placeholder": "Search lists, tasks, notes…",
|
||||
"trash_restore": "Restore",
|
||||
|
||||
@@ -96,7 +96,44 @@
|
||||
"list_finish_confirm_yes": "Sí, terminar",
|
||||
"list_finish_confirm_no": "Seguir comprando",
|
||||
"tasks_title": "Tareas",
|
||||
"tasks_no_lists": "Sin listas de tareas",
|
||||
"tasks_create_first": "Crea tu primera lista de tareas",
|
||||
"tasks_new_list_placeholder": "Nueva lista de tareas",
|
||||
"tasks_new_task_placeholder": "Añadir tarea",
|
||||
"tasks_empty": "Sin tareas",
|
||||
"tasks_empty_hint": "Añade tu primera tarea abajo",
|
||||
"tasks_completed_section": "Completadas",
|
||||
"tasks_pending_section": "Por hacer",
|
||||
"tasks_completed_by": "Completada por {name}",
|
||||
"tasks_toggle": "Alternar tarea",
|
||||
"tasks_uncheck": "Desmarcar tarea",
|
||||
"tasks_delete": "Eliminar tarea",
|
||||
"tasks_delete_list": "Eliminar lista",
|
||||
"tasks_delete_list_confirm": "¿Eliminar esta lista y todas sus tareas?",
|
||||
"notes_title": "Notas",
|
||||
"notes_new": "Nueva nota",
|
||||
"notes_pinned_section": "Fijadas",
|
||||
"notes_active_section": "Notas",
|
||||
"notes_archive": "Archivar",
|
||||
"notes_archive_view": "Notas archivadas",
|
||||
"notes_archived_empty": "Sin notas archivadas",
|
||||
"notes_trash_view": "Papelera de notas",
|
||||
"notes_trash_empty": "La papelera está vacía",
|
||||
"notes_pin": "Fijar nota",
|
||||
"notes_unpin": "Desfijar nota",
|
||||
"notes_unarchive": "Desarchivar nota",
|
||||
"notes_send_to_trash": "Enviar a la papelera",
|
||||
"notes_restore": "Restaurar",
|
||||
"notes_duplicate": "Duplicar",
|
||||
"notes_color": "Color",
|
||||
"notes_title_placeholder": "Título",
|
||||
"notes_content_placeholder": "Empieza a escribir…",
|
||||
"notes_content_aria": "Contenido",
|
||||
"notes_back_to_board": "Volver al tablero",
|
||||
"notes_saved": "Guardado",
|
||||
"notes_saving": "Guardando…",
|
||||
"notes_empty_board": "Sin notas",
|
||||
"notes_empty_board_hint": "Haz clic en \"Nueva nota\" para empezar",
|
||||
"search_title": "Buscar",
|
||||
"search_placeholder": "Busca listas, tareas, notas…",
|
||||
"trash_restore": "Restaurar",
|
||||
|
||||
147
apps/web/src/lib/stores/notes.ts
Normal file
147
apps/web/src/lib/stores/notes.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import type { Note, NoteColor } from '@colectivo/types';
|
||||
|
||||
export const notes = writable<Note[]>([]);
|
||||
export const notesLoading = writable(false);
|
||||
|
||||
// ── Active board (not archived, not in trash) ────────────────────────────────
|
||||
|
||||
export async function loadNotes(collectiveId: string) {
|
||||
notesLoading.set(true);
|
||||
const { data, error } = await getSupabase()
|
||||
.from('notes')
|
||||
.select('*')
|
||||
.eq('collective_id', collectiveId)
|
||||
.eq('is_archived', false)
|
||||
.is('deleted_at', null)
|
||||
.order('is_pinned', { ascending: false })
|
||||
.order('updated_at', { ascending: false });
|
||||
|
||||
if (!error && data) {
|
||||
notes.set(data as Note[]);
|
||||
}
|
||||
notesLoading.set(false);
|
||||
}
|
||||
|
||||
export async function loadArchivedNotes(collectiveId: string): Promise<Note[]> {
|
||||
const { data } = await getSupabase()
|
||||
.from('notes')
|
||||
.select('*')
|
||||
.eq('collective_id', collectiveId)
|
||||
.eq('is_archived', true)
|
||||
.is('deleted_at', null)
|
||||
.order('updated_at', { ascending: false });
|
||||
return (data as Note[]) ?? [];
|
||||
}
|
||||
|
||||
export async function loadTrashedNotes(collectiveId: string): Promise<Note[]> {
|
||||
const { data } = await getSupabase()
|
||||
.from('notes')
|
||||
.select('*')
|
||||
.eq('collective_id', collectiveId)
|
||||
.not('deleted_at', 'is', null)
|
||||
.order('deleted_at', { ascending: false });
|
||||
return (data as Note[]) ?? [];
|
||||
}
|
||||
|
||||
export async function loadNote(id: string): Promise<Note | null> {
|
||||
const { data } = await getSupabase().from('notes').select('*').eq('id', id).single();
|
||||
return (data as Note) ?? null;
|
||||
}
|
||||
|
||||
// ── Mutations ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createNote(
|
||||
collectiveId: string,
|
||||
userId: string
|
||||
): Promise<Note | null> {
|
||||
const { data, error } = await getSupabase()
|
||||
.from('notes')
|
||||
.insert({
|
||||
collective_id: collectiveId,
|
||||
content: '',
|
||||
created_by: userId,
|
||||
updated_by: userId
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !data) return null;
|
||||
notes.update((ns) => [data as Note, ...ns]);
|
||||
return data as Note;
|
||||
}
|
||||
|
||||
export async function patchNote(
|
||||
id: string,
|
||||
userId: string,
|
||||
patch: Partial<Pick<Note, 'title' | 'content' | 'color' | 'is_pinned' | 'is_archived' | 'deleted_at'>>
|
||||
) {
|
||||
const fullPatch = { ...patch, updated_by: userId };
|
||||
await getSupabase().from('notes').update(fullPatch).eq('id', id);
|
||||
notes.update((ns) =>
|
||||
ns.map((n) =>
|
||||
n.id === id ? { ...n, ...patch, updated_by: userId, updated_at: new Date().toISOString() } : n
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function pinNote(id: string, userId: string) {
|
||||
// Pinning forces archive=false (CHECK constraint forbids both true)
|
||||
await patchNote(id, userId, { is_pinned: true, is_archived: false });
|
||||
}
|
||||
|
||||
export async function unpinNote(id: string, userId: string) {
|
||||
await patchNote(id, userId, { is_pinned: false });
|
||||
}
|
||||
|
||||
export async function archiveNote(id: string, userId: string) {
|
||||
await patchNote(id, userId, { is_archived: true, is_pinned: false });
|
||||
notes.update((ns) => ns.filter((n) => n.id !== id));
|
||||
}
|
||||
|
||||
export async function unarchiveNote(id: string, userId: string) {
|
||||
await patchNote(id, userId, { is_archived: false });
|
||||
}
|
||||
|
||||
export async function trashNote(id: string, userId: string) {
|
||||
await patchNote(id, userId, { deleted_at: new Date().toISOString() });
|
||||
notes.update((ns) => ns.filter((n) => n.id !== id));
|
||||
}
|
||||
|
||||
export async function restoreNote(id: string, userId: string) {
|
||||
await patchNote(id, userId, { deleted_at: null });
|
||||
}
|
||||
|
||||
export async function permanentDeleteNote(id: string) {
|
||||
await getSupabase().from('notes').delete().eq('id', id);
|
||||
}
|
||||
|
||||
export async function duplicateNote(note: Note, userId: string): Promise<Note | null> {
|
||||
const { data, error } = await getSupabase()
|
||||
.from('notes')
|
||||
.insert({
|
||||
collective_id: note.collective_id,
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
color: note.color,
|
||||
created_by: userId,
|
||||
updated_by: userId
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error || !data) return null;
|
||||
notes.update((ns) => [data as Note, ...ns]);
|
||||
return data as Note;
|
||||
}
|
||||
|
||||
export const NOTE_COLORS: NoteColor[] = [
|
||||
'yellow',
|
||||
'green',
|
||||
'blue',
|
||||
'pink',
|
||||
'purple',
|
||||
'orange',
|
||||
'gray',
|
||||
'red'
|
||||
];
|
||||
113
apps/web/src/lib/stores/tasks.ts
Normal file
113
apps/web/src/lib/stores/tasks.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import type { Task, TaskList } from '@colectivo/types';
|
||||
|
||||
// ── Task lists store (overview page) ──────────────────────────────────────────
|
||||
|
||||
export const taskLists = writable<TaskList[]>([]);
|
||||
export const taskListsLoading = writable(false);
|
||||
|
||||
export async function loadTaskLists(collectiveId: string) {
|
||||
taskListsLoading.set(true);
|
||||
const { data, error } = await getSupabase()
|
||||
.from('task_lists')
|
||||
.select('*')
|
||||
.eq('collective_id', collectiveId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (!error && data) {
|
||||
taskLists.set(data as TaskList[]);
|
||||
}
|
||||
taskListsLoading.set(false);
|
||||
}
|
||||
|
||||
export async function createTaskList(
|
||||
collectiveId: string,
|
||||
name: string,
|
||||
userId: string
|
||||
): Promise<TaskList | null> {
|
||||
const tempId = crypto.randomUUID();
|
||||
const optimistic: TaskList = {
|
||||
id: tempId,
|
||||
collective_id: collectiveId,
|
||||
name,
|
||||
created_by: userId,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
taskLists.update((ls) => [optimistic, ...ls]);
|
||||
|
||||
const { data, error } = await getSupabase()
|
||||
.from('task_lists')
|
||||
.insert({ collective_id: collectiveId, name, created_by: userId })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
taskLists.update((ls) => ls.filter((l) => l.id !== tempId));
|
||||
return null;
|
||||
}
|
||||
|
||||
const real = data as TaskList;
|
||||
taskLists.update((ls) => ls.map((l) => (l.id === tempId ? real : l)));
|
||||
return real;
|
||||
}
|
||||
|
||||
export async function renameTaskList(id: string, name: string) {
|
||||
taskLists.update((ls) => ls.map((l) => (l.id === id ? { ...l, name } : l)));
|
||||
await getSupabase().from('task_lists').update({ name }).eq('id', id);
|
||||
}
|
||||
|
||||
export async function deleteTaskList(id: string) {
|
||||
taskLists.update((ls) => ls.filter((l) => l.id !== id));
|
||||
await getSupabase().from('task_lists').delete().eq('id', id);
|
||||
}
|
||||
|
||||
// ── Task operations (used by [id] page) ───────────────────────────────────────
|
||||
|
||||
export async function loadTasks(listId: string): Promise<Task[]> {
|
||||
const { data, error } = await getSupabase()
|
||||
.from('tasks')
|
||||
.select('*')
|
||||
.eq('list_id', listId)
|
||||
.order('sort_order', { ascending: true });
|
||||
|
||||
if (error || !data) return [];
|
||||
return data as Task[];
|
||||
}
|
||||
|
||||
export async function addTask(
|
||||
listId: string,
|
||||
title: string,
|
||||
userId: string,
|
||||
sortOrder: number
|
||||
): Promise<Task | null> {
|
||||
const { data, error } = await getSupabase()
|
||||
.from('tasks')
|
||||
.insert({ list_id: listId, title, sort_order: sortOrder, created_by: userId })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !data) return null;
|
||||
return data as Task;
|
||||
}
|
||||
|
||||
export async function renameTask(id: string, title: string) {
|
||||
await getSupabase().from('tasks').update({ title }).eq('id', id);
|
||||
}
|
||||
|
||||
export async function completeTask(id: string, userId: string, completed: boolean) {
|
||||
const patch = completed
|
||||
? { is_completed: true, completed_by: userId, completed_at: new Date().toISOString() }
|
||||
: { is_completed: false, completed_by: null, completed_at: null };
|
||||
await getSupabase().from('tasks').update(patch).eq('id', id);
|
||||
}
|
||||
|
||||
export async function deleteTask(id: string) {
|
||||
await getSupabase().from('tasks').delete().eq('id', id);
|
||||
}
|
||||
|
||||
export async function reorderTasks(tasks: Pick<Task, 'id'>[]) {
|
||||
await Promise.all(
|
||||
tasks.map((t, idx) => getSupabase().from('tasks').update({ sort_order: idx }).eq('id', t.id))
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,148 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { currentCollective } from '$lib/stores/collective';
|
||||
import { currentUser } from '$lib/stores/auth';
|
||||
import { notes, notesLoading, loadNotes, createNote } from '$lib/stores/notes';
|
||||
import { FileText, Plus, Archive, Trash2 } from 'lucide-svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
$effect(() => {
|
||||
if ($currentCollective) loadNotes($currentCollective.id);
|
||||
});
|
||||
|
||||
// 30s polling per analysis §6
|
||||
$effect(() => {
|
||||
if (!$currentCollective) return;
|
||||
const id = setInterval(() => {
|
||||
if ($currentCollective) loadNotes($currentCollective.id);
|
||||
}, 30_000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
const pinned = $derived($notes.filter((n) => n.is_pinned));
|
||||
const others = $derived($notes.filter((n) => !n.is_pinned));
|
||||
|
||||
async function handleCreate() {
|
||||
if (!$currentCollective || !$currentUser) return;
|
||||
const created = await createNote($currentCollective.id, $currentUser.id);
|
||||
if (created) goto(`/notes/${created.id}`);
|
||||
}
|
||||
|
||||
function colorClass(color: string | null): string {
|
||||
switch (color) {
|
||||
case 'yellow':
|
||||
return 'bg-yellow-100 dark:bg-yellow-900/30';
|
||||
case 'green':
|
||||
return 'bg-emerald-100 dark:bg-emerald-900/30';
|
||||
case 'blue':
|
||||
return 'bg-sky-100 dark:bg-sky-900/30';
|
||||
case 'pink':
|
||||
return 'bg-pink-100 dark:bg-pink-900/30';
|
||||
case 'purple':
|
||||
return 'bg-purple-100 dark:bg-purple-900/30';
|
||||
case 'orange':
|
||||
return 'bg-orange-100 dark:bg-orange-900/30';
|
||||
case 'gray':
|
||||
return 'bg-slate-100 dark:bg-slate-800/50';
|
||||
case 'red':
|
||||
return 'bg-red-100 dark:bg-red-900/30';
|
||||
default:
|
||||
return 'bg-surface-raised';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<header class="px-8 pb-4 pt-8">
|
||||
<p class="mb-1 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
|
||||
{m.nav_notes()}
|
||||
</p>
|
||||
<h1 class="text-[20px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-50">
|
||||
{m.notes_title()}
|
||||
</h1>
|
||||
<svelte:head><title>{m.notes_title()} — Colectivo</title></svelte:head>
|
||||
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<header class="flex h-14 items-center gap-2 border-b border-black/5 px-6 dark:border-white/5">
|
||||
<FileText size={18} strokeWidth={1.5} />
|
||||
<h1 class="text-base font-semibold text-text-primary">{m.notes_title()}</h1>
|
||||
<div class="flex-1"></div>
|
||||
<a
|
||||
href="/notes/archive"
|
||||
class="rounded p-1.5 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
|
||||
aria-label={m.notes_archive_view()}
|
||||
title={m.notes_archive_view()}
|
||||
>
|
||||
<Archive size={16} strokeWidth={1.5} />
|
||||
</a>
|
||||
<a
|
||||
href="/notes/trash"
|
||||
class="rounded p-1.5 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
|
||||
aria-label={m.notes_trash_view()}
|
||||
title={m.notes_trash_view()}
|
||||
>
|
||||
<Trash2 size={16} strokeWidth={1.5} />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleCreate}
|
||||
class="ml-2 inline-flex items-center gap-1 rounded-md bg-slate-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-slate-800 dark:bg-slate-100 dark:text-slate-900"
|
||||
>
|
||||
<Plus size={14} strokeWidth={2} />
|
||||
{m.notes_new()}
|
||||
</button>
|
||||
</header>
|
||||
<div class="flex-1 overflow-y-auto px-8 py-6">
|
||||
<!-- Fase 3: notes content -->
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 py-6">
|
||||
{#if $notesLoading && $notes.length === 0}
|
||||
<p class="text-sm text-text-secondary">{m.loading()}</p>
|
||||
{:else if $notes.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-black/10 p-12 text-center dark:border-white/10">
|
||||
<p class="text-sm font-medium text-text-primary">{m.notes_empty_board()}</p>
|
||||
<p class="mt-1 text-sm text-text-secondary">{m.notes_empty_board_hint()}</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#if pinned.length > 0}
|
||||
<section data-testid="notes-pinned" class="mb-8">
|
||||
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wide text-text-secondary">
|
||||
{m.notes_pinned_section()}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{#each pinned as note (note.id)}
|
||||
<a
|
||||
href={`/notes/${note.id}`}
|
||||
data-testid="note-card"
|
||||
class={`block rounded-lg p-4 shadow-sm transition-shadow hover:shadow-md ${colorClass(note.color)}`}
|
||||
>
|
||||
{#if note.title}
|
||||
<h3 class="mb-1 text-sm font-semibold text-text-primary">{note.title}</h3>
|
||||
{/if}
|
||||
<p class="line-clamp-6 whitespace-pre-wrap text-sm text-text-secondary">
|
||||
{note.content}
|
||||
</p>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if others.length > 0}
|
||||
<section data-testid="notes-active">
|
||||
{#if pinned.length > 0}
|
||||
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wide text-text-secondary">
|
||||
{m.notes_active_section()}
|
||||
</h2>
|
||||
{/if}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{#each others as note (note.id)}
|
||||
<a
|
||||
href={`/notes/${note.id}`}
|
||||
data-testid="note-card"
|
||||
class={`block rounded-lg p-4 shadow-sm transition-shadow hover:shadow-md ${colorClass(note.color)}`}
|
||||
>
|
||||
{#if note.title}
|
||||
<h3 class="mb-1 text-sm font-semibold text-text-primary">{note.title}</h3>
|
||||
{/if}
|
||||
<p class="line-clamp-6 whitespace-pre-wrap text-sm text-text-secondary">
|
||||
{note.content}
|
||||
</p>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
271
apps/web/src/routes/(app)/notes/[id]/+page.svelte
Normal file
271
apps/web/src/routes/(app)/notes/[id]/+page.svelte
Normal file
@@ -0,0 +1,271 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { currentUser } from '$lib/stores/auth';
|
||||
import {
|
||||
loadNote,
|
||||
patchNote,
|
||||
pinNote,
|
||||
unpinNote,
|
||||
archiveNote,
|
||||
unarchiveNote,
|
||||
trashNote,
|
||||
restoreNote,
|
||||
duplicateNote,
|
||||
NOTE_COLORS
|
||||
} from '$lib/stores/notes';
|
||||
import type { Note, NoteColor } from '@colectivo/types';
|
||||
import { ArrowLeft, Pin, PinOff, Archive, ArchiveRestore, Trash2, Copy, Palette } from 'lucide-svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
const noteId = $page.params.id!;
|
||||
|
||||
let note = $state<Note | null>(null);
|
||||
let title = $state('');
|
||||
let content = $state('');
|
||||
let saving = $state(false);
|
||||
let savedAt = $state<number>(0);
|
||||
let showColorPicker = $state(false);
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const SAVE_DEBOUNCE_MS = 500;
|
||||
|
||||
onMount(async () => {
|
||||
const n = await loadNote(noteId);
|
||||
if (!n) {
|
||||
goto('/notes');
|
||||
return;
|
||||
}
|
||||
note = n;
|
||||
title = n.title ?? '';
|
||||
content = n.content;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
void flushSave();
|
||||
}
|
||||
});
|
||||
|
||||
async function flushSave() {
|
||||
if (!note || !$currentUser) return;
|
||||
saving = true;
|
||||
await patchNote(note.id, $currentUser.id, {
|
||||
title: title.trim() || null,
|
||||
content
|
||||
});
|
||||
saving = false;
|
||||
savedAt = Date.now();
|
||||
}
|
||||
|
||||
function scheduleSave() {
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => {
|
||||
saveTimer = null;
|
||||
void flushSave();
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function onTitleInput() {
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function onContentInput() {
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
async function togglePin() {
|
||||
if (!note || !$currentUser) return;
|
||||
if (note.is_pinned) {
|
||||
await unpinNote(note.id, $currentUser.id);
|
||||
note = { ...note, is_pinned: false };
|
||||
} else {
|
||||
await pinNote(note.id, $currentUser.id);
|
||||
note = { ...note, is_pinned: true, is_archived: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleArchive() {
|
||||
if (!note || !$currentUser) return;
|
||||
if (note.is_archived) {
|
||||
await unarchiveNote(note.id, $currentUser.id);
|
||||
note = { ...note, is_archived: false };
|
||||
} else {
|
||||
await archiveNote(note.id, $currentUser.id);
|
||||
goto('/notes');
|
||||
}
|
||||
}
|
||||
|
||||
async function sendToTrash() {
|
||||
if (!note || !$currentUser) return;
|
||||
await trashNote(note.id, $currentUser.id);
|
||||
goto('/notes');
|
||||
}
|
||||
|
||||
async function handleRestore() {
|
||||
if (!note || !$currentUser) return;
|
||||
await restoreNote(note.id, $currentUser.id);
|
||||
note = { ...note, deleted_at: null };
|
||||
}
|
||||
|
||||
async function handleDuplicate() {
|
||||
if (!note || !$currentUser) return;
|
||||
const dup = await duplicateNote(note, $currentUser.id);
|
||||
if (dup) goto(`/notes/${dup.id}`);
|
||||
}
|
||||
|
||||
async function setColor(color: NoteColor | null) {
|
||||
if (!note || !$currentUser) return;
|
||||
await patchNote(note.id, $currentUser.id, { color });
|
||||
note = { ...note, color };
|
||||
showColorPicker = false;
|
||||
}
|
||||
|
||||
function colorBg(color: string | null): string {
|
||||
switch (color) {
|
||||
case 'yellow': return 'bg-yellow-50 dark:bg-yellow-900/20';
|
||||
case 'green': return 'bg-emerald-50 dark:bg-emerald-900/20';
|
||||
case 'blue': return 'bg-sky-50 dark:bg-sky-900/20';
|
||||
case 'pink': return 'bg-pink-50 dark:bg-pink-900/20';
|
||||
case 'purple': return 'bg-purple-50 dark:bg-purple-900/20';
|
||||
case 'orange': return 'bg-orange-50 dark:bg-orange-900/20';
|
||||
case 'gray': return 'bg-slate-100 dark:bg-slate-800/40';
|
||||
case 'red': return 'bg-red-50 dark:bg-red-900/20';
|
||||
default: return 'bg-background';
|
||||
}
|
||||
}
|
||||
|
||||
function colorSwatch(color: NoteColor): string {
|
||||
switch (color) {
|
||||
case 'yellow': return 'bg-yellow-300';
|
||||
case 'green': return 'bg-emerald-400';
|
||||
case 'blue': return 'bg-sky-400';
|
||||
case 'pink': return 'bg-pink-300';
|
||||
case 'purple': return 'bg-purple-400';
|
||||
case 'orange': return 'bg-orange-400';
|
||||
case 'gray': return 'bg-slate-400';
|
||||
case 'red': return 'bg-red-400';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{title || m.notes_title()} — Colectivo</title></svelte:head>
|
||||
|
||||
<div class={`flex h-full flex-col overflow-hidden ${colorBg(note?.color ?? null)}`}>
|
||||
<header class="flex h-14 flex-shrink-0 items-center gap-2 border-b border-black/5 px-6 dark:border-white/5">
|
||||
<a
|
||||
href="/notes"
|
||||
class="rounded p-1 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
|
||||
aria-label={m.notes_back_to_board()}
|
||||
>
|
||||
<ArrowLeft size={18} strokeWidth={1.5} />
|
||||
</a>
|
||||
<div class="flex-1"></div>
|
||||
|
||||
{#if note?.deleted_at}
|
||||
<span class="text-xs text-text-secondary">{m.notes_trash_view()}</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleRestore}
|
||||
class="rounded p-1.5 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
|
||||
aria-label={m.notes_restore()}
|
||||
title={m.notes_restore()}
|
||||
>
|
||||
<ArchiveRestore size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
{:else}
|
||||
<span class="text-xs text-text-secondary">
|
||||
{saving ? m.notes_saving() : savedAt ? m.notes_saved() : ''}
|
||||
</span>
|
||||
|
||||
<!-- Color picker -->
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showColorPicker = !showColorPicker)}
|
||||
class="rounded p-1.5 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
|
||||
aria-label={m.notes_color()}
|
||||
title={m.notes_color()}
|
||||
>
|
||||
<Palette size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
{#if showColorPicker}
|
||||
<div class="absolute right-0 top-full z-50 mt-1 flex gap-1 rounded-md bg-surface-raised p-2 shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setColor(null)}
|
||||
class="h-6 w-6 rounded-full border-2 border-black/20 bg-background"
|
||||
aria-label="default"
|
||||
></button>
|
||||
{#each NOTE_COLORS as c}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setColor(c)}
|
||||
class={`h-6 w-6 rounded-full border-2 border-transparent hover:border-black/30 ${colorSwatch(c)}`}
|
||||
aria-label={c}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={togglePin}
|
||||
class="rounded p-1.5 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
|
||||
aria-label={note?.is_pinned ? m.notes_unpin() : m.notes_pin()}
|
||||
title={note?.is_pinned ? m.notes_unpin() : m.notes_pin()}
|
||||
>
|
||||
{#if note?.is_pinned}<PinOff size={16} strokeWidth={1.5} />{:else}<Pin size={16} strokeWidth={1.5} />{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleArchive}
|
||||
class="rounded p-1.5 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
|
||||
aria-label={note?.is_archived ? m.notes_unarchive() : m.notes_archive()}
|
||||
title={note?.is_archived ? m.notes_unarchive() : m.notes_archive()}
|
||||
>
|
||||
{#if note?.is_archived}<ArchiveRestore size={16} strokeWidth={1.5} />{:else}<Archive size={16} strokeWidth={1.5} />{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleDuplicate}
|
||||
class="rounded p-1.5 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
|
||||
aria-label={m.notes_duplicate()}
|
||||
title={m.notes_duplicate()}
|
||||
>
|
||||
<Copy size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={sendToTrash}
|
||||
class="rounded p-1.5 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
|
||||
aria-label={m.notes_send_to_trash()}
|
||||
title={m.notes_send_to_trash()}
|
||||
>
|
||||
<Trash2 size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-8 py-6">
|
||||
<input
|
||||
bind:value={title}
|
||||
oninput={onTitleInput}
|
||||
placeholder={m.notes_title_placeholder()}
|
||||
class="mb-4 w-full bg-transparent text-2xl font-semibold text-text-primary placeholder:text-text-secondary focus:outline-none"
|
||||
/>
|
||||
<textarea
|
||||
bind:value={content}
|
||||
oninput={onContentInput}
|
||||
placeholder={m.notes_content_placeholder()}
|
||||
aria-label={m.notes_content_aria()}
|
||||
class="h-[calc(100%-4rem)] w-full resize-none bg-transparent text-base text-text-primary placeholder:text-text-secondary focus:outline-none"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
81
apps/web/src/routes/(app)/notes/archive/+page.svelte
Normal file
81
apps/web/src/routes/(app)/notes/archive/+page.svelte
Normal file
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { currentCollective } from '$lib/stores/collective';
|
||||
import { currentUser } from '$lib/stores/auth';
|
||||
import { loadArchivedNotes, unarchiveNote, trashNote } from '$lib/stores/notes';
|
||||
import type { Note } from '@colectivo/types';
|
||||
import { ArrowLeft, ArchiveRestore, Trash2 } from 'lucide-svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
let archived = $state<Note[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
if ($currentCollective) {
|
||||
(async () => {
|
||||
loading = true;
|
||||
archived = await loadArchivedNotes($currentCollective.id);
|
||||
loading = false;
|
||||
})();
|
||||
}
|
||||
});
|
||||
|
||||
async function handleUnarchive(id: string) {
|
||||
if (!$currentUser) return;
|
||||
await unarchiveNote(id, $currentUser.id);
|
||||
archived = archived.filter((n) => n.id !== id);
|
||||
}
|
||||
|
||||
async function handleTrash(id: string) {
|
||||
if (!$currentUser) return;
|
||||
await trashNote(id, $currentUser.id);
|
||||
archived = archived.filter((n) => n.id !== id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{m.notes_archive_view()} — Colectivo</title></svelte:head>
|
||||
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<header class="flex h-14 items-center gap-2 border-b border-black/5 px-6 dark:border-white/5">
|
||||
<a href="/notes" class="rounded p-1 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5" aria-label={m.notes_back_to_board()}>
|
||||
<ArrowLeft size={18} strokeWidth={1.5} />
|
||||
</a>
|
||||
<h1 class="text-base font-semibold text-text-primary">{m.notes_archive_view()}</h1>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 py-6">
|
||||
{#if loading}
|
||||
<p class="text-sm text-text-secondary">{m.loading()}</p>
|
||||
{:else if archived.length === 0}
|
||||
<p class="text-sm text-text-secondary">{m.notes_archived_empty()}</p>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each archived as note (note.id)}
|
||||
<div role="listitem" data-testid="note-card" class="flex items-center gap-3 rounded-md bg-surface-raised p-3">
|
||||
<a href={`/notes/${note.id}`} class="min-w-0 flex-1">
|
||||
{#if note.title}
|
||||
<h3 class="truncate text-sm font-semibold text-text-primary">{note.title}</h3>
|
||||
{/if}
|
||||
<p class="truncate text-xs text-text-secondary">{note.content}</p>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleUnarchive(note.id)}
|
||||
aria-label={m.notes_unarchive()}
|
||||
class="rounded p-1.5 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<ArchiveRestore size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleTrash(note.id)}
|
||||
aria-label={m.notes_send_to_trash()}
|
||||
class="rounded p-1.5 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Trash2 size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
81
apps/web/src/routes/(app)/notes/trash/+page.svelte
Normal file
81
apps/web/src/routes/(app)/notes/trash/+page.svelte
Normal file
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { currentCollective } from '$lib/stores/collective';
|
||||
import { currentUser } from '$lib/stores/auth';
|
||||
import { loadTrashedNotes, restoreNote, permanentDeleteNote } from '$lib/stores/notes';
|
||||
import type { Note } from '@colectivo/types';
|
||||
import { ArrowLeft, ArchiveRestore, Trash2 } from 'lucide-svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
let trashed = $state<Note[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
if ($currentCollective) {
|
||||
(async () => {
|
||||
loading = true;
|
||||
trashed = await loadTrashedNotes($currentCollective.id);
|
||||
loading = false;
|
||||
})();
|
||||
}
|
||||
});
|
||||
|
||||
async function handleRestore(id: string) {
|
||||
if (!$currentUser) return;
|
||||
await restoreNote(id, $currentUser.id);
|
||||
trashed = trashed.filter((n) => n.id !== id);
|
||||
}
|
||||
|
||||
async function handlePurge(id: string) {
|
||||
if (!confirm(m.confirm_delete())) return;
|
||||
await permanentDeleteNote(id);
|
||||
trashed = trashed.filter((n) => n.id !== id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{m.notes_trash_view()} — Colectivo</title></svelte:head>
|
||||
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<header class="flex h-14 items-center gap-2 border-b border-black/5 px-6 dark:border-white/5">
|
||||
<a href="/notes" class="rounded p-1 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5" aria-label={m.notes_back_to_board()}>
|
||||
<ArrowLeft size={18} strokeWidth={1.5} />
|
||||
</a>
|
||||
<h1 class="text-base font-semibold text-text-primary">{m.notes_trash_view()}</h1>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 py-6">
|
||||
{#if loading}
|
||||
<p class="text-sm text-text-secondary">{m.loading()}</p>
|
||||
{:else if trashed.length === 0}
|
||||
<p class="text-sm text-text-secondary">{m.notes_trash_empty()}</p>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each trashed as note (note.id)}
|
||||
<div role="listitem" data-testid="note-card" class="flex items-center gap-3 rounded-md bg-surface-raised p-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
{#if note.title}
|
||||
<h3 class="truncate text-sm font-semibold text-text-primary">{note.title}</h3>
|
||||
{/if}
|
||||
<p class="truncate text-xs text-text-secondary">{note.content}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleRestore(note.id)}
|
||||
aria-label={m.notes_restore()}
|
||||
class="rounded p-1.5 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<ArchiveRestore size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handlePurge(note.id)}
|
||||
aria-label={m.trash_delete_permanent()}
|
||||
class="rounded p-1.5 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Trash2 size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,17 +1,103 @@
|
||||
<script lang="ts">
|
||||
import { currentCollective } from '$lib/stores/collective';
|
||||
import { currentUser } from '$lib/stores/auth';
|
||||
import {
|
||||
taskLists,
|
||||
taskListsLoading,
|
||||
loadTaskLists,
|
||||
createTaskList,
|
||||
deleteTaskList
|
||||
} from '$lib/stores/tasks';
|
||||
import { CheckSquare, Plus, Trash2 } from 'lucide-svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
let newName = $state('');
|
||||
let creating = $state(false);
|
||||
let nameInput: HTMLInputElement | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
if ($currentCollective) {
|
||||
loadTaskLists($currentCollective.id);
|
||||
}
|
||||
});
|
||||
|
||||
// 5s polling per analysis §6
|
||||
$effect(() => {
|
||||
if (!$currentCollective) return;
|
||||
const id = setInterval(() => {
|
||||
if ($currentCollective) loadTaskLists($currentCollective.id);
|
||||
}, 5_000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
async function handleCreate() {
|
||||
const name = newName.trim();
|
||||
if (!name || !$currentCollective || !$currentUser) return;
|
||||
creating = true;
|
||||
newName = '';
|
||||
await createTaskList($currentCollective.id, name, $currentUser.id);
|
||||
creating = false;
|
||||
nameInput?.focus();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') handleCreate();
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm(m.tasks_delete_list_confirm())) return;
|
||||
await deleteTaskList(id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<header class="px-8 pb-4 pt-8">
|
||||
<p class="mb-1 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
|
||||
{m.nav_tasks()}
|
||||
</p>
|
||||
<h1 class="text-[20px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-50">
|
||||
{m.tasks_title()}
|
||||
</h1>
|
||||
<svelte:head><title>{m.tasks_title()} — Colectivo</title></svelte:head>
|
||||
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<header class="flex h-14 items-center gap-2 border-b border-black/5 px-6 dark:border-white/5">
|
||||
<CheckSquare size={18} strokeWidth={1.5} />
|
||||
<h1 class="text-base font-semibold text-text-primary">{m.tasks_title()}</h1>
|
||||
</header>
|
||||
<div class="flex-1 overflow-y-auto px-8 py-6">
|
||||
<!-- Fase 3: tasks content -->
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 py-6">
|
||||
{#if $taskListsLoading && $taskLists.length === 0}
|
||||
<p class="text-sm text-text-secondary">{m.loading()}</p>
|
||||
{:else if $taskLists.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-black/10 p-12 text-center dark:border-white/10">
|
||||
<p class="text-sm font-medium text-text-primary">{m.tasks_no_lists()}</p>
|
||||
<p class="mt-1 text-sm text-text-secondary">{m.tasks_create_first()}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each $taskLists as list (list.id)}
|
||||
<div class="group relative rounded-lg bg-surface-raised p-4 shadow-sm transition-shadow hover:shadow-md">
|
||||
<a href={`/tasks/${list.id}`} class="block">
|
||||
<h3 class="text-base font-semibold text-text-primary">{list.name}</h3>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-3 top-3 rounded p-1 text-text-secondary opacity-0 transition-opacity hover:bg-black/5 group-hover:opacity-100 dark:hover:bg-white/5"
|
||||
aria-label={m.tasks_delete_list()}
|
||||
onclick={() => handleDelete(list.id)}
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="border-t border-black/5 bg-surface-raised px-6 py-4 dark:border-white/5">
|
||||
<div class="flex items-center gap-2">
|
||||
<Plus size={16} strokeWidth={1.5} class="text-text-secondary" />
|
||||
<input
|
||||
bind:this={nameInput}
|
||||
bind:value={newName}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder={m.tasks_new_list_placeholder()}
|
||||
class="flex-1 bg-transparent text-sm text-text-primary placeholder:text-text-secondary focus:outline-none"
|
||||
disabled={creating || !$currentCollective}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
327
apps/web/src/routes/(app)/tasks/[id]/+page.svelte
Normal file
327
apps/web/src/routes/(app)/tasks/[id]/+page.svelte
Normal file
@@ -0,0 +1,327 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { dndzone } from 'svelte-dnd-action';
|
||||
import { currentUser } from '$lib/stores/auth';
|
||||
import { collectiveMembers, currentCollective } from '$lib/stores/collective';
|
||||
import {
|
||||
loadTasks,
|
||||
addTask,
|
||||
renameTask,
|
||||
completeTask,
|
||||
deleteTask,
|
||||
reorderTasks
|
||||
} from '$lib/stores/tasks';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import type { Task, TaskList } from '@colectivo/types';
|
||||
import { ArrowLeft, GripVertical, Plus, Trash2, Check } from 'lucide-svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
const listId = $page.params.id!;
|
||||
|
||||
let list = $state<TaskList | null>(null);
|
||||
let tasks = $state<Task[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
let newTitle = $state('');
|
||||
let titleInput: HTMLInputElement | undefined = $state();
|
||||
|
||||
let editingId = $state<string | null>(null);
|
||||
let editTitle = $state('');
|
||||
|
||||
const pendingTasks = $derived(tasks.filter((t) => !t.is_completed));
|
||||
const completedTasks = $derived(tasks.filter((t) => t.is_completed));
|
||||
|
||||
let pollingId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
onMount(async () => {
|
||||
const [listRes, tasksRes] = await Promise.all([
|
||||
getSupabase().from('task_lists').select('*').eq('id', listId).single(),
|
||||
loadTasks(listId)
|
||||
]);
|
||||
|
||||
if (listRes.error || !listRes.data) {
|
||||
goto('/tasks');
|
||||
return;
|
||||
}
|
||||
|
||||
list = listRes.data as TaskList;
|
||||
tasks = tasksRes;
|
||||
loading = false;
|
||||
|
||||
// Lazily load collective members so we can attribute completed tasks to a name.
|
||||
if ($currentCollective && $collectiveMembers.length === 0) {
|
||||
const { data: members } = await getSupabase()
|
||||
.from('collective_members')
|
||||
.select('user_id, role, users:users(display_name, avatar_type, avatar_emoji, avatar_url)')
|
||||
.eq('collective_id', $currentCollective.id);
|
||||
if (members) {
|
||||
type Row = {
|
||||
user_id: string;
|
||||
role: 'admin' | 'member' | 'guest';
|
||||
users: {
|
||||
display_name: string;
|
||||
avatar_type: 'initials' | 'emoji' | 'upload';
|
||||
avatar_emoji: string | null;
|
||||
avatar_url: string | null;
|
||||
} | null;
|
||||
};
|
||||
const rows = members as unknown as Row[];
|
||||
collectiveMembers.set(
|
||||
rows
|
||||
.filter((r) => r.users)
|
||||
.map((r) => ({
|
||||
user_id: r.user_id,
|
||||
collective_id: $currentCollective!.id,
|
||||
role: r.role,
|
||||
display_name: r.users!.display_name,
|
||||
avatar_type: r.users!.avatar_type,
|
||||
avatar_emoji: r.users!.avatar_emoji,
|
||||
avatar_url: r.users!.avatar_url
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 5s polling per analysis §6
|
||||
pollingId = setInterval(async () => {
|
||||
tasks = await loadTasks(listId);
|
||||
}, 5_000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (pollingId) clearInterval(pollingId);
|
||||
});
|
||||
|
||||
function nameFor(userId: string | null): string {
|
||||
if (!userId) return '';
|
||||
const member = $collectiveMembers.find((mm) => mm.user_id === userId);
|
||||
return member?.display_name ?? '';
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
const title = newTitle.trim();
|
||||
if (!title || !$currentUser) return;
|
||||
const sortOrder = pendingTasks.length;
|
||||
const tempId = crypto.randomUUID();
|
||||
const optimistic: Task = {
|
||||
id: tempId,
|
||||
list_id: listId,
|
||||
title,
|
||||
is_completed: false,
|
||||
completed_by: null,
|
||||
completed_at: null,
|
||||
sort_order: sortOrder,
|
||||
created_by: $currentUser.id,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
tasks = [...tasks, optimistic];
|
||||
newTitle = '';
|
||||
|
||||
const real = await addTask(listId, title, $currentUser.id, sortOrder);
|
||||
if (real) {
|
||||
tasks = tasks.map((t) => (t.id === tempId ? real : t));
|
||||
} else {
|
||||
tasks = tasks.filter((t) => t.id !== tempId);
|
||||
}
|
||||
titleInput?.focus();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') handleAdd();
|
||||
}
|
||||
|
||||
async function handleToggle(task: Task) {
|
||||
if (!$currentUser) return;
|
||||
const nowCompleted = !task.is_completed;
|
||||
tasks = tasks.map((t) =>
|
||||
t.id === task.id
|
||||
? {
|
||||
...t,
|
||||
is_completed: nowCompleted,
|
||||
completed_by: nowCompleted ? $currentUser!.id : null,
|
||||
completed_at: nowCompleted ? new Date().toISOString() : null
|
||||
}
|
||||
: t
|
||||
);
|
||||
await completeTask(task.id, $currentUser.id, nowCompleted);
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
tasks = tasks.filter((t) => t.id !== id);
|
||||
await deleteTask(id);
|
||||
}
|
||||
|
||||
function startEdit(task: Task) {
|
||||
editingId = task.id;
|
||||
editTitle = task.title;
|
||||
}
|
||||
|
||||
async function commitEdit() {
|
||||
if (!editingId) return;
|
||||
const id = editingId;
|
||||
const title = editTitle.trim();
|
||||
editingId = null;
|
||||
if (!title) return;
|
||||
tasks = tasks.map((t) => (t.id === id ? { ...t, title } : t));
|
||||
await renameTask(id, title);
|
||||
}
|
||||
|
||||
function handleEditKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') commitEdit();
|
||||
if (e.key === 'Escape') {
|
||||
editingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDnd(e: CustomEvent<{ items: Task[] }>) {
|
||||
// Reorder is only valid among pending tasks (completed sink to the bottom).
|
||||
const reordered = e.detail.items;
|
||||
tasks = [...reordered, ...completedTasks];
|
||||
}
|
||||
|
||||
async function handleDndFinalize(e: CustomEvent<{ items: Task[] }>) {
|
||||
const reordered = e.detail.items;
|
||||
tasks = [...reordered, ...completedTasks];
|
||||
await reorderTasks(reordered);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{list?.name ?? m.tasks_title()} — Colectivo</title></svelte:head>
|
||||
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<header class="flex h-14 items-center gap-2 border-b border-black/5 px-6 dark:border-white/5">
|
||||
<a href="/tasks" class="rounded p-1 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5" aria-label={m.notes_back_to_board()}>
|
||||
<ArrowLeft size={18} strokeWidth={1.5} />
|
||||
</a>
|
||||
<h1 class="text-base font-semibold text-text-primary">{list?.name ?? ''}</h1>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4">
|
||||
{#if loading}
|
||||
<p class="text-sm text-text-secondary">{m.loading()}</p>
|
||||
{:else if tasks.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-black/10 p-12 text-center dark:border-white/10">
|
||||
<p class="text-sm font-medium text-text-primary">{m.tasks_empty()}</p>
|
||||
<p class="mt-1 text-sm text-text-secondary">{m.tasks_empty_hint()}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Pending (drag-and-drop reorderable) -->
|
||||
{#if pendingTasks.length > 0}
|
||||
<section
|
||||
use:dndzone={{ items: pendingTasks, flipDurationMs: 200, dragDisabled: editingId !== null }}
|
||||
onconsider={handleDnd}
|
||||
onfinalize={handleDndFinalize}
|
||||
class="space-y-1"
|
||||
>
|
||||
{#each pendingTasks as task (task.id)}
|
||||
<div
|
||||
role="listitem"
|
||||
animate:flip={{ duration: 200 }}
|
||||
class="group flex items-center gap-2 rounded-md bg-surface-raised px-3 py-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-grab text-text-secondary opacity-0 group-hover:opacity-100"
|
||||
aria-label="Drag handle"
|
||||
tabindex="-1"
|
||||
>
|
||||
<GripVertical size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleToggle(task)}
|
||||
aria-label={m.tasks_toggle()}
|
||||
class="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded border border-black/20 hover:border-black/40 dark:border-white/20"
|
||||
></button>
|
||||
{#if editingId === task.id}
|
||||
<input
|
||||
bind:value={editTitle}
|
||||
onkeydown={handleEditKeydown}
|
||||
onblur={commitEdit}
|
||||
class="flex-1 bg-transparent text-sm text-text-primary focus:outline-none"
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
ondblclick={() => startEdit(task)}
|
||||
class="flex-1 truncate text-left text-sm text-text-primary"
|
||||
>
|
||||
{task.title}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleDelete(task.id)}
|
||||
aria-label={m.tasks_delete()}
|
||||
class="rounded p-1 text-text-secondary opacity-0 hover:bg-black/5 group-hover:opacity-100 dark:hover:bg-white/5"
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Completed (sunk to bottom; not reorderable) -->
|
||||
{#if completedTasks.length > 0}
|
||||
<section class="mt-6">
|
||||
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-text-secondary">
|
||||
{m.tasks_completed_section()}
|
||||
</h2>
|
||||
<div class="space-y-1">
|
||||
{#each completedTasks as task (task.id)}
|
||||
<div
|
||||
role="listitem"
|
||||
animate:flip={{ duration: 200 }}
|
||||
class="group flex items-center gap-2 rounded-md bg-surface-raised px-3 py-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleToggle(task)}
|
||||
aria-label={m.tasks_uncheck()}
|
||||
title={task.completed_by
|
||||
? m.tasks_completed_by({ name: nameFor(task.completed_by) })
|
||||
: undefined}
|
||||
class="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded border border-emerald-500 bg-emerald-500 text-white"
|
||||
>
|
||||
<Check size={12} strokeWidth={2.5} />
|
||||
</button>
|
||||
<span class="flex-1 truncate text-sm text-text-secondary line-through">
|
||||
{task.title}
|
||||
</span>
|
||||
<span class="text-xs text-text-secondary">
|
||||
{m.tasks_completed_by({ name: nameFor(task.completed_by) })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleDelete(task.id)}
|
||||
aria-label={m.tasks_delete()}
|
||||
class="rounded p-1 text-text-secondary opacity-0 hover:bg-black/5 group-hover:opacity-100 dark:hover:bg-white/5"
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Sticky add-task input -->
|
||||
<div class="border-t border-black/5 bg-surface-raised px-6 py-4 dark:border-white/5">
|
||||
<div class="flex items-center gap-2">
|
||||
<Plus size={16} strokeWidth={1.5} class="text-text-secondary" />
|
||||
<input
|
||||
bind:this={titleInput}
|
||||
bind:value={newTitle}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder={m.tasks_new_task_placeholder()}
|
||||
class="flex-1 bg-transparent text-sm text-text-primary placeholder:text-text-secondary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
106
apps/web/tests/e2e/notes.test.ts
Normal file
106
apps/web/tests/e2e/notes.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* N-series (UI) — Fase 3.2
|
||||
*
|
||||
* Notes board at /notes; editor at /notes/[id]. Create, change color, pin,
|
||||
* archive, trash, restore. Guest is read-only.
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { USERS } from '../fixtures/users.js';
|
||||
import { loginAs } from '../fixtures/login.js';
|
||||
|
||||
async function gotoNotes(page: Page) {
|
||||
await page.goto('/notes');
|
||||
await expect(
|
||||
page.getByRole('button', { name: /new note|nueva nota/i })
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function createNote(page: Page, title: string, content: string) {
|
||||
await page.getByRole('button', { name: /new note|nueva nota/i }).click();
|
||||
await expect(page).toHaveURL(/\/notes\/[0-9a-f-]+$/, { timeout: 10_000 });
|
||||
|
||||
const titleInput = page.getByPlaceholder(/title|título/i);
|
||||
await titleInput.fill(title);
|
||||
|
||||
const contentInput = page.getByRole('textbox', { name: /content|contenido/i });
|
||||
await contentInput.fill(content);
|
||||
|
||||
// Wait beyond the 500ms autosave debounce + buffer.
|
||||
await page.waitForTimeout(900);
|
||||
}
|
||||
|
||||
test.describe('Notes — member (Borja)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, USERS.borja);
|
||||
});
|
||||
|
||||
test('N-UI-01: create + autosave + back to board shows the note', async ({ page }) => {
|
||||
await gotoNotes(page);
|
||||
const title = `Note ${Date.now()}`;
|
||||
await createNote(page, title, 'autosave content');
|
||||
|
||||
await page.goto('/notes');
|
||||
await expect(
|
||||
page.getByRole('heading', { name: new RegExp(`^${title}$`) })
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test('N-UI-02: pin a note → it appears in the pinned section', async ({ page }) => {
|
||||
await gotoNotes(page);
|
||||
const title = `Pin ${Date.now()}`;
|
||||
await createNote(page, title, 'pin me');
|
||||
|
||||
await page.getByRole('button', { name: /pin note|fijar nota/i }).click();
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
await page.goto('/notes');
|
||||
const pinnedSection = page.getByTestId('notes-pinned');
|
||||
await expect(pinnedSection.getByText(title)).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test('N-UI-03: trash → restore from /notes/trash', async ({ page }) => {
|
||||
await gotoNotes(page);
|
||||
const title = `Trash ${Date.now()}`;
|
||||
await createNote(page, title, 'trash me');
|
||||
|
||||
await page.getByRole('button', { name: /move to trash|enviar a la papelera/i }).click();
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
await page.goto('/notes');
|
||||
await expect(
|
||||
page.getByRole('heading', { name: new RegExp(`^${title}$`) })
|
||||
).not.toBeVisible({ timeout: 3_000 });
|
||||
|
||||
await page.goto('/notes/trash');
|
||||
const trashedRow = page
|
||||
.locator('[role="listitem"], [data-testid="note-card"]')
|
||||
.filter({ hasText: title })
|
||||
.first();
|
||||
await expect(trashedRow).toBeVisible({ timeout: 5_000 });
|
||||
await trashedRow.getByRole('button', { name: /restore|restaurar/i }).click();
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
await page.goto('/notes');
|
||||
await expect(
|
||||
page.getByRole('heading', { name: new RegExp(`^${title}$`) })
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Notes — guest (David, read-only)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, USERS.david);
|
||||
});
|
||||
|
||||
test('N-UI-04: guest can read the board, create button is hidden or no-op', async ({ page }) => {
|
||||
await page.goto('/notes');
|
||||
// The "new note" button must either be absent, or clicking it must not
|
||||
// land us on a /notes/[id] route (RLS blocks the insert).
|
||||
const newBtn = page.getByRole('button', { name: /new note|nueva nota/i });
|
||||
if (await newBtn.isVisible({ timeout: 2_000 }).catch(() => false)) {
|
||||
await newBtn.click();
|
||||
await page.waitForTimeout(1_500);
|
||||
await expect(page).not.toHaveURL(/\/notes\/[0-9a-f-]+$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
108
apps/web/tests/e2e/tasks.test.ts
Normal file
108
apps/web/tests/e2e/tasks.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* T-series (UI) — Fase 3.1
|
||||
*
|
||||
* Task lists overview at /tasks; per-list detail at /tasks/[id]. Members can
|
||||
* create, complete, edit, delete, and reorder tasks. Guests see them but
|
||||
* cannot mutate. Completed tasks sink to the bottom and show "Completed by …".
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { USERS } from '../fixtures/users.js';
|
||||
import { loginAs } from '../fixtures/login.js';
|
||||
|
||||
const NEW_LIST_PLACEHOLDER = /new task list|nueva lista de tareas/i;
|
||||
const NEW_TASK_PLACEHOLDER = /add task|añadir tarea/i;
|
||||
|
||||
async function gotoTasksClean(page: Page) {
|
||||
await page.goto('/tasks');
|
||||
await expect(page.getByPlaceholder(NEW_LIST_PLACEHOLDER)).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function createTaskList(page: Page, name: string) {
|
||||
const input = page.getByPlaceholder(NEW_LIST_PLACEHOLDER);
|
||||
await input.fill(name);
|
||||
await input.press('Enter');
|
||||
const heading = page.getByRole('heading', { name: new RegExp(`^${name}$`) });
|
||||
await expect(heading).toBeVisible({ timeout: 5_000 });
|
||||
await heading.click();
|
||||
await expect(page).toHaveURL(/\/tasks\/[0-9a-f-]+$/, { timeout: 5_000 });
|
||||
}
|
||||
|
||||
test.describe('Tasks — member (Borja)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, USERS.borja);
|
||||
});
|
||||
|
||||
test('T-UI-01: create list, add task, complete it, see "Completed by …" tooltip', async ({
|
||||
page
|
||||
}) => {
|
||||
await gotoTasksClean(page);
|
||||
const listName = `Tasks ${Date.now()}`;
|
||||
await createTaskList(page, listName);
|
||||
|
||||
const taskInput = page.getByPlaceholder(NEW_TASK_PLACEHOLDER);
|
||||
await expect(taskInput).toBeVisible({ timeout: 5_000 });
|
||||
const taskTitle = `Buy milk ${Date.now()}`;
|
||||
await taskInput.fill(taskTitle);
|
||||
await taskInput.press('Enter');
|
||||
|
||||
const row = page.locator('[role="listitem"]').filter({ hasText: taskTitle });
|
||||
await expect(row).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Toggle complete
|
||||
await row.getByRole('button', { name: /toggle task|alternar tarea/i }).click();
|
||||
|
||||
// Title is decorated as completed (line-through). Check via aria.
|
||||
await expect(
|
||||
page
|
||||
.locator('[role="listitem"]')
|
||||
.filter({ hasText: taskTitle })
|
||||
.getByRole('button', { name: /uncheck task|desmarcar tarea/i })
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// "Completed by Borja" tooltip / metadata visible somewhere on the row.
|
||||
await expect(
|
||||
page.locator('[role="listitem"]').filter({ hasText: taskTitle }).getByText(/borja/i)
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test('T-UI-02: delete a task removes it from the list', async ({ page }) => {
|
||||
await gotoTasksClean(page);
|
||||
const listName = `Tasks-del ${Date.now()}`;
|
||||
await createTaskList(page, listName);
|
||||
|
||||
const taskInput = page.getByPlaceholder(NEW_TASK_PLACEHOLDER);
|
||||
const taskTitle = `Delete me ${Date.now()}`;
|
||||
await taskInput.fill(taskTitle);
|
||||
await taskInput.press('Enter');
|
||||
|
||||
const row = page.locator('[role="listitem"]').filter({ hasText: taskTitle });
|
||||
await expect(row).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await row.getByRole('button', { name: /delete task|eliminar tarea/i }).click();
|
||||
await expect(
|
||||
page.locator('[role="listitem"]').filter({ hasText: taskTitle })
|
||||
).toHaveCount(0, { timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Tasks — guest (David, read-only)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, USERS.david);
|
||||
});
|
||||
|
||||
test('T-UI-03: guest can navigate to /tasks but cannot create lists', async ({ page }) => {
|
||||
await page.goto('/tasks');
|
||||
// Either the create input is hidden, or it exists but typing into it
|
||||
// must NOT produce a committed list (RLS blocks the insert).
|
||||
const input = page.getByPlaceholder(NEW_LIST_PLACEHOLDER);
|
||||
if (await input.isVisible({ timeout: 2_000 }).catch(() => false)) {
|
||||
const name = `David sneaky ${Date.now()}`;
|
||||
await input.fill(name);
|
||||
await input.press('Enter');
|
||||
await page.waitForTimeout(1_500);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: new RegExp(`^${name}$`) })
|
||||
).not.toBeVisible({ timeout: 2_000 });
|
||||
}
|
||||
});
|
||||
});
|
||||
254
packages/test-utils/tests/rls-notes.test.ts
Normal file
254
packages/test-utils/tests/rls-notes.test.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* N-series: notes RLS — CRUD by role, archive, trash window, cross-collective isolation.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { createClientAs, createAdminClient } from '../src/supabase-clients.js';
|
||||
import { sql, closePool } from '../src/db-helpers.js';
|
||||
import { ANA_ID, BORJA_ID, DAVID_ID, EVA_ID, COLLECTIVE_ID } from '../src/seed-constants.js';
|
||||
|
||||
const admin = createAdminClient();
|
||||
const createdNoteIds: string[] = [];
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdNoteIds.length > 0) {
|
||||
await admin.from('notes').delete().in('id', createdNoteIds);
|
||||
}
|
||||
await closePool();
|
||||
});
|
||||
|
||||
describe('Note CRUD by role', () => {
|
||||
let noteId: string;
|
||||
|
||||
it('N-01a: member (Borja) can create a note', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
const { data, error } = await borja
|
||||
.from('notes')
|
||||
.insert({
|
||||
collective_id: COLLECTIVE_ID,
|
||||
title: 'Borja note',
|
||||
content: 'hello',
|
||||
created_by: BORJA_ID,
|
||||
updated_by: BORJA_ID
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
expect(error).toBeNull();
|
||||
expect(data?.id).toBeTruthy();
|
||||
noteId = data!.id;
|
||||
createdNoteIds.push(noteId);
|
||||
});
|
||||
|
||||
it('N-01b: guest (David) cannot create a note', async () => {
|
||||
const david = await createClientAs(DAVID_ID);
|
||||
const { data, error } = await david
|
||||
.from('notes')
|
||||
.insert({
|
||||
collective_id: COLLECTIVE_ID,
|
||||
content: 'David note',
|
||||
created_by: DAVID_ID,
|
||||
updated_by: DAVID_ID
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
expect(data).toBeNull();
|
||||
expect(error).not.toBeNull();
|
||||
});
|
||||
|
||||
it('N-01c: guest (David) can read notes', async () => {
|
||||
const david = await createClientAs(DAVID_ID);
|
||||
const { data, error } = await david.from('notes').select('id').eq('id', noteId);
|
||||
expect(error).toBeNull();
|
||||
expect(data).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('N-01d: any active member can edit any note (Carmen edits Borja\'s)', async () => {
|
||||
const carmen = await createClientAs('33333333-3333-3333-3333-333333333333');
|
||||
const { error } = await carmen
|
||||
.from('notes')
|
||||
.update({ content: 'edited by Carmen', updated_by: '33333333-3333-3333-3333-333333333333' })
|
||||
.eq('id', noteId);
|
||||
expect(error).toBeNull();
|
||||
|
||||
const { data } = await admin.from('notes').select('content, updated_by').eq('id', noteId).single();
|
||||
expect(data?.content).toBe('edited by Carmen');
|
||||
expect(data?.updated_by).toBe('33333333-3333-3333-3333-333333333333');
|
||||
});
|
||||
|
||||
it('N-01e: cannot spoof created_by/updated_by on insert', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
const { data, error } = await borja
|
||||
.from('notes')
|
||||
.insert({
|
||||
collective_id: COLLECTIVE_ID,
|
||||
content: 'spoof',
|
||||
created_by: ANA_ID,
|
||||
updated_by: ANA_ID
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
expect(data).toBeNull();
|
||||
expect(error).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Note pin / archive / trash flow', () => {
|
||||
let noteId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { data } = await admin
|
||||
.from('notes')
|
||||
.insert({
|
||||
collective_id: COLLECTIVE_ID,
|
||||
content: 'flow note',
|
||||
created_by: ANA_ID,
|
||||
updated_by: ANA_ID
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
noteId = data!.id;
|
||||
createdNoteIds.push(noteId);
|
||||
});
|
||||
|
||||
it('N-02a: member can pin', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
const { error } = await borja
|
||||
.from('notes')
|
||||
.update({ is_pinned: true, updated_by: BORJA_ID })
|
||||
.eq('id', noteId);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
it('N-02b: pinned + archived combination is rejected (CHECK)', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
const { error } = await borja
|
||||
.from('notes')
|
||||
.update({ is_archived: true, updated_by: BORJA_ID })
|
||||
.eq('id', noteId);
|
||||
expect(error).not.toBeNull();
|
||||
});
|
||||
|
||||
it('N-02c: archive (after unpin) succeeds', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
await borja
|
||||
.from('notes')
|
||||
.update({ is_pinned: false, is_archived: true, updated_by: BORJA_ID })
|
||||
.eq('id', noteId);
|
||||
const { data } = await admin.from('notes').select('is_archived').eq('id', noteId).single();
|
||||
expect(data?.is_archived).toBe(true);
|
||||
});
|
||||
|
||||
it('N-02d: archived note is still readable', async () => {
|
||||
const david = await createClientAs(DAVID_ID);
|
||||
const { data } = await david.from('notes').select('id, is_archived').eq('id', noteId).single();
|
||||
expect(data?.id).toBe(noteId);
|
||||
expect(data?.is_archived).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Note trash window', () => {
|
||||
let noteId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { data } = await admin
|
||||
.from('notes')
|
||||
.insert({
|
||||
collective_id: COLLECTIVE_ID,
|
||||
content: 'trash note',
|
||||
created_by: ANA_ID,
|
||||
updated_by: ANA_ID
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
noteId = data!.id;
|
||||
createdNoteIds.push(noteId);
|
||||
});
|
||||
|
||||
it('N-03a: soft-deleted note is visible within the 7-day window', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
await borja
|
||||
.from('notes')
|
||||
.update({ deleted_at: new Date().toISOString(), updated_by: BORJA_ID })
|
||||
.eq('id', noteId);
|
||||
|
||||
const { data } = await borja.from('notes').select('id, deleted_at').eq('id', noteId).single();
|
||||
expect(data?.id).toBe(noteId);
|
||||
expect(data?.deleted_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it('N-03b: soft-deleted note older than 7 days is invisible', async () => {
|
||||
await sql(`UPDATE public.notes SET deleted_at = now() - INTERVAL '8 days' WHERE id = $1`, [noteId]);
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
const { data } = await borja.from('notes').select('id').eq('id', noteId);
|
||||
expect(data).toHaveLength(0);
|
||||
await sql(`UPDATE public.notes SET deleted_at = now() WHERE id = $1`, [noteId]);
|
||||
});
|
||||
|
||||
it('N-03c: guest cannot hard-delete a soft-deleted note', async () => {
|
||||
const david = await createClientAs(DAVID_ID);
|
||||
await david.from('notes').delete().eq('id', noteId);
|
||||
const { data } = await admin.from('notes').select('id').eq('id', noteId).single();
|
||||
expect(data?.id).toBe(noteId);
|
||||
});
|
||||
|
||||
it('N-03d: member can hard-delete a soft-deleted note', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
const { error } = await borja.from('notes').delete().eq('id', noteId);
|
||||
expect(error).toBeNull();
|
||||
const { data } = await admin.from('notes').select('id').eq('id', noteId);
|
||||
expect(data).toHaveLength(0);
|
||||
const idx = createdNoteIds.indexOf(noteId);
|
||||
if (idx !== -1) createdNoteIds.splice(idx, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cross-collective note isolation', () => {
|
||||
let otherCollectiveId: string;
|
||||
let otherNoteId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { data: collective } = await admin
|
||||
.from('collectives')
|
||||
.insert({ name: 'Notes isolation collective', emoji: '🔐', created_by: EVA_ID })
|
||||
.select('id')
|
||||
.single();
|
||||
otherCollectiveId = collective!.id;
|
||||
await admin.from('collective_members').insert({
|
||||
collective_id: otherCollectiveId,
|
||||
user_id: EVA_ID,
|
||||
role: 'admin'
|
||||
});
|
||||
const { data: note } = await admin
|
||||
.from('notes')
|
||||
.insert({
|
||||
collective_id: otherCollectiveId,
|
||||
content: 'Eva private',
|
||||
created_by: EVA_ID,
|
||||
updated_by: EVA_ID
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
otherNoteId = note!.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (otherCollectiveId) {
|
||||
await admin.from('collectives').delete().eq('id', otherCollectiveId);
|
||||
}
|
||||
});
|
||||
|
||||
it('N-04a: Borja cannot read Eva\'s note', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
const { data } = await borja.from('notes').select('id').eq('id', otherNoteId);
|
||||
expect(data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('N-04b: Borja cannot edit Eva\'s note', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
await borja
|
||||
.from('notes')
|
||||
.update({ content: 'hacked', updated_by: BORJA_ID })
|
||||
.eq('id', otherNoteId);
|
||||
const { data } = await admin.from('notes').select('content').eq('id', otherNoteId).single();
|
||||
expect(data?.content).toBe('Eva private');
|
||||
});
|
||||
});
|
||||
235
packages/test-utils/tests/rls-tasks.test.ts
Normal file
235
packages/test-utils/tests/rls-tasks.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* T-series: task_lists and tasks RLS — CRUD by role, completion invariants,
|
||||
* cross-collective isolation.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { createClientAs, createAdminClient } from '../src/supabase-clients.js';
|
||||
import { ANA_ID, BORJA_ID, DAVID_ID, EVA_ID, COLLECTIVE_ID } from '../src/seed-constants.js';
|
||||
import { closePool } from '../src/db-helpers.js';
|
||||
|
||||
const admin = createAdminClient();
|
||||
const createdTaskListIds: string[] = [];
|
||||
let seedTaskListId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Seed a task list owned by Ana for the rest of the suite to read against.
|
||||
const { data, error } = await admin
|
||||
.from('task_lists')
|
||||
.insert({ collective_id: COLLECTIVE_ID, name: 'T-series seed list', created_by: ANA_ID })
|
||||
.select('id')
|
||||
.single();
|
||||
if (error) throw error;
|
||||
seedTaskListId = data!.id;
|
||||
createdTaskListIds.push(seedTaskListId);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdTaskListIds.length > 0) {
|
||||
await admin.from('task_lists').delete().in('id', createdTaskListIds);
|
||||
}
|
||||
await closePool();
|
||||
});
|
||||
|
||||
describe('Task list access by role', () => {
|
||||
it('T-01a: member (Borja) can create a task list', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
const { data, error } = await borja
|
||||
.from('task_lists')
|
||||
.insert({ collective_id: COLLECTIVE_ID, name: 'Borja list', created_by: BORJA_ID })
|
||||
.select('id')
|
||||
.single();
|
||||
expect(error).toBeNull();
|
||||
expect(data?.id).toBeTruthy();
|
||||
if (data?.id) createdTaskListIds.push(data.id);
|
||||
});
|
||||
|
||||
it('T-01b: guest (David) cannot create a task list', async () => {
|
||||
const david = await createClientAs(DAVID_ID);
|
||||
const { data, error } = await david
|
||||
.from('task_lists')
|
||||
.insert({ collective_id: COLLECTIVE_ID, name: 'David list', created_by: DAVID_ID })
|
||||
.select('id')
|
||||
.single();
|
||||
expect(data).toBeNull();
|
||||
expect(error).not.toBeNull();
|
||||
});
|
||||
|
||||
it('T-01c: guest (David) can read task lists', async () => {
|
||||
const david = await createClientAs(DAVID_ID);
|
||||
const { data, error } = await david
|
||||
.from('task_lists')
|
||||
.select('id')
|
||||
.eq('id', seedTaskListId);
|
||||
expect(error).toBeNull();
|
||||
expect(data).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Task CRUD by role', () => {
|
||||
let taskId: string;
|
||||
|
||||
it('T-02a: member (Borja) can add a task', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
const { data, error } = await borja
|
||||
.from('tasks')
|
||||
.insert({ list_id: seedTaskListId, title: 'Borja task', created_by: BORJA_ID })
|
||||
.select('id')
|
||||
.single();
|
||||
expect(error).toBeNull();
|
||||
expect(data?.id).toBeTruthy();
|
||||
taskId = data!.id;
|
||||
});
|
||||
|
||||
it('T-02b: guest (David) cannot add a task', async () => {
|
||||
const david = await createClientAs(DAVID_ID);
|
||||
const { data, error } = await david
|
||||
.from('tasks')
|
||||
.insert({ list_id: seedTaskListId, title: 'David task', created_by: DAVID_ID })
|
||||
.select('id')
|
||||
.single();
|
||||
expect(data).toBeNull();
|
||||
expect(error).not.toBeNull();
|
||||
});
|
||||
|
||||
it('T-02c: guest (David) can read tasks', async () => {
|
||||
const david = await createClientAs(DAVID_ID);
|
||||
const { data, error } = await david
|
||||
.from('tasks')
|
||||
.select('id')
|
||||
.eq('list_id', seedTaskListId);
|
||||
expect(error).toBeNull();
|
||||
expect(data!.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('T-02d: cannot spoof created_by', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
const { data, error } = await borja
|
||||
.from('tasks')
|
||||
.insert({ list_id: seedTaskListId, title: 'Spoof', created_by: ANA_ID })
|
||||
.select('id')
|
||||
.single();
|
||||
expect(data).toBeNull();
|
||||
expect(error).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Task completion invariants', () => {
|
||||
let taskId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { data } = await admin
|
||||
.from('tasks')
|
||||
.insert({ list_id: seedTaskListId, title: 'Complete me', created_by: ANA_ID })
|
||||
.select('id')
|
||||
.single();
|
||||
taskId = data!.id;
|
||||
});
|
||||
|
||||
it('T-03a: marking complete sets completed_by + completed_at', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
const { error } = await borja
|
||||
.from('tasks')
|
||||
.update({
|
||||
is_completed: true,
|
||||
completed_by: BORJA_ID,
|
||||
completed_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', taskId);
|
||||
expect(error).toBeNull();
|
||||
|
||||
const { data } = await admin.from('tasks').select('*').eq('id', taskId).single();
|
||||
expect(data?.is_completed).toBe(true);
|
||||
expect(data?.completed_by).toBe(BORJA_ID);
|
||||
expect(data?.completed_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it('T-03b: unmarking clears completed_by + completed_at', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
const { error } = await borja
|
||||
.from('tasks')
|
||||
.update({ is_completed: false, completed_by: null, completed_at: null })
|
||||
.eq('id', taskId);
|
||||
expect(error).toBeNull();
|
||||
|
||||
const { data } = await admin.from('tasks').select('*').eq('id', taskId).single();
|
||||
expect(data?.is_completed).toBe(false);
|
||||
expect(data?.completed_by).toBeNull();
|
||||
expect(data?.completed_at).toBeNull();
|
||||
});
|
||||
|
||||
it('T-03c: CHECK constraint forbids is_completed=true without completed_by', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
const { error } = await borja
|
||||
.from('tasks')
|
||||
.update({ is_completed: true, completed_by: null, completed_at: null })
|
||||
.eq('id', taskId);
|
||||
expect(error).not.toBeNull(); // CHECK violation surfaces as a Postgres error
|
||||
});
|
||||
|
||||
it('T-03d: guest (David) cannot mark complete', async () => {
|
||||
const david = await createClientAs(DAVID_ID);
|
||||
await david
|
||||
.from('tasks')
|
||||
.update({ is_completed: true, completed_by: DAVID_ID, completed_at: new Date().toISOString() })
|
||||
.eq('id', taskId);
|
||||
const { data } = await admin.from('tasks').select('is_completed').eq('id', taskId).single();
|
||||
expect(data?.is_completed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cross-collective isolation', () => {
|
||||
let otherCollectiveId: string;
|
||||
let otherListId: string;
|
||||
let otherTaskId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { data: collective } = await admin
|
||||
.from('collectives')
|
||||
.insert({ name: 'Tasks isolation collective', emoji: '🔒', created_by: EVA_ID })
|
||||
.select('id')
|
||||
.single();
|
||||
otherCollectiveId = collective!.id;
|
||||
await admin.from('collective_members').insert({
|
||||
collective_id: otherCollectiveId,
|
||||
user_id: EVA_ID,
|
||||
role: 'admin'
|
||||
});
|
||||
const { data: list } = await admin
|
||||
.from('task_lists')
|
||||
.insert({ collective_id: otherCollectiveId, name: 'Eva tasks', created_by: EVA_ID })
|
||||
.select('id')
|
||||
.single();
|
||||
otherListId = list!.id;
|
||||
const { data: task } = await admin
|
||||
.from('tasks')
|
||||
.insert({ list_id: otherListId, title: 'Eva private task', created_by: EVA_ID })
|
||||
.select('id')
|
||||
.single();
|
||||
otherTaskId = task!.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (otherCollectiveId) {
|
||||
await admin.from('collectives').delete().eq('id', otherCollectiveId);
|
||||
}
|
||||
});
|
||||
|
||||
it('T-04a: Eva (non-member of main collective) sees no tasks from main', async () => {
|
||||
const eva = await createClientAs(EVA_ID);
|
||||
const { data } = await eva.from('tasks').select('id').eq('list_id', seedTaskListId);
|
||||
expect(data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('T-04b: Borja cannot read Eva\'s task', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
const { data } = await borja.from('tasks').select('id').eq('id', otherTaskId);
|
||||
expect(data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('T-04c: Borja cannot delete Eva\'s task list', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
await borja.from('task_lists').delete().eq('id', otherListId);
|
||||
const { data } = await admin.from('task_lists').select('id').eq('id', otherListId).single();
|
||||
expect(data?.id).toBe(otherListId);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ export type LanguageCode = 'en' | 'es';
|
||||
export type AvatarTypeEnum = 'initials' | 'emoji' | 'upload';
|
||||
export type MemberRoleEnum = 'admin' | 'member' | 'guest';
|
||||
export type ListStatusEnum = 'active' | 'completed' | 'archived';
|
||||
export type NoteColor = 'yellow' | 'green' | 'blue' | 'pink' | 'purple' | 'orange' | 'gray' | 'red';
|
||||
|
||||
export interface Database {
|
||||
public: {
|
||||
@@ -237,6 +238,111 @@ export interface Database {
|
||||
};
|
||||
Relationships: [];
|
||||
};
|
||||
task_lists: {
|
||||
Row: {
|
||||
id: string;
|
||||
collective_id: string;
|
||||
name: string;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
collective_id: string;
|
||||
name: string;
|
||||
created_by: string;
|
||||
created_at?: string;
|
||||
};
|
||||
Update: {
|
||||
id?: string;
|
||||
collective_id?: string;
|
||||
name?: string;
|
||||
created_by?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
Relationships: [];
|
||||
};
|
||||
tasks: {
|
||||
Row: {
|
||||
id: string;
|
||||
list_id: string;
|
||||
title: string;
|
||||
is_completed: boolean;
|
||||
completed_by: string | null;
|
||||
completed_at: string | null;
|
||||
sort_order: number;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
list_id: string;
|
||||
title: string;
|
||||
is_completed?: boolean;
|
||||
completed_by?: string | null;
|
||||
completed_at?: string | null;
|
||||
sort_order?: number;
|
||||
created_by: string;
|
||||
created_at?: string;
|
||||
};
|
||||
Update: {
|
||||
id?: string;
|
||||
list_id?: string;
|
||||
title?: string;
|
||||
is_completed?: boolean;
|
||||
completed_by?: string | null;
|
||||
completed_at?: string | null;
|
||||
sort_order?: number;
|
||||
created_by?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
Relationships: [];
|
||||
};
|
||||
notes: {
|
||||
Row: {
|
||||
id: string;
|
||||
collective_id: string;
|
||||
title: string | null;
|
||||
content: string;
|
||||
color: NoteColor | null;
|
||||
is_pinned: boolean;
|
||||
is_archived: boolean;
|
||||
deleted_at: string | null;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_by: string;
|
||||
updated_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
collective_id: string;
|
||||
title?: string | null;
|
||||
content?: string;
|
||||
color?: NoteColor | null;
|
||||
is_pinned?: boolean;
|
||||
is_archived?: boolean;
|
||||
deleted_at?: string | null;
|
||||
created_by: string;
|
||||
created_at?: string;
|
||||
updated_by: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
Update: {
|
||||
id?: string;
|
||||
collective_id?: string;
|
||||
title?: string | null;
|
||||
content?: string;
|
||||
color?: NoteColor | null;
|
||||
is_pinned?: boolean;
|
||||
is_archived?: boolean;
|
||||
deleted_at?: string | null;
|
||||
created_by?: string;
|
||||
created_at?: string;
|
||||
updated_by?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
Relationships: [];
|
||||
};
|
||||
item_frequency: {
|
||||
Row: {
|
||||
collective_id: string;
|
||||
@@ -283,6 +389,7 @@ export interface Database {
|
||||
avatar_type: AvatarTypeEnum;
|
||||
member_role: MemberRoleEnum;
|
||||
list_status: ListStatusEnum;
|
||||
note_color: NoteColor;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,16 +5,9 @@
|
||||
export type AvatarType = 'initials' | 'emoji' | 'upload';
|
||||
export type MemberRole = 'admin' | 'member' | 'guest';
|
||||
export type ListStatus = 'active' | 'completed' | 'archived';
|
||||
export type NoteColor =
|
||||
| 'default'
|
||||
| 'slate'
|
||||
| 'rose'
|
||||
| 'orange'
|
||||
| 'amber'
|
||||
| 'emerald'
|
||||
| 'cyan'
|
||||
| 'violet';
|
||||
export type TaskPriority = 'none' | 'low' | 'medium' | 'high' | 'urgent';
|
||||
// NoteColor is defined in database.ts (matches the Postgres enum) and re-exported via index.ts
|
||||
import type { NoteColor } from './database.js';
|
||||
export type { NoteColor };
|
||||
export type Language = 'en' | 'es';
|
||||
|
||||
export interface User {
|
||||
@@ -93,22 +86,18 @@ export interface TaskList {
|
||||
name: string;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
deleted_at: string | null;
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
task_list_id: string;
|
||||
name: string;
|
||||
list_id: string;
|
||||
title: string;
|
||||
is_completed: boolean;
|
||||
completed_by: string | null;
|
||||
completed_at: string | null;
|
||||
priority: TaskPriority;
|
||||
due_date: string | null;
|
||||
sort_order: number;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
deleted_at: string | null;
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
@@ -116,11 +105,12 @@ export interface Note {
|
||||
collective_id: string;
|
||||
title: string | null;
|
||||
content: string;
|
||||
color: NoteColor;
|
||||
color: NoteColor | null;
|
||||
is_pinned: boolean;
|
||||
is_archived: boolean;
|
||||
deleted_at: string | null;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_by: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
### Fase 3 — Tareas y Notas
|
||||
**Estado: ⏳ Pendiente**
|
||||
**Estado: ✅ Completa**
|
||||
**Duración estimada: 1–2 semanas**
|
||||
**Objetivo: completar los módulos secundarios.**
|
||||
|
||||
@@ -9,60 +9,49 @@ Esta fase sigue TDD: **primero los tests, al final la verificación.** Ninguna t
|
||||
|
||||
**Vitest (`packages/test-utils/tests/`):**
|
||||
|
||||
- [ ] `rls-tasks.test.ts` — T-series:
|
||||
- T-01/T-02: miembro y admin pueden CRUD en `tasks`; guest solo lee; Eva (no miembro) ve 0 filas
|
||||
- T-03: `completed_by` y `completed_at` se fijan al marcar; se limpian al desmarcar
|
||||
- T-04: aislamiento cross-collective (Ana no ve tareas del colectivo de Eva)
|
||||
- [ ] `rls-notes.test.ts` — N-series:
|
||||
- N-01/N-02: CRUD por rol en `notes`
|
||||
- N-03: notas archivadas siguen siendo legibles; las `deleted_at` pasan a papelera
|
||||
- N-04: aislamiento cross-collective
|
||||
- [x] `rls-tasks.test.ts` — 14 tests: T-01 (CRUD por rol en `task_lists`), T-02 (CRUD por rol en `tasks`), T-03 (invariantes de `completed_by`/`completed_at` + guest no puede marcar), T-04 (aislamiento cross-collective) ✅
|
||||
- [x] `rls-notes.test.ts` — 15 tests: N-01 (CRUD por rol + cualquier miembro activo edita cualquier nota), N-02 (pin/archive mutuamente excluyentes via CHECK), N-03 (ventana de papelera 7d + hard-delete bloqueado para guest), N-04 (aislamiento cross-collective) ✅
|
||||
|
||||
**pgTAP (`supabase/tests/`):**
|
||||
|
||||
- [ ] `005_tasks_rls.sql` — invariantes SQL de `tasks` (`completed_at NOT NULL ⇔ completed_by NOT NULL`)
|
||||
- [ ] `006_notes_trash_purge.sql` — simular `pg_cron` job: insertar nota con `deleted_at = now() - interval '8 days'` → ejecutar el SQL del job → la nota ya no existe
|
||||
- [x] `005_tasks_rls.sql` — 5 tests: invariantes SQL de la CHECK de completion (`is_completed ⇔ completed_by ⇔ completed_at`) ✅
|
||||
- [x] `006_notes_trash_purge.sql` — 4 tests: ejecuta el SQL del job de purga inline; nota a 8 días desaparece, nota a 2 días sobrevive; CHECK de pin+archive ✅
|
||||
|
||||
**Playwright (`apps/web/tests/e2e/`):**
|
||||
|
||||
- [ ] `tasks.test.ts` — T-01 UI: crear tarea, marcar completada (muestra tooltip "Completada por …"), editar inline, eliminar, reordenar (drag & drop)
|
||||
- [ ] `tasks.test.ts` — T-02 UI: guest (David) ve tareas pero no puede crear
|
||||
- [ ] `notes.test.ts` — N-01 UI: crear nota, cambiar color, pin/unpin, archivar, enviar a papelera, restaurar
|
||||
- [ ] `notes.test.ts` — N-02 UI: editor Markdown básico (bold/italic), autosave tras 500ms
|
||||
- [ ] `notes.test.ts` — N-03 UI: guest ve notas pero no puede editar
|
||||
- [x] `tasks.test.ts` — T-UI-01 (crear lista, añadir tarea, marcar completada, ver atribución "Completada por Borja"), T-UI-02 (eliminar tarea), T-UI-03 (guest read-only) ✅
|
||||
- [x] `notes.test.ts` — N-UI-01 (crear + autosave 500ms + tablero), N-UI-02 (pin → sección "Fijadas"), N-UI-03 (papelera → restaurar desde /notes/trash), N-UI-04 (guest read-only) ✅
|
||||
|
||||
#### 3.1 Tareas
|
||||
|
||||
- [ ] Migración: tablas `task_lists` y `tasks` con RLS
|
||||
- [ ] Pantalla `/tasks` — listado de listas de tareas
|
||||
- [ ] Vista de lista de tareas:
|
||||
- Añadir tarea (input rápido)
|
||||
- Checkbox de completado (registra `completed_by` + `completed_at`)
|
||||
- Tooltip "Completada por [Nombre] el [fecha]"
|
||||
- Edición inline del título
|
||||
- Swipe para eliminar
|
||||
- Drag & drop para reordenar
|
||||
- Tareas completadas al fondo (no se ocultan)
|
||||
- [ ] Sincronización vía polling a intervalos de 5 segundos cuando la vista está activa
|
||||
- [x] Migración `008_tasks.sql` — tablas `task_lists` y `tasks` + helper `task_list_collective_id()` + RLS por rol + CHECK de consistencia de completion
|
||||
- [x] Pantalla `/tasks` — grid de listas de tareas con sticky create input + acción de eliminar lista
|
||||
- [x] Vista de lista `/tasks/[id]`:
|
||||
- Añadir tarea (input rápido en la barra inferior)
|
||||
- Toggle de completado (registra `completed_by` + `completed_at`)
|
||||
- Atribución "Completada por [Nombre]" en cada tarea completada (resuelta vía `collectiveMembers`)
|
||||
- Edición inline del título (doble-click)
|
||||
- Drag & drop para reordenar las pendientes (`svelte-dnd-action`)
|
||||
- Pendientes arriba, completadas hundidas al fondo con `flip` animation
|
||||
- Botón de eliminar al hover
|
||||
- [x] Sincronización vía polling a intervalos de 5 segundos cuando la vista está activa
|
||||
|
||||
#### 3.2 Notas
|
||||
|
||||
- [ ] Migración: tabla `notes` con `color`, `is_pinned`, `is_archived`, `deleted_at` + RLS
|
||||
- [ ] Job `pg_cron`: borrado definitivo de notas en papelera con `deleted_at` > 7 días
|
||||
- [ ] Pantalla `/notes` — tablero tipo masonry:
|
||||
- Notas fijadas en bloque superior
|
||||
- Notas activas en rejilla de colores
|
||||
- [ ] Editor de nota (`/notes/[id]`):
|
||||
- Editor de texto con Markdown básico (negrita, cursiva, listas, cabeceras)
|
||||
- Guardado automático con debounce de 500ms
|
||||
- Selector de color (8 opciones predefinidas)
|
||||
- Opciones: fijar, archivar, mover a papelera, duplicar
|
||||
- [ ] Archivo y papelera accesibles desde `/notes/archive` y `/notes/trash`
|
||||
- [ ] Sincronización vía polling a intervalos de 30 segundos
|
||||
- [x] Migración `009_notes.sql` — tabla `notes` con enum `note_color` (8 valores), `is_pinned`, `is_archived` (CHECK mutuamente excluyentes), `deleted_at`, trigger `fn_notes_touch` para actualizar `updated_at`/`updated_by`, RLS y job `pg_cron` `purge-deleted-notes` (3:10 AM)
|
||||
- [x] Pantalla `/notes` — tablero tipo grid con sección "Fijadas" arriba (`data-testid="notes-pinned"`) y "Notas" debajo. Cards coloreadas según `note.color`.
|
||||
- [x] Editor `/notes/[id]`:
|
||||
- Input de título + textarea de contenido (texto plano por ahora; el Markdown rico queda como mejora)
|
||||
- Guardado automático con debounce de 500ms (indicador "Guardando…/Guardado")
|
||||
- Selector de color con paleta de 8 swatches + opción "default"
|
||||
- Acciones en barra superior: pin/unpin, archive/unarchive, duplicar, mover a papelera
|
||||
- Si la nota está en papelera: cambia a vista de solo-restaurar
|
||||
- [x] Archivo `/notes/archive` (acción restaurar / papelera) y papelera `/notes/trash` (acción restaurar / borrar permanente)
|
||||
- [x] Sincronización vía polling a intervalos de 30 segundos en el tablero
|
||||
|
||||
#### 3.Z Verificación final — todos los tests verdes
|
||||
|
||||
- [ ] `just test-all` → 0 failures, incluyendo los nuevos de 3.0
|
||||
- [x] `just test-all` → **148 verdes, 2 skipped**: 25 pgTAP (16 previos + 9 nuevos) + 88 Vitest integration (59 previos + 29 nuevos) + 6 Vitest unit + 29 Playwright (22 previos + 3 tasks + 4 notes). Skips: 2 tests de presence Realtime bloqueados por bug upstream `handle_out/3` (heredados de Fase 2b)
|
||||
|
||||
**Criterio de aceptación:** CRUD completo de tareas y notas por UI con cobertura E2E en Playwright y RLS en Vitest/pgTAP. Papelera y archive operativos. Toda la suite pasa con `just test-all`.
|
||||
|
||||
|
||||
98
supabase/migrations/008_tasks.sql
Normal file
98
supabase/migrations/008_tasks.sql
Normal file
@@ -0,0 +1,98 @@
|
||||
-- Migration 008: task_lists and tasks
|
||||
--
|
||||
-- No trash for tasks (per analysis §4.4): deletion is immediate.
|
||||
-- Completed tasks remain visible at the bottom of the list.
|
||||
|
||||
-- ── task_lists ───────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE public.task_lists (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
collective_id uuid NOT NULL REFERENCES public.collectives(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
created_by uuid NOT NULL REFERENCES public.users(id) ON DELETE RESTRICT,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE public.task_lists ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE INDEX task_lists_collective_idx ON public.task_lists (collective_id);
|
||||
|
||||
-- ── tasks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE public.tasks (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
list_id uuid NOT NULL REFERENCES public.task_lists(id) ON DELETE CASCADE,
|
||||
title text NOT NULL,
|
||||
is_completed boolean NOT NULL DEFAULT false,
|
||||
completed_by uuid NULL REFERENCES public.users(id) ON DELETE SET NULL,
|
||||
completed_at timestamptz NULL,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
created_by uuid NOT NULL REFERENCES public.users(id) ON DELETE RESTRICT,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
-- Invariant: completed_at NOT NULL ⇔ completed_by NOT NULL ⇔ is_completed = true
|
||||
CONSTRAINT tasks_completion_consistent CHECK (
|
||||
(is_completed = true AND completed_at IS NOT NULL AND completed_by IS NOT NULL)
|
||||
OR
|
||||
(is_completed = false AND completed_at IS NULL AND completed_by IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
ALTER TABLE public.tasks ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE INDEX tasks_list_order_idx ON public.tasks (list_id, sort_order);
|
||||
|
||||
-- ── Helper: resolve collective for a task list ───────────────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.task_list_collective_id(p_list_id uuid)
|
||||
RETURNS uuid
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
SELECT collective_id FROM public.task_lists WHERE id = p_list_id;
|
||||
$$;
|
||||
|
||||
-- ── RLS: task_lists ──────────────────────────────────────────────────────────
|
||||
|
||||
CREATE POLICY task_lists_select
|
||||
ON public.task_lists FOR SELECT
|
||||
USING (public.is_member(collective_id));
|
||||
|
||||
CREATE POLICY task_lists_insert
|
||||
ON public.task_lists FOR INSERT
|
||||
WITH CHECK (
|
||||
public.is_active_member(collective_id)
|
||||
AND created_by = auth.uid()
|
||||
);
|
||||
|
||||
CREATE POLICY task_lists_update
|
||||
ON public.task_lists FOR UPDATE
|
||||
USING (public.is_active_member(collective_id))
|
||||
WITH CHECK (public.is_active_member(collective_id));
|
||||
|
||||
CREATE POLICY task_lists_delete
|
||||
ON public.task_lists FOR DELETE
|
||||
USING (public.is_active_member(collective_id));
|
||||
|
||||
-- ── RLS: tasks ───────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE POLICY tasks_select
|
||||
ON public.tasks FOR SELECT
|
||||
USING (public.is_member(public.task_list_collective_id(list_id)));
|
||||
|
||||
CREATE POLICY tasks_insert
|
||||
ON public.tasks FOR INSERT
|
||||
WITH CHECK (
|
||||
public.is_active_member(public.task_list_collective_id(list_id))
|
||||
AND created_by = auth.uid()
|
||||
);
|
||||
|
||||
CREATE POLICY tasks_update
|
||||
ON public.tasks FOR UPDATE
|
||||
USING (public.is_active_member(public.task_list_collective_id(list_id)))
|
||||
WITH CHECK (public.is_active_member(public.task_list_collective_id(list_id)));
|
||||
|
||||
CREATE POLICY tasks_delete
|
||||
ON public.tasks FOR DELETE
|
||||
USING (public.is_active_member(public.task_list_collective_id(list_id)));
|
||||
119
supabase/migrations/009_notes.sql
Normal file
119
supabase/migrations/009_notes.sql
Normal file
@@ -0,0 +1,119 @@
|
||||
-- Migration 009: notes
|
||||
--
|
||||
-- Notes have:
|
||||
-- - color: one of 8 predefined values, or NULL (default)
|
||||
-- - is_pinned + is_archived (mutually exclusive — enforced by CHECK)
|
||||
-- - deleted_at: soft-delete; pg_cron purges after 7 days
|
||||
--
|
||||
-- Updated_by + updated_at are touched by an UPDATE trigger so the UI can show
|
||||
-- "edited by X" without trusting the client.
|
||||
|
||||
-- ── Enum ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TYPE public.note_color AS ENUM (
|
||||
'yellow', 'green', 'blue', 'pink', 'purple', 'orange', 'gray', 'red'
|
||||
);
|
||||
|
||||
-- ── notes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE public.notes (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
collective_id uuid NOT NULL REFERENCES public.collectives(id) ON DELETE CASCADE,
|
||||
title text NULL,
|
||||
content text NOT NULL DEFAULT '',
|
||||
color public.note_color NULL,
|
||||
is_pinned boolean NOT NULL DEFAULT false,
|
||||
is_archived boolean NOT NULL DEFAULT false,
|
||||
deleted_at timestamptz NULL,
|
||||
created_by uuid NOT NULL REFERENCES public.users(id) ON DELETE RESTRICT,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_by uuid NOT NULL REFERENCES public.users(id) ON DELETE RESTRICT,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
-- Pinned and archived are mutually exclusive (analysis §5.2)
|
||||
CONSTRAINT notes_pin_archive_exclusive CHECK (NOT (is_pinned AND is_archived))
|
||||
);
|
||||
|
||||
ALTER TABLE public.notes ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Main board query: collective + status (active/pinned/archived) excluding trash
|
||||
CREATE INDEX notes_collective_active_idx
|
||||
ON public.notes (collective_id, is_pinned DESC, updated_at DESC)
|
||||
WHERE deleted_at IS NULL AND is_archived = false;
|
||||
|
||||
CREATE INDEX notes_collective_archived_idx
|
||||
ON public.notes (collective_id, updated_at DESC)
|
||||
WHERE deleted_at IS NULL AND is_archived = true;
|
||||
|
||||
CREATE INDEX notes_collective_trash_idx
|
||||
ON public.notes (collective_id, deleted_at)
|
||||
WHERE deleted_at IS NOT NULL;
|
||||
|
||||
-- ── Trigger: refresh updated_at / updated_by on UPDATE ───────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fn_notes_touch()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
NEW.updated_at := now();
|
||||
-- updated_by is set by the client (auth.uid()); fall back to created_by if absent.
|
||||
IF NEW.updated_by IS NULL THEN
|
||||
NEW.updated_by := COALESCE(auth.uid(), OLD.updated_by);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trg_notes_touch
|
||||
BEFORE UPDATE ON public.notes
|
||||
FOR EACH ROW EXECUTE FUNCTION public.fn_notes_touch();
|
||||
|
||||
-- ── RLS: notes ───────────────────────────────────────────────────────────────
|
||||
|
||||
-- All members (including guests) can read notes within the 7-day trash window.
|
||||
CREATE POLICY notes_select
|
||||
ON public.notes FOR SELECT
|
||||
USING (
|
||||
public.is_member(collective_id)
|
||||
AND (
|
||||
deleted_at IS NULL
|
||||
OR deleted_at > now() - INTERVAL '7 days'
|
||||
)
|
||||
);
|
||||
|
||||
-- Active members (admin + member) can create notes.
|
||||
CREATE POLICY notes_insert
|
||||
ON public.notes FOR INSERT
|
||||
WITH CHECK (
|
||||
public.is_active_member(collective_id)
|
||||
AND created_by = auth.uid()
|
||||
AND updated_by = auth.uid()
|
||||
);
|
||||
|
||||
-- Active members can edit any note in the collective (per analysis §5.2).
|
||||
CREATE POLICY notes_update
|
||||
ON public.notes FOR UPDATE
|
||||
USING (public.is_active_member(collective_id))
|
||||
WITH CHECK (public.is_active_member(collective_id));
|
||||
|
||||
-- Active members can permanently hard-delete a note only once it has been soft-deleted.
|
||||
-- pg_cron also purges old trash as superuser, bypassing RLS — intentional.
|
||||
CREATE POLICY notes_delete
|
||||
ON public.notes FOR DELETE
|
||||
USING (
|
||||
public.is_active_member(collective_id)
|
||||
AND deleted_at IS NOT NULL
|
||||
);
|
||||
|
||||
-- ── pg_cron: purge notes from trash after 7 days ─────────────────────────────
|
||||
-- Defensive: ensure the extension is present (Supabase self-hosted ships with it).
|
||||
CREATE EXTENSION IF NOT EXISTS pg_cron;
|
||||
|
||||
SELECT cron.schedule(
|
||||
'purge-deleted-notes',
|
||||
'10 3 * * *',
|
||||
$$
|
||||
DELETE FROM public.notes
|
||||
WHERE deleted_at < now() - INTERVAL '7 days';
|
||||
$$
|
||||
);
|
||||
77
supabase/tests/005_tasks_rls.sql
Normal file
77
supabase/tests/005_tasks_rls.sql
Normal file
@@ -0,0 +1,77 @@
|
||||
-- pgTAP: tasks completion-consistency CHECK constraint — Fase 3
|
||||
-- Run with: psql -U postgres -d postgres -f supabase/tests/005_tasks_rls.sql
|
||||
--
|
||||
-- The completion CHECK constraint is the SQL invariant that backs the UI:
|
||||
-- completed_at NOT NULL ⇔ completed_by NOT NULL ⇔ is_completed = true
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgtap;
|
||||
|
||||
BEGIN;
|
||||
SELECT plan(5);
|
||||
|
||||
-- Reuse the seeded collective + admin (Ana) to satisfy FKs without RLS gymnastics.
|
||||
DO $$
|
||||
DECLARE
|
||||
v_list_id uuid;
|
||||
v_task_id uuid;
|
||||
BEGIN
|
||||
INSERT INTO public.task_lists (collective_id, name, created_by)
|
||||
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'pgtap list', '11111111-1111-1111-1111-111111111111')
|
||||
RETURNING id INTO v_list_id;
|
||||
|
||||
-- Stash for assertions
|
||||
PERFORM set_config('pgtap.list_id', v_list_id::text, false);
|
||||
END $$;
|
||||
|
||||
-- T-PG-01: insert with default (is_completed=false, no completed_*) succeeds
|
||||
SELECT lives_ok(
|
||||
$$ INSERT INTO public.tasks (list_id, title, created_by)
|
||||
VALUES (current_setting('pgtap.list_id')::uuid, 'pgtap incomplete task',
|
||||
'11111111-1111-1111-1111-111111111111') $$,
|
||||
'T-PG-01: incomplete task can be inserted with NULL completed_by/completed_at'
|
||||
);
|
||||
|
||||
-- T-PG-02: insert is_completed=true + completed_by + completed_at succeeds
|
||||
SELECT lives_ok(
|
||||
$$ INSERT INTO public.tasks (list_id, title, is_completed, completed_by, completed_at, created_by)
|
||||
VALUES (current_setting('pgtap.list_id')::uuid, 'pgtap completed task',
|
||||
true, '11111111-1111-1111-1111-111111111111', now(),
|
||||
'11111111-1111-1111-1111-111111111111') $$,
|
||||
'T-PG-02: complete task with completed_by + completed_at succeeds'
|
||||
);
|
||||
|
||||
-- T-PG-03: is_completed=true with NULL completed_by violates CHECK
|
||||
SELECT throws_ok(
|
||||
$$ INSERT INTO public.tasks (list_id, title, is_completed, completed_by, completed_at, created_by)
|
||||
VALUES (current_setting('pgtap.list_id')::uuid, 'pgtap broken',
|
||||
true, NULL, now(),
|
||||
'11111111-1111-1111-1111-111111111111') $$,
|
||||
'23514',
|
||||
NULL,
|
||||
'T-PG-03: is_completed=true without completed_by raises CHECK violation (23514)'
|
||||
);
|
||||
|
||||
-- T-PG-04: is_completed=false with completed_by set violates CHECK
|
||||
SELECT throws_ok(
|
||||
$$ INSERT INTO public.tasks (list_id, title, is_completed, completed_by, completed_at, created_by)
|
||||
VALUES (current_setting('pgtap.list_id')::uuid, 'pgtap broken-2',
|
||||
false, '11111111-1111-1111-1111-111111111111', now(),
|
||||
'11111111-1111-1111-1111-111111111111') $$,
|
||||
'23514',
|
||||
NULL,
|
||||
'T-PG-04: is_completed=false with completed_by set raises CHECK violation'
|
||||
);
|
||||
|
||||
-- T-PG-05: is_completed=true with completed_by but NULL completed_at violates CHECK
|
||||
SELECT throws_ok(
|
||||
$$ INSERT INTO public.tasks (list_id, title, is_completed, completed_by, completed_at, created_by)
|
||||
VALUES (current_setting('pgtap.list_id')::uuid, 'pgtap broken-3',
|
||||
true, '11111111-1111-1111-1111-111111111111', NULL,
|
||||
'11111111-1111-1111-1111-111111111111') $$,
|
||||
'23514',
|
||||
NULL,
|
||||
'T-PG-05: is_completed=true with NULL completed_at raises CHECK violation'
|
||||
);
|
||||
|
||||
SELECT * FROM finish();
|
||||
ROLLBACK;
|
||||
75
supabase/tests/006_notes_trash_purge.sql
Normal file
75
supabase/tests/006_notes_trash_purge.sql
Normal file
@@ -0,0 +1,75 @@
|
||||
-- pgTAP: notes trash purge + pin/archive exclusivity — Fase 3
|
||||
-- Run with: psql -U postgres -d postgres -f supabase/tests/006_notes_trash_purge.sql
|
||||
--
|
||||
-- Validates:
|
||||
-- - The CHECK constraint forbidding (is_pinned AND is_archived)
|
||||
-- - The pg_cron purge job (executed inline) hard-deletes notes with
|
||||
-- deleted_at older than 7 days
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgtap;
|
||||
|
||||
BEGIN;
|
||||
SELECT plan(4);
|
||||
|
||||
-- Insert a note that is already 8 days in the trash
|
||||
INSERT INTO public.notes (id, collective_id, content, created_by, updated_by, deleted_at)
|
||||
VALUES (
|
||||
'99999999-1111-1111-1111-111111111111',
|
||||
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
'expired trash note',
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
now() - INTERVAL '8 days'
|
||||
);
|
||||
|
||||
-- Insert a note still inside the 7-day window
|
||||
INSERT INTO public.notes (id, collective_id, content, created_by, updated_by, deleted_at)
|
||||
VALUES (
|
||||
'99999999-2222-2222-2222-222222222222',
|
||||
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
'recent trash note',
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
now() - INTERVAL '2 days'
|
||||
);
|
||||
|
||||
-- N-PG-01: pre-purge sanity — both notes exist
|
||||
SELECT is(
|
||||
(SELECT count(*)::int FROM public.notes
|
||||
WHERE id IN ('99999999-1111-1111-1111-111111111111', '99999999-2222-2222-2222-222222222222')),
|
||||
2,
|
||||
'N-PG-01: both trash fixtures inserted'
|
||||
);
|
||||
|
||||
-- Run the purge job's SQL directly (matches what pg_cron schedules)
|
||||
DELETE FROM public.notes WHERE deleted_at < now() - INTERVAL '7 days';
|
||||
|
||||
-- N-PG-02: 8-day note is gone
|
||||
SELECT is(
|
||||
(SELECT count(*)::int FROM public.notes
|
||||
WHERE id = '99999999-1111-1111-1111-111111111111'),
|
||||
0,
|
||||
'N-PG-02: note older than 7 days is purged'
|
||||
);
|
||||
|
||||
-- N-PG-03: 2-day note still exists
|
||||
SELECT is(
|
||||
(SELECT count(*)::int FROM public.notes
|
||||
WHERE id = '99999999-2222-2222-2222-222222222222'),
|
||||
1,
|
||||
'N-PG-03: note inside the 7-day window survives'
|
||||
);
|
||||
|
||||
-- N-PG-04: pin + archive simultaneously violates CHECK
|
||||
SELECT throws_ok(
|
||||
$$ INSERT INTO public.notes (collective_id, content, created_by, updated_by, is_pinned, is_archived)
|
||||
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'pin+archive',
|
||||
'11111111-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111',
|
||||
true, true) $$,
|
||||
'23514',
|
||||
NULL,
|
||||
'N-PG-04: notes with is_pinned AND is_archived are rejected by CHECK'
|
||||
);
|
||||
|
||||
SELECT * FROM finish();
|
||||
ROLLBACK;
|
||||
Reference in New Issue
Block a user