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

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