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