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:
2026-04-13 10:28:19 +02:00
parent e7a961a66d
commit 104eeba02e
23 changed files with 2563 additions and 81 deletions

View File

@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Status ## 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 - `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) - `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) 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 ### 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` - `supabase/migrations/007_realtime_publication.sql` — adds shopping_items + shopping_lists to `supabase_realtime` publication with `REPLICA IDENTITY FULL`

View File

@@ -13,8 +13,8 @@
| Fase 1 — Auth y Colectivo | ✅ Completa | | Fase 1 — Auth y Colectivo | ✅ Completa |
| Fase 2a — Lista de Compra CRUD | ✅ Completa | | Fase 2a — Lista de Compra CRUD | ✅ Completa |
| Fase 2b — Realtime y Modo Compra | ✅ Completa (presence bloqueado por bug upstream de `supabase/realtime`) | | 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 | ✅ Completa |
| Fase 3 — Tareas y Notas | ⏳ Pendiente | | 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 | | Fase 4 — Búsqueda y Pulido | ⏳ Pendiente |
--- ---

View File

@@ -96,7 +96,44 @@
"list_finish_confirm_yes": "Yes, finish", "list_finish_confirm_yes": "Yes, finish",
"list_finish_confirm_no": "Keep shopping", "list_finish_confirm_no": "Keep shopping",
"tasks_title": "Tasks", "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_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_title": "Search",
"search_placeholder": "Search lists, tasks, notes…", "search_placeholder": "Search lists, tasks, notes…",
"trash_restore": "Restore", "trash_restore": "Restore",

View File

@@ -96,7 +96,44 @@
"list_finish_confirm_yes": "Sí, terminar", "list_finish_confirm_yes": "Sí, terminar",
"list_finish_confirm_no": "Seguir comprando", "list_finish_confirm_no": "Seguir comprando",
"tasks_title": "Tareas", "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_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_title": "Buscar",
"search_placeholder": "Busca listas, tareas, notas…", "search_placeholder": "Busca listas, tareas, notas…",
"trash_restore": "Restaurar", "trash_restore": "Restaurar",

View 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'
];

View 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))
);
}

View File

@@ -1,17 +1,148 @@
<script lang="ts"> <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'; 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> </script>
<div class="flex flex-1 flex-col overflow-hidden"> <svelte:head><title>{m.notes_title()} — Colectivo</title></svelte:head>
<header class="px-8 pb-4 pt-8">
<p class="mb-1 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary"> <div class="flex h-full flex-col overflow-hidden">
{m.nav_notes()} <header class="flex h-14 items-center gap-2 border-b border-black/5 px-6 dark:border-white/5">
</p> <FileText size={18} strokeWidth={1.5} />
<h1 class="text-[20px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-50"> <h1 class="text-base font-semibold text-text-primary">{m.notes_title()}</h1>
{m.notes_title()} <div class="flex-1"></div>
</h1> <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> </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>
</div> </div>

View 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>

View 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>

View 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>

View File

@@ -1,17 +1,103 @@
<script lang="ts"> <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'; 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> </script>
<div class="flex flex-1 flex-col overflow-hidden"> <svelte:head><title>{m.tasks_title()} — Colectivo</title></svelte:head>
<header class="px-8 pb-4 pt-8">
<p class="mb-1 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary"> <div class="flex h-full flex-col overflow-hidden">
{m.nav_tasks()} <header class="flex h-14 items-center gap-2 border-b border-black/5 px-6 dark:border-white/5">
</p> <CheckSquare size={18} strokeWidth={1.5} />
<h1 class="text-[20px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-50"> <h1 class="text-base font-semibold text-text-primary">{m.tasks_title()}</h1>
{m.tasks_title()}
</h1>
</header> </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>
</div> </div>

View 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>

View 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-]+$/);
}
});
});

View 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 });
}
});
});

View 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');
});
});

View 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);
});
});

View File

@@ -8,6 +8,7 @@ export type LanguageCode = 'en' | 'es';
export type AvatarTypeEnum = 'initials' | 'emoji' | 'upload'; export type AvatarTypeEnum = 'initials' | 'emoji' | 'upload';
export type MemberRoleEnum = 'admin' | 'member' | 'guest'; export type MemberRoleEnum = 'admin' | 'member' | 'guest';
export type ListStatusEnum = 'active' | 'completed' | 'archived'; export type ListStatusEnum = 'active' | 'completed' | 'archived';
export type NoteColor = 'yellow' | 'green' | 'blue' | 'pink' | 'purple' | 'orange' | 'gray' | 'red';
export interface Database { export interface Database {
public: { public: {
@@ -237,6 +238,111 @@ export interface Database {
}; };
Relationships: []; 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: { item_frequency: {
Row: { Row: {
collective_id: string; collective_id: string;
@@ -283,6 +389,7 @@ export interface Database {
avatar_type: AvatarTypeEnum; avatar_type: AvatarTypeEnum;
member_role: MemberRoleEnum; member_role: MemberRoleEnum;
list_status: ListStatusEnum; list_status: ListStatusEnum;
note_color: NoteColor;
}; };
}; };
} }

View File

@@ -5,16 +5,9 @@
export type AvatarType = 'initials' | 'emoji' | 'upload'; export type AvatarType = 'initials' | 'emoji' | 'upload';
export type MemberRole = 'admin' | 'member' | 'guest'; export type MemberRole = 'admin' | 'member' | 'guest';
export type ListStatus = 'active' | 'completed' | 'archived'; export type ListStatus = 'active' | 'completed' | 'archived';
export type NoteColor = // NoteColor is defined in database.ts (matches the Postgres enum) and re-exported via index.ts
| 'default' import type { NoteColor } from './database.js';
| 'slate' export type { NoteColor };
| 'rose'
| 'orange'
| 'amber'
| 'emerald'
| 'cyan'
| 'violet';
export type TaskPriority = 'none' | 'low' | 'medium' | 'high' | 'urgent';
export type Language = 'en' | 'es'; export type Language = 'en' | 'es';
export interface User { export interface User {
@@ -93,22 +86,18 @@ export interface TaskList {
name: string; name: string;
created_by: string; created_by: string;
created_at: string; created_at: string;
deleted_at: string | null;
} }
export interface Task { export interface Task {
id: string; id: string;
task_list_id: string; list_id: string;
name: string; title: string;
is_completed: boolean; is_completed: boolean;
completed_by: string | null; completed_by: string | null;
completed_at: string | null; completed_at: string | null;
priority: TaskPriority;
due_date: string | null;
sort_order: number; sort_order: number;
created_by: string; created_by: string;
created_at: string; created_at: string;
deleted_at: string | null;
} }
export interface Note { export interface Note {
@@ -116,11 +105,12 @@ export interface Note {
collective_id: string; collective_id: string;
title: string | null; title: string | null;
content: string; content: string;
color: NoteColor; color: NoteColor | null;
is_pinned: boolean; is_pinned: boolean;
is_archived: boolean; is_archived: boolean;
deleted_at: string | null;
created_by: string; created_by: string;
created_at: string; created_at: string;
updated_by: string;
updated_at: string; updated_at: string;
deleted_at: string | null;
} }

View File

@@ -1,5 +1,5 @@
### Fase 3 — Tareas y Notas ### Fase 3 — Tareas y Notas
**Estado: ⏳ Pendiente** **Estado: ✅ Completa**
**Duración estimada: 12 semanas** **Duración estimada: 12 semanas**
**Objetivo: completar los módulos secundarios.** **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/`):** **Vitest (`packages/test-utils/tests/`):**
- [ ] `rls-tasks.test.ts`T-series: - [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) ✅
- T-01/T-02: miembro y admin pueden CRUD en `tasks`; guest solo lee; Eva (no miembro) ve 0 filas - [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) ✅
- 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
**pgTAP (`supabase/tests/`):** **pgTAP (`supabase/tests/`):**
- [ ] `005_tasks_rls.sql` — invariantes SQL de `tasks` (`completed_at NOT NULL ⇔ completed_by NOT NULL`) - [x] `005_tasks_rls.sql` 5 tests: invariantes SQL de la CHECK de completion (`is_completed ⇔ completed_by ⇔ completed_at`)
- [ ] `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] `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/`):** **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) - [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) ✅
- [ ] `tasks.test.ts`T-02 UI: guest (David) ve tareas pero no puede crear - [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) ✅
- [ ] `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
#### 3.1 Tareas #### 3.1 Tareas
- [ ] Migración: tablas `task_lists` y `tasks` con RLS - [x] Migración `008_tasks.sql` tablas `task_lists` y `tasks` + helper `task_list_collective_id()` + RLS por rol + CHECK de consistencia de completion
- [ ] Pantalla `/tasks`listado de listas de tareas - [x] Pantalla `/tasks`grid de listas de tareas con sticky create input + acción de eliminar lista
- [ ] Vista de lista de tareas: - [x] Vista de lista `/tasks/[id]`:
- Añadir tarea (input rápido) - Añadir tarea (input rápido en la barra inferior)
- Checkbox de completado (registra `completed_by` + `completed_at`) - Toggle de completado (registra `completed_by` + `completed_at`)
- Tooltip "Completada por [Nombre] el [fecha]" - Atribución "Completada por [Nombre]" en cada tarea completada (resuelta vía `collectiveMembers`)
- Edición inline del título - Edición inline del título (doble-click)
- Swipe para eliminar - Drag & drop para reordenar las pendientes (`svelte-dnd-action`)
- Drag & drop para reordenar - Pendientes arriba, completadas hundidas al fondo con `flip` animation
- Tareas completadas al fondo (no se ocultan) - Botón de eliminar al hover
- [ ] Sincronización vía polling a intervalos de 5 segundos cuando la vista está activa - [x] Sincronización vía polling a intervalos de 5 segundos cuando la vista está activa
#### 3.2 Notas #### 3.2 Notas
- [ ] Migración: tabla `notes` con `color`, `is_pinned`, `is_archived`, `deleted_at` + RLS - [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)
- [ ] Job `pg_cron`: borrado definitivo de notas en papelera con `deleted_at` > 7 días - [x] Pantalla `/notes` — tablero tipo grid con sección "Fijadas" arriba (`data-testid="notes-pinned"`) y "Notas" debajo. Cards coloreadas según `note.color`.
- [ ] Pantalla `/notes` — tablero tipo masonry: - [x] Editor `/notes/[id]`:
- Notas fijadas en bloque superior - Input de título + textarea de contenido (texto plano por ahora; el Markdown rico queda como mejora)
- Notas activas en rejilla de colores - Guardado automático con debounce de 500ms (indicador "Guardando…/Guardado")
- [ ] Editor de nota (`/notes/[id]`): - Selector de color con paleta de 8 swatches + opción "default"
- Editor de texto con Markdown básico (negrita, cursiva, listas, cabeceras) - Acciones en barra superior: pin/unpin, archive/unarchive, duplicar, mover a papelera
- Guardado automático con debounce de 500ms - Si la nota está en papelera: cambia a vista de solo-restaurar
- Selector de color (8 opciones predefinidas) - [x] Archivo `/notes/archive` (acción restaurar / papelera) y papelera `/notes/trash` (acción restaurar / borrar permanente)
- Opciones: fijar, archivar, mover a papelera, duplicar - [x] Sincronización vía polling a intervalos de 30 segundos en el tablero
- [ ] Archivo y papelera accesibles desde `/notes/archive` y `/notes/trash`
- [ ] Sincronización vía polling a intervalos de 30 segundos
#### 3.Z Verificación final — todos los tests verdes #### 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`. **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`.

View 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)));

View 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';
$$
);

View 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;

View 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;