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:
147
apps/web/src/lib/stores/notes.ts
Normal file
147
apps/web/src/lib/stores/notes.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import type { Note, NoteColor } from '@colectivo/types';
|
||||
|
||||
export const notes = writable<Note[]>([]);
|
||||
export const notesLoading = writable(false);
|
||||
|
||||
// ── Active board (not archived, not in trash) ────────────────────────────────
|
||||
|
||||
export async function loadNotes(collectiveId: string) {
|
||||
notesLoading.set(true);
|
||||
const { data, error } = await getSupabase()
|
||||
.from('notes')
|
||||
.select('*')
|
||||
.eq('collective_id', collectiveId)
|
||||
.eq('is_archived', false)
|
||||
.is('deleted_at', null)
|
||||
.order('is_pinned', { ascending: false })
|
||||
.order('updated_at', { ascending: false });
|
||||
|
||||
if (!error && data) {
|
||||
notes.set(data as Note[]);
|
||||
}
|
||||
notesLoading.set(false);
|
||||
}
|
||||
|
||||
export async function loadArchivedNotes(collectiveId: string): Promise<Note[]> {
|
||||
const { data } = await getSupabase()
|
||||
.from('notes')
|
||||
.select('*')
|
||||
.eq('collective_id', collectiveId)
|
||||
.eq('is_archived', true)
|
||||
.is('deleted_at', null)
|
||||
.order('updated_at', { ascending: false });
|
||||
return (data as Note[]) ?? [];
|
||||
}
|
||||
|
||||
export async function loadTrashedNotes(collectiveId: string): Promise<Note[]> {
|
||||
const { data } = await getSupabase()
|
||||
.from('notes')
|
||||
.select('*')
|
||||
.eq('collective_id', collectiveId)
|
||||
.not('deleted_at', 'is', null)
|
||||
.order('deleted_at', { ascending: false });
|
||||
return (data as Note[]) ?? [];
|
||||
}
|
||||
|
||||
export async function loadNote(id: string): Promise<Note | null> {
|
||||
const { data } = await getSupabase().from('notes').select('*').eq('id', id).single();
|
||||
return (data as Note) ?? null;
|
||||
}
|
||||
|
||||
// ── Mutations ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createNote(
|
||||
collectiveId: string,
|
||||
userId: string
|
||||
): Promise<Note | null> {
|
||||
const { data, error } = await getSupabase()
|
||||
.from('notes')
|
||||
.insert({
|
||||
collective_id: collectiveId,
|
||||
content: '',
|
||||
created_by: userId,
|
||||
updated_by: userId
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !data) return null;
|
||||
notes.update((ns) => [data as Note, ...ns]);
|
||||
return data as Note;
|
||||
}
|
||||
|
||||
export async function patchNote(
|
||||
id: string,
|
||||
userId: string,
|
||||
patch: Partial<Pick<Note, 'title' | 'content' | 'color' | 'is_pinned' | 'is_archived' | 'deleted_at'>>
|
||||
) {
|
||||
const fullPatch = { ...patch, updated_by: userId };
|
||||
await getSupabase().from('notes').update(fullPatch).eq('id', id);
|
||||
notes.update((ns) =>
|
||||
ns.map((n) =>
|
||||
n.id === id ? { ...n, ...patch, updated_by: userId, updated_at: new Date().toISOString() } : n
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function pinNote(id: string, userId: string) {
|
||||
// Pinning forces archive=false (CHECK constraint forbids both true)
|
||||
await patchNote(id, userId, { is_pinned: true, is_archived: false });
|
||||
}
|
||||
|
||||
export async function unpinNote(id: string, userId: string) {
|
||||
await patchNote(id, userId, { is_pinned: false });
|
||||
}
|
||||
|
||||
export async function archiveNote(id: string, userId: string) {
|
||||
await patchNote(id, userId, { is_archived: true, is_pinned: false });
|
||||
notes.update((ns) => ns.filter((n) => n.id !== id));
|
||||
}
|
||||
|
||||
export async function unarchiveNote(id: string, userId: string) {
|
||||
await patchNote(id, userId, { is_archived: false });
|
||||
}
|
||||
|
||||
export async function trashNote(id: string, userId: string) {
|
||||
await patchNote(id, userId, { deleted_at: new Date().toISOString() });
|
||||
notes.update((ns) => ns.filter((n) => n.id !== id));
|
||||
}
|
||||
|
||||
export async function restoreNote(id: string, userId: string) {
|
||||
await patchNote(id, userId, { deleted_at: null });
|
||||
}
|
||||
|
||||
export async function permanentDeleteNote(id: string) {
|
||||
await getSupabase().from('notes').delete().eq('id', id);
|
||||
}
|
||||
|
||||
export async function duplicateNote(note: Note, userId: string): Promise<Note | null> {
|
||||
const { data, error } = await getSupabase()
|
||||
.from('notes')
|
||||
.insert({
|
||||
collective_id: note.collective_id,
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
color: note.color,
|
||||
created_by: userId,
|
||||
updated_by: userId
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error || !data) return null;
|
||||
notes.update((ns) => [data as Note, ...ns]);
|
||||
return data as Note;
|
||||
}
|
||||
|
||||
export const NOTE_COLORS: NoteColor[] = [
|
||||
'yellow',
|
||||
'green',
|
||||
'blue',
|
||||
'pink',
|
||||
'purple',
|
||||
'orange',
|
||||
'gray',
|
||||
'red'
|
||||
];
|
||||
113
apps/web/src/lib/stores/tasks.ts
Normal file
113
apps/web/src/lib/stores/tasks.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import type { Task, TaskList } from '@colectivo/types';
|
||||
|
||||
// ── Task lists store (overview page) ──────────────────────────────────────────
|
||||
|
||||
export const taskLists = writable<TaskList[]>([]);
|
||||
export const taskListsLoading = writable(false);
|
||||
|
||||
export async function loadTaskLists(collectiveId: string) {
|
||||
taskListsLoading.set(true);
|
||||
const { data, error } = await getSupabase()
|
||||
.from('task_lists')
|
||||
.select('*')
|
||||
.eq('collective_id', collectiveId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (!error && data) {
|
||||
taskLists.set(data as TaskList[]);
|
||||
}
|
||||
taskListsLoading.set(false);
|
||||
}
|
||||
|
||||
export async function createTaskList(
|
||||
collectiveId: string,
|
||||
name: string,
|
||||
userId: string
|
||||
): Promise<TaskList | null> {
|
||||
const tempId = crypto.randomUUID();
|
||||
const optimistic: TaskList = {
|
||||
id: tempId,
|
||||
collective_id: collectiveId,
|
||||
name,
|
||||
created_by: userId,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
taskLists.update((ls) => [optimistic, ...ls]);
|
||||
|
||||
const { data, error } = await getSupabase()
|
||||
.from('task_lists')
|
||||
.insert({ collective_id: collectiveId, name, created_by: userId })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
taskLists.update((ls) => ls.filter((l) => l.id !== tempId));
|
||||
return null;
|
||||
}
|
||||
|
||||
const real = data as TaskList;
|
||||
taskLists.update((ls) => ls.map((l) => (l.id === tempId ? real : l)));
|
||||
return real;
|
||||
}
|
||||
|
||||
export async function renameTaskList(id: string, name: string) {
|
||||
taskLists.update((ls) => ls.map((l) => (l.id === id ? { ...l, name } : l)));
|
||||
await getSupabase().from('task_lists').update({ name }).eq('id', id);
|
||||
}
|
||||
|
||||
export async function deleteTaskList(id: string) {
|
||||
taskLists.update((ls) => ls.filter((l) => l.id !== id));
|
||||
await getSupabase().from('task_lists').delete().eq('id', id);
|
||||
}
|
||||
|
||||
// ── Task operations (used by [id] page) ───────────────────────────────────────
|
||||
|
||||
export async function loadTasks(listId: string): Promise<Task[]> {
|
||||
const { data, error } = await getSupabase()
|
||||
.from('tasks')
|
||||
.select('*')
|
||||
.eq('list_id', listId)
|
||||
.order('sort_order', { ascending: true });
|
||||
|
||||
if (error || !data) return [];
|
||||
return data as Task[];
|
||||
}
|
||||
|
||||
export async function addTask(
|
||||
listId: string,
|
||||
title: string,
|
||||
userId: string,
|
||||
sortOrder: number
|
||||
): Promise<Task | null> {
|
||||
const { data, error } = await getSupabase()
|
||||
.from('tasks')
|
||||
.insert({ list_id: listId, title, sort_order: sortOrder, created_by: userId })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !data) return null;
|
||||
return data as Task;
|
||||
}
|
||||
|
||||
export async function renameTask(id: string, title: string) {
|
||||
await getSupabase().from('tasks').update({ title }).eq('id', id);
|
||||
}
|
||||
|
||||
export async function completeTask(id: string, userId: string, completed: boolean) {
|
||||
const patch = completed
|
||||
? { is_completed: true, completed_by: userId, completed_at: new Date().toISOString() }
|
||||
: { is_completed: false, completed_by: null, completed_at: null };
|
||||
await getSupabase().from('tasks').update(patch).eq('id', id);
|
||||
}
|
||||
|
||||
export async function deleteTask(id: string) {
|
||||
await getSupabase().from('tasks').delete().eq('id', id);
|
||||
}
|
||||
|
||||
export async function reorderTasks(tasks: Pick<Task, 'id'>[]) {
|
||||
await Promise.all(
|
||||
tasks.map((t, idx) => getSupabase().from('tasks').update({ sort_order: idx }).eq('id', t.id))
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user