nextjs-hackathon-stack 0.1.30 → 0.1.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/index.js +6 -3
  2. package/package.json +1 -1
  3. package/template/.cursor/agents/backend.md +10 -59
  4. package/template/.cursor/agents/business-intelligence.md +37 -15
  5. package/template/.cursor/agents/code-reviewer.md +2 -2
  6. package/template/.cursor/agents/frontend.md +6 -10
  7. package/template/.cursor/agents/security-researcher.md +8 -1
  8. package/template/.cursor/agents/technical-lead.md +47 -18
  9. package/template/.cursor/agents/test-qa.md +9 -49
  10. package/template/.cursor/rules/architecture.mdc +2 -0
  11. package/template/.cursor/rules/coding-standards.mdc +12 -1
  12. package/template/.cursor/rules/components.mdc +7 -0
  13. package/template/.cursor/rules/data-fetching.mdc +56 -5
  14. package/template/.cursor/rules/forms.mdc +4 -0
  15. package/template/.cursor/rules/general.mdc +5 -3
  16. package/template/.cursor/rules/nextjs.mdc +27 -3
  17. package/template/.cursor/rules/security.mdc +53 -3
  18. package/template/.cursor/rules/supabase.mdc +19 -3
  19. package/template/.cursor/rules/testing.mdc +13 -4
  20. package/template/.cursor/skills/create-feature/SKILL.md +8 -4
  21. package/template/.cursor/skills/create-feature/references/server-action-test-template.md +51 -0
  22. package/template/.cursor/skills/review-branch/SKILL.md +2 -22
  23. package/template/.cursor/skills/review-branch/references/review-checklist.md +36 -0
  24. package/template/.cursor/skills/security-audit/SKILL.md +8 -39
  25. package/template/.cursor/skills/security-audit/references/audit-steps.md +41 -0
  26. package/template/CLAUDE.md +27 -10
  27. package/template/eslint.config.ts +7 -1
  28. package/template/next.config.ts +2 -1
  29. package/template/src/app/(protected)/page.tsx +2 -4
  30. package/template/src/app/__tests__/auth-callback.test.ts +14 -0
  31. package/template/src/app/__tests__/protected-page.test.tsx +11 -13
  32. package/template/src/app/api/auth/callback/route.ts +2 -1
  33. package/template/src/e2e/login.spec.ts +4 -4
  34. package/template/src/features/todos/__tests__/todos.action.test.ts +78 -44
  35. package/template/src/features/todos/__tests__/todos.queries.test.ts +101 -0
  36. package/template/src/features/todos/actions/todos.action.ts +41 -27
  37. package/template/src/features/todos/queries/todos.queries.ts +16 -0
  38. package/template/src/shared/lib/supabase/middleware.ts +0 -1
  39. package/template/src/shared/lib/supabase/server.ts +0 -1
@@ -1,11 +1,8 @@
1
1
  import { describe, it, expect, vi, beforeEach } from "vitest";
2
2
 
3
- const mockInsert = vi.fn();
4
- const mockUpdate = vi.fn();
5
- const mockDelete = vi.fn();
6
- const mockSelectWhere = vi.fn();
7
3
  const mockRevalidatePath = vi.fn();
8
4
  const mockGetUser = vi.fn();
5
+ const mockFrom = vi.fn();
9
6
 
10
7
  vi.mock("next/cache", () => ({
11
8
  revalidatePath: (path: string): unknown => mockRevalidatePath(path),
@@ -15,22 +12,27 @@ vi.mock("@/shared/lib/supabase/server", () => ({
15
12
  createClient: vi.fn(() =>
16
13
  Promise.resolve({
17
14
  auth: { getUser: mockGetUser },
15
+ from: mockFrom,
18
16
  })
19
17
  ),
20
18
  }));
21
19
 
22
- vi.mock("@/shared/db", () => ({
23
- db: {
24
- insert: vi.fn(() => ({ values: mockInsert })),
25
- update: vi.fn(() => ({ set: vi.fn(() => ({ where: mockUpdate })) })),
26
- delete: vi.fn(() => ({ where: mockDelete })),
27
- select: vi.fn(() => ({ from: vi.fn(() => ({ where: mockSelectWhere })) })),
28
- },
29
- }));
30
-
31
- vi.mock("@/shared/db/schema", () => ({
32
- todos: {},
33
- }));
20
+ interface QueryResult { data: unknown; error: unknown }
21
+
22
+ function makeChain(result: QueryResult) {
23
+ const chain: Record<string, unknown> = {};
24
+ chain.insert = vi.fn(() => chain);
25
+ chain.select = vi.fn(() => chain);
26
+ chain.update = vi.fn(() => chain);
27
+ chain.delete = vi.fn(() => chain);
28
+ chain.eq = vi.fn(() => chain);
29
+ chain.single = vi.fn(() => Promise.resolve(result));
30
+ chain.then = (
31
+ onFulfilled: (v: QueryResult) => unknown,
32
+ onRejected?: (e: unknown) => unknown
33
+ ) => Promise.resolve(result).then(onFulfilled, onRejected);
34
+ return chain;
35
+ }
34
36
 
35
37
  describe("addTodoAction", () => {
36
38
  beforeEach(() => {
@@ -48,58 +50,57 @@ describe("addTodoAction", () => {
48
50
 
49
51
  // Assert
50
52
  expect(result.status).toBe("error");
51
- expect(mockInsert).not.toHaveBeenCalled();
53
+ expect(mockFrom).not.toHaveBeenCalled();
52
54
  });
53
55
 
54
- it("retorna error si no hay usuario autenticado", async () => {
56
+ it("retorna error si el campo title no está presente", async () => {
55
57
  // Arrange
56
- mockGetUser.mockResolvedValue({ data: { user: null } });
57
58
  const { addTodoAction } = await import("../actions/todos.action");
58
59
  const fd = new FormData();
59
- fd.set("title", "Mi tarea");
60
60
 
61
61
  // Act
62
62
  const result = await addTodoAction(fd);
63
63
 
64
64
  // Assert
65
65
  expect(result.status).toBe("error");
66
- expect(mockInsert).not.toHaveBeenCalled();
66
+ expect(mockFrom).not.toHaveBeenCalled();
67
67
  });
68
68
 
69
- it("inserta la tarea, llama a revalidatePath y retorna success cuando el usuario existe", async () => {
69
+ it("retorna error si no hay usuario autenticado", async () => {
70
70
  // Arrange
71
- mockGetUser.mockResolvedValue({ data: { user: { id: "u1" } } });
72
- mockInsert.mockResolvedValue(undefined);
71
+ mockGetUser.mockResolvedValue({ data: { user: null } });
73
72
  const { addTodoAction } = await import("../actions/todos.action");
74
73
  const fd = new FormData();
75
- fd.set("title", "Nueva tarea");
74
+ fd.set("title", "Mi tarea");
76
75
 
77
76
  // Act
78
77
  const result = await addTodoAction(fd);
79
78
 
80
79
  // Assert
81
- expect(result.status).toBe("success");
82
- expect(mockInsert).toHaveBeenCalledWith({ userId: "u1", title: "Nueva tarea" });
83
- expect(mockRevalidatePath).toHaveBeenCalledWith("/");
80
+ expect(result.status).toBe("error");
81
+ expect(mockFrom).not.toHaveBeenCalled();
84
82
  });
85
83
 
86
- it("retorna error si el campo title no está presente", async () => {
84
+ it("inserta la tarea, llama a revalidatePath y retorna success cuando el usuario existe", async () => {
87
85
  // Arrange
86
+ mockGetUser.mockResolvedValue({ data: { user: { id: "u1" } } });
87
+ mockFrom.mockReturnValue(makeChain({ data: null, error: null }));
88
88
  const { addTodoAction } = await import("../actions/todos.action");
89
89
  const fd = new FormData();
90
+ fd.set("title", "Nueva tarea");
90
91
 
91
92
  // Act
92
93
  const result = await addTodoAction(fd);
93
94
 
94
95
  // Assert
95
- expect(result.status).toBe("error");
96
- expect(mockInsert).not.toHaveBeenCalled();
96
+ expect(result.status).toBe("success");
97
+ expect(mockRevalidatePath).toHaveBeenCalledWith("/");
97
98
  });
98
99
 
99
- it("retorna error si la inserción lanza una excepción", async () => {
100
+ it("retorna error si la inserción falla", async () => {
100
101
  // Arrange
101
102
  mockGetUser.mockResolvedValue({ data: { user: { id: "u1" } } });
102
- mockInsert.mockRejectedValue(new Error("DB error"));
103
+ mockFrom.mockReturnValue(makeChain({ data: null, error: new Error("DB error") }));
103
104
  const { addTodoAction } = await import("../actions/todos.action");
104
105
  const fd = new FormData();
105
106
  fd.set("title", "Tarea fallida");
@@ -117,9 +118,25 @@ describe("toggleTodoAction", () => {
117
118
  vi.clearAllMocks();
118
119
  });
119
120
 
121
+ it("retorna error si no hay usuario autenticado", async () => {
122
+ // Arrange
123
+ mockGetUser.mockResolvedValue({ data: { user: null } });
124
+ const { toggleTodoAction } = await import("../actions/todos.action");
125
+
126
+ // Act
127
+ const result = await toggleTodoAction("1");
128
+
129
+ // Assert
130
+ expect(result.status).toBe("error");
131
+ expect(mockFrom).not.toHaveBeenCalled();
132
+ });
133
+
120
134
  it("retorna error si la tarea no existe", async () => {
121
135
  // Arrange
122
- mockSelectWhere.mockResolvedValue([]);
136
+ mockGetUser.mockResolvedValue({ data: { user: { id: "u1" } } });
137
+ mockFrom.mockReturnValue(
138
+ makeChain({ data: null, error: { code: "PGRST116" } })
139
+ );
123
140
  const { toggleTodoAction } = await import("../actions/todos.action");
124
141
 
125
142
  // Act
@@ -127,13 +144,14 @@ describe("toggleTodoAction", () => {
127
144
 
128
145
  // Assert
129
146
  expect(result.status).toBe("error");
130
- expect(mockUpdate).not.toHaveBeenCalled();
131
147
  });
132
148
 
133
149
  it("invierte completed, llama a revalidatePath y retorna success", async () => {
134
150
  // Arrange
135
- mockSelectWhere.mockResolvedValue([{ id: "1", completed: false }]);
136
- mockUpdate.mockResolvedValue(undefined);
151
+ mockGetUser.mockResolvedValue({ data: { user: { id: "u1" } } });
152
+ mockFrom
153
+ .mockReturnValueOnce(makeChain({ data: { completed: false }, error: null }))
154
+ .mockReturnValueOnce(makeChain({ data: null, error: null }));
137
155
  const { toggleTodoAction } = await import("../actions/todos.action");
138
156
 
139
157
  // Act
@@ -141,13 +159,15 @@ describe("toggleTodoAction", () => {
141
159
 
142
160
  // Assert
143
161
  expect(result.status).toBe("success");
144
- expect(mockUpdate).toHaveBeenCalled();
145
162
  expect(mockRevalidatePath).toHaveBeenCalledWith("/");
146
163
  });
147
164
 
148
- it("retorna error si la actualización lanza una excepción", async () => {
165
+ it("retorna error si la actualización falla", async () => {
149
166
  // Arrange
150
- mockSelectWhere.mockRejectedValue(new Error("DB error"));
167
+ mockGetUser.mockResolvedValue({ data: { user: { id: "u1" } } });
168
+ mockFrom
169
+ .mockReturnValueOnce(makeChain({ data: { completed: true }, error: null }))
170
+ .mockReturnValueOnce(makeChain({ data: null, error: new Error("DB error") }));
151
171
  const { toggleTodoAction } = await import("../actions/todos.action");
152
172
 
153
173
  // Act
@@ -163,9 +183,23 @@ describe("deleteTodoAction", () => {
163
183
  vi.clearAllMocks();
164
184
  });
165
185
 
186
+ it("retorna error si no hay usuario autenticado", async () => {
187
+ // Arrange
188
+ mockGetUser.mockResolvedValue({ data: { user: null } });
189
+ const { deleteTodoAction } = await import("../actions/todos.action");
190
+
191
+ // Act
192
+ const result = await deleteTodoAction("1");
193
+
194
+ // Assert
195
+ expect(result.status).toBe("error");
196
+ expect(mockFrom).not.toHaveBeenCalled();
197
+ });
198
+
166
199
  it("elimina la tarea, llama a revalidatePath y retorna success", async () => {
167
200
  // Arrange
168
- mockDelete.mockResolvedValue(undefined);
201
+ mockGetUser.mockResolvedValue({ data: { user: { id: "u1" } } });
202
+ mockFrom.mockReturnValue(makeChain({ data: null, error: null }));
169
203
  const { deleteTodoAction } = await import("../actions/todos.action");
170
204
 
171
205
  // Act
@@ -173,13 +207,13 @@ describe("deleteTodoAction", () => {
173
207
 
174
208
  // Assert
175
209
  expect(result.status).toBe("success");
176
- expect(mockDelete).toHaveBeenCalled();
177
210
  expect(mockRevalidatePath).toHaveBeenCalledWith("/");
178
211
  });
179
212
 
180
- it("retorna error si la eliminación lanza una excepción", async () => {
213
+ it("retorna error si la eliminación falla", async () => {
181
214
  // Arrange
182
- mockDelete.mockRejectedValue(new Error("DB error"));
215
+ mockGetUser.mockResolvedValue({ data: { user: { id: "u1" } } });
216
+ mockFrom.mockReturnValue(makeChain({ data: null, error: new Error("DB error") }));
183
217
  const { deleteTodoAction } = await import("../actions/todos.action");
184
218
 
185
219
  // Act
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+
3
+ import { getTodoById, getTodosByUserId } from "../queries/todos.queries";
4
+
5
+ import type { createClient } from "@/shared/lib/supabase/server";
6
+
7
+ type Client = Awaited<ReturnType<typeof createClient>>;
8
+ interface QueryResult { data: unknown; error: unknown }
9
+
10
+ function makeSupabase(result: QueryResult) {
11
+ const chain: Record<string, unknown> = {};
12
+ chain.select = vi.fn(() => chain);
13
+ chain.eq = vi.fn(() => chain);
14
+ chain.single = vi.fn(() => Promise.resolve(result));
15
+ chain.then = (
16
+ onFulfilled: (v: QueryResult) => unknown,
17
+ onRejected?: (e: unknown) => unknown
18
+ ) => Promise.resolve(result).then(onFulfilled, onRejected);
19
+ return { from: vi.fn(() => chain) } as unknown as Client;
20
+ }
21
+
22
+ describe("getTodosByUserId", () => {
23
+ it("retorna los todos del usuario cuando la consulta tiene éxito", async () => {
24
+ // Arrange
25
+ const todos = [{ id: "1", title: "Tarea", completed: false, user_id: "u1" }];
26
+ const supabase = makeSupabase({ data: todos, error: null });
27
+
28
+ // Act
29
+ const result = await getTodosByUserId(supabase, "u1");
30
+
31
+ // Assert
32
+ expect(result.data).toEqual(todos);
33
+ expect(result.error).toBeNull();
34
+ });
35
+
36
+ it("retorna lista vacía cuando el usuario no tiene todos", async () => {
37
+ // Arrange
38
+ const supabase = makeSupabase({ data: [], error: null });
39
+
40
+ // Act
41
+ const result = await getTodosByUserId(supabase, "u1");
42
+
43
+ // Assert
44
+ expect(result.data).toEqual([]);
45
+ expect(result.error).toBeNull();
46
+ });
47
+
48
+ it("retorna error cuando falla la consulta", async () => {
49
+ // Arrange
50
+ const dbError = new Error("DB error");
51
+ const supabase = makeSupabase({ data: null, error: dbError });
52
+
53
+ // Act
54
+ const result = await getTodosByUserId(supabase, "u1");
55
+
56
+ // Assert
57
+ expect(result.data).toBeNull();
58
+ expect(result.error).toBe(dbError);
59
+ });
60
+ });
61
+
62
+ describe("getTodoById", () => {
63
+ it("retorna el todo cuando existe y pertenece al usuario", async () => {
64
+ // Arrange
65
+ const todo = { id: "1", completed: false };
66
+ const supabase = makeSupabase({ data: todo, error: null });
67
+
68
+ // Act
69
+ const result = await getTodoById(supabase, "1", "u1");
70
+
71
+ // Assert
72
+ expect(result.data).toEqual(todo);
73
+ expect(result.error).toBeNull();
74
+ });
75
+
76
+ it("retorna error cuando el todo no existe", async () => {
77
+ // Arrange
78
+ const dbError = { code: "PGRST116" };
79
+ const supabase = makeSupabase({ data: null, error: dbError });
80
+
81
+ // Act
82
+ const result = await getTodoById(supabase, "non-existent", "u1");
83
+
84
+ // Assert
85
+ expect(result.data).toBeNull();
86
+ expect(result.error).toEqual(dbError);
87
+ });
88
+
89
+ it("retorna error cuando falla la consulta", async () => {
90
+ // Arrange
91
+ const dbError = new Error("DB error");
92
+ const supabase = makeSupabase({ data: null, error: dbError });
93
+
94
+ // Act
95
+ const result = await getTodoById(supabase, "1", "u1");
96
+
97
+ // Assert
98
+ expect(result.data).toBeNull();
99
+ expect(result.error).toBe(dbError);
100
+ });
101
+ });
@@ -1,10 +1,8 @@
1
1
  "use server";
2
2
 
3
- import { eq } from "drizzle-orm";
4
3
  import { revalidatePath } from "next/cache";
5
4
 
6
- import { db } from "@/shared/db";
7
- import { todos } from "@/shared/db/schema";
5
+ import { getTodoById } from "@/features/todos/queries/todos.queries";
8
6
  import type { ActionResult } from "@/shared/lib/action-result";
9
7
  import { createClient } from "@/shared/lib/supabase/server";
10
8
 
@@ -20,34 +18,50 @@ export async function addTodoAction(formData: FormData): Promise<ActionResult> {
20
18
  } = await supabase.auth.getUser();
21
19
  if (!user) return { status: "error", message: "No autenticado" };
22
20
 
23
- try {
24
- await db.insert(todos).values({ userId: user.id, title: title.trim() });
25
- revalidatePath("/");
26
- return { status: "success", message: "Tarea agregada" };
27
- } catch {
28
- return { status: "error", message: "Error al agregar la tarea" };
29
- }
21
+ const { error } = await supabase
22
+ .from("todos")
23
+ .insert({ user_id: user.id, title: title.trim() });
24
+ if (error) return { status: "error", message: "Error al agregar la tarea" };
25
+
26
+ revalidatePath("/");
27
+ return { status: "success", message: "Tarea agregada" };
30
28
  }
31
29
 
32
30
  export async function toggleTodoAction(id: string): Promise<ActionResult> {
33
- try {
34
- const [todo] = await db.select().from(todos).where(eq(todos.id, id));
35
- if (!todo) return { status: "error", message: "Tarea no encontrada" };
36
-
37
- await db.update(todos).set({ completed: !todo.completed }).where(eq(todos.id, id));
38
- revalidatePath("/");
39
- return { status: "success", message: "Tarea actualizada" };
40
- } catch {
41
- return { status: "error", message: "Error al actualizar la tarea" };
42
- }
31
+ const supabase = await createClient();
32
+ const {
33
+ data: { user },
34
+ } = await supabase.auth.getUser();
35
+ if (!user) return { status: "error", message: "No autenticado" };
36
+
37
+ const { data: todo, error: fetchError } = await getTodoById(supabase, id, user.id);
38
+ if (fetchError ?? !todo) return { status: "error", message: "Tarea no encontrada" };
39
+
40
+ const { error } = await supabase
41
+ .from("todos")
42
+ .update({ completed: !todo.completed })
43
+ .eq("id", id)
44
+ .eq("user_id", user.id);
45
+ if (error) return { status: "error", message: "Error al actualizar la tarea" };
46
+
47
+ revalidatePath("/");
48
+ return { status: "success", message: "Tarea actualizada" };
43
49
  }
44
50
 
45
51
  export async function deleteTodoAction(id: string): Promise<ActionResult> {
46
- try {
47
- await db.delete(todos).where(eq(todos.id, id));
48
- revalidatePath("/");
49
- return { status: "success", message: "Tarea eliminada" };
50
- } catch {
51
- return { status: "error", message: "Error al eliminar la tarea" };
52
- }
52
+ const supabase = await createClient();
53
+ const {
54
+ data: { user },
55
+ } = await supabase.auth.getUser();
56
+ if (!user) return { status: "error", message: "No autenticado" };
57
+
58
+ const { error } = await supabase
59
+ .from("todos")
60
+ .delete()
61
+ .eq("id", id)
62
+ .eq("user_id", user.id);
63
+ if (error) return { status: "error", message: "Error al eliminar la tarea" };
64
+
65
+ revalidatePath("/");
66
+ return { status: "success", message: "Tarea eliminada" };
53
67
  }
@@ -0,0 +1,16 @@
1
+ import type { createClient } from "@/shared/lib/supabase/server";
2
+
3
+ type Client = Awaited<ReturnType<typeof createClient>>;
4
+
5
+ export async function getTodosByUserId(supabase: Client, userId: string) {
6
+ return supabase.from("todos").select("*").eq("user_id", userId);
7
+ }
8
+
9
+ export async function getTodoById(supabase: Client, id: string, userId: string) {
10
+ return supabase
11
+ .from("todos")
12
+ .select("completed")
13
+ .eq("id", id)
14
+ .eq("user_id", userId)
15
+ .single();
16
+ }
@@ -4,7 +4,6 @@ import { NextResponse, type NextRequest } from "next/server";
4
4
  export async function updateSession(request: NextRequest) {
5
5
  let supabaseResponse = NextResponse.next({ request });
6
6
 
7
- // eslint-disable-next-line @typescript-eslint/no-deprecated
8
7
  const supabase = createServerClient(
9
8
  process.env.NEXT_PUBLIC_SUPABASE_URL ?? "",
10
9
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "",
@@ -4,7 +4,6 @@ import { cookies } from "next/headers";
4
4
  export async function createClient() {
5
5
  const cookieStore = await cookies();
6
6
 
7
- // eslint-disable-next-line @typescript-eslint/no-deprecated
8
7
  return createServerClient(
9
8
  process.env.NEXT_PUBLIC_SUPABASE_URL ?? "",
10
9
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "",