@dennisrongo/skills 0.1.3 → 0.2.0

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 (23) hide show
  1. package/README.md +23 -13
  2. package/package.json +1 -1
  3. package/skills/nextjs-app-router/SKILL.md +228 -175
  4. package/skills/nextjs-app-router/references/anti-patterns.md +163 -99
  5. package/skills/nextjs-app-router/references/folder-layout.md +79 -46
  6. package/skills/nextjs-app-router/references/good-patterns.md +233 -99
  7. package/skills/nextjs-app-router/references/templates/api-base.md +24 -21
  8. package/skills/nextjs-app-router/references/templates/auth-slice.md +82 -78
  9. package/skills/nextjs-app-router/references/templates/ci-and-hooks.md +58 -6
  10. package/skills/nextjs-app-router/references/templates/db-client.md +48 -0
  11. package/skills/nextjs-app-router/references/templates/env-and-utils.md +90 -23
  12. package/skills/nextjs-app-router/references/templates/feature-slice.md +110 -47
  13. package/skills/nextjs-app-router/references/templates/form-with-zod.md +23 -15
  14. package/skills/nextjs-app-router/references/templates/middleware.md +69 -59
  15. package/skills/nextjs-app-router/references/templates/next-config.md +4 -14
  16. package/skills/nextjs-app-router/references/templates/nextauth-config.md +178 -0
  17. package/skills/nextjs-app-router/references/templates/package.md +35 -5
  18. package/skills/nextjs-app-router/references/templates/prisma-schema.md +162 -0
  19. package/skills/nextjs-app-router/references/templates/providers-and-store.md +35 -21
  20. package/skills/nextjs-app-router/references/templates/root-layout.md +99 -20
  21. package/skills/nextjs-app-router/references/templates/route-group-layouts.md +46 -6
  22. package/skills/nextjs-app-router/references/templates/route-handler.md +168 -0
  23. package/skills/nextjs-app-router/references/templates/testing.md +105 -31
@@ -1,8 +1,8 @@
1
1
  # Route group layouts
2
2
 
3
- Layouts are **server components** by default. Push interactive chrome into `'use client'` children.
3
+ Group layouts are **server components** that render zero data they just delegate to a `'use client'` shell. Pages inside the group are `'use client'`.
4
4
 
5
- ## `src/app/(public)/layout.tsx` (SERVER)
5
+ ## `src/app/(public)/layout.tsx` (SERVER, zero data)
6
6
 
7
7
  ```tsx
8
8
  export default function PublicLayout({ children }: { children: React.ReactNode }) {
@@ -20,12 +20,14 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
20
20
  import { AppShell } from './_components/AppShell';
21
21
 
22
22
  export default function AppLayout({ children }: { children: React.ReactNode }) {
23
- // Server-side session check happens in middleware.ts.
24
- // This layout assumes the request reached here because the user is authenticated.
23
+ // Server-side session check is done in middleware.ts — by the time we render here,
24
+ // the user is authenticated. We do NOT call `await auth()` here; the gate is the middleware.
25
25
  return <AppShell>{children}</AppShell>;
26
26
  }
27
27
  ```
28
28
 
29
+ **No `await auth()` here.** The middleware already gated the request. Calling `auth()` again from this layout adds a round-trip and doesn't change the outcome.
30
+
29
31
  ## `src/app/(app)/_components/AppShell.tsx` (CLIENT, holds interactive UI state)
30
32
 
31
33
  ```tsx
@@ -50,9 +52,40 @@ export function AppShell({ children }: { children: React.ReactNode }) {
50
52
  }
51
53
  ```
52
54
 
53
- ## `src/app/(app)/loading.tsx` (SERVER)
55
+ ## `src/app/(app)/_components/UserMenu.tsx` (CLIENT — uses NextAuth)
54
56
 
55
57
  ```tsx
58
+ 'use client';
59
+
60
+ import { signOut, useSession } from 'next-auth/react';
61
+ import { Button } from '@/components/ui/button';
62
+
63
+ export function UserMenu() {
64
+ const { data: session, status } = useSession();
65
+
66
+ if (status === 'loading') return null;
67
+ if (!session?.user) return null;
68
+
69
+ return (
70
+ <div className="flex items-center gap-3">
71
+ <span className="text-sm text-muted-foreground">{session.user.email}</span>
72
+ <Button
73
+ variant="ghost"
74
+ size="sm"
75
+ onClick={() => signOut({ callbackUrl: '/auth/login', redirect: true })}
76
+ >
77
+ Sign out
78
+ </Button>
79
+ </div>
80
+ );
81
+ }
82
+ ```
83
+
84
+ ## `src/app/(app)/loading.tsx` (CLIENT — Suspense fallback)
85
+
86
+ ```tsx
87
+ 'use client';
88
+
56
89
  export default function Loading() {
57
90
  return (
58
91
  <div className="flex h-[50vh] items-center justify-center">
@@ -87,4 +120,11 @@ export default function AppError({ error, reset }: { error: Error & { digest?: s
87
120
 
88
121
  ## `src/app/(admin)/layout.tsx` (OPTIONAL, SERVER)
89
122
 
90
- Same shape as `(app)/layout.tsx`. The role check happens in `middleware.ts`; this layout just renders an admin-flavored shell.
123
+ Same shape as `(app)/layout.tsx`. The role check happens in `middleware.ts` (Variant B in [`middleware.md`](middleware.md)); this layout just renders an admin-flavored shell.
124
+
125
+ ## Forbidden in any layout
126
+
127
+ - `await auth()` — duplicates middleware's job.
128
+ - `await db.*` — server data fetching is banned.
129
+ - `'use client'` directly on a `layout.tsx` — push interactive chrome into a client child.
130
+ - `redirect()` — middleware handles all redirects.
@@ -0,0 +1,168 @@
1
+ # Route Handler template
2
+
3
+ Route Handlers under `src/app/api/**/route.ts` are the backend. Every one (except `/api/auth/[...nextauth]` and explicit public endpoints like `/api/health`) authenticates with `requireSession()`, validates inputs with the feature's Zod schema, and accesses the DB through the Prisma singleton.
4
+
5
+ ## `src/app/api/{{feature}}s/route.ts` — list + create
6
+
7
+ ```ts
8
+ import { NextResponse } from 'next/server';
9
+ import { db } from '@/lib/db';
10
+ import { requireSession, withApiErrors } from '@/lib/api-auth';
11
+ import { {{feature}}Schema } from '@/app/(app)/{{feature}}s/schema';
12
+
13
+ export const GET = withApiErrors(async () => {
14
+ const session = await requireSession();
15
+
16
+ const rows = await db.{{feature}}.findMany({
17
+ where: { userId: session.user.id },
18
+ orderBy: { createdAt: 'desc' },
19
+ });
20
+
21
+ return NextResponse.json(rows);
22
+ });
23
+
24
+ export const POST = withApiErrors(async (req: Request) => {
25
+ const session = await requireSession();
26
+
27
+ const body = await req.json().catch(() => null);
28
+ const parsed = {{feature}}Schema.safeParse(body);
29
+ if (!parsed.success) {
30
+ return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
31
+ }
32
+
33
+ const created = await db.{{feature}}.create({
34
+ data: {
35
+ ...parsed.data,
36
+ userId: session.user.id,
37
+ },
38
+ });
39
+
40
+ return NextResponse.json(created, { status: 201 });
41
+ });
42
+ ```
43
+
44
+ ## `src/app/api/{{feature}}s/[id]/route.ts` — get + update + delete
45
+
46
+ ```ts
47
+ import { NextResponse } from 'next/server';
48
+ import { db } from '@/lib/db';
49
+ import { requireSession, withApiErrors, assertOwnership } from '@/lib/api-auth';
50
+ import { {{feature}}Schema } from '@/app/(app)/{{feature}}s/schema';
51
+
52
+ interface RouteContext {
53
+ params: Promise<{ id: string }>;
54
+ }
55
+
56
+ export const GET = withApiErrors(async (_req: Request, ctx: RouteContext) => {
57
+ const session = await requireSession();
58
+ const { id } = await ctx.params;
59
+
60
+ const row = await db.{{feature}}.findFirst({
61
+ where: { id, userId: session.user.id },
62
+ });
63
+ if (!row) return NextResponse.json({ error: 'Not found' }, { status: 404 });
64
+
65
+ return NextResponse.json(row);
66
+ });
67
+
68
+ export const PATCH = withApiErrors(async (req: Request, ctx: RouteContext) => {
69
+ const session = await requireSession();
70
+ const { id } = await ctx.params;
71
+
72
+ const body = await req.json().catch(() => null);
73
+ const parsed = {{feature}}Schema.partial().safeParse(body);
74
+ if (!parsed.success) {
75
+ return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
76
+ }
77
+
78
+ // Ownership check via findFirst + userId predicate.
79
+ const existing = await db.{{feature}}.findFirst({
80
+ where: { id, userId: session.user.id },
81
+ select: { id: true },
82
+ });
83
+ if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 });
84
+
85
+ const updated = await db.{{feature}}.update({
86
+ where: { id },
87
+ data: parsed.data,
88
+ });
89
+
90
+ return NextResponse.json(updated);
91
+ });
92
+
93
+ export const DELETE = withApiErrors(async (_req: Request, ctx: RouteContext) => {
94
+ const session = await requireSession();
95
+ const { id } = await ctx.params;
96
+
97
+ const existing = await db.{{feature}}.findFirst({
98
+ where: { id, userId: session.user.id },
99
+ select: { id: true },
100
+ });
101
+ if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 });
102
+
103
+ await db.{{feature}}.delete({ where: { id } });
104
+
105
+ return new NextResponse(null, { status: 204 });
106
+ });
107
+ ```
108
+
109
+ ## Public handlers (e.g. health check)
110
+
111
+ The matcher in `middleware.ts` already excludes `/api/auth/*`. Other `/api/*` routes aren't redirected by middleware (they run their own `requireSession()`). A public handler simply omits the session check:
112
+
113
+ ```ts
114
+ // src/app/api/health/route.ts
115
+ import { NextResponse } from 'next/server';
116
+ export const GET = () => NextResponse.json({ ok: true });
117
+ ```
118
+
119
+ Comment in the file so the absence of `requireSession()` is intentional, not an oversight.
120
+
121
+ ## Forbidden
122
+
123
+ - **No `requireSession()` call** in any handler outside `/api/auth/*` and explicitly-public ones (and the public ones must comment why).
124
+ - **Trusting `userId` from the request body.** Always read it from the session: `session.user.id`.
125
+ - **Returning the full record on POST/PATCH without checking ownership first** on updates — the `where: { id, userId }` predicate is the gate.
126
+ - **`prisma.$queryRaw` with string concatenation.** Use parameterized template literals (`prisma.$queryRaw\`... ${value} ...\``) or stick to the typed query builder.
127
+ - **Catching and re-throwing without logging.** `withApiErrors` logs unexpected errors; if you write your own try/catch, log unexpected ones at least once.
128
+ - **Returning 500 with the raw error message.** Leaks stack traces. `withApiErrors` returns a generic message; if you write your own, do the same.
129
+ - **Long-running work in a handler.** Route Handlers run per-request. For background work, dispatch to a queue (the skill doesn't ship one — ask the user to pick).
130
+ - **`Set-Cookie` headers writing your own session cookie.** NextAuth owns the session cookie; setting a parallel one is the "custom JWT alongside NextAuth" anti-pattern.
131
+
132
+ ## Why we import the schema from the feature folder
133
+
134
+ The form (`_components/{{Feature}}Form.tsx`) and the handler validate the same shape. If the handler had its own schema, drift would silently let the form post payloads the handler accepts but the form's UI hasn't accounted for — or vice versa. One Zod schema in `(app)/{{feature}}s/schema.ts` is the contract; both sides import it.
135
+
136
+ ## Handler test (optional but recommended)
137
+
138
+ ```ts
139
+ // tests/unit/{{feature}}.handler.test.ts
140
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
141
+
142
+ vi.mock('@/auth', () => ({ auth: vi.fn() }));
143
+ vi.mock('@/lib/db', () => ({
144
+ db: { {{feature}}: { findMany: vi.fn(), create: vi.fn() } },
145
+ }));
146
+
147
+ import { GET, POST } from '@/app/api/{{feature}}s/route';
148
+ import { auth } from '@/auth';
149
+ import { db } from '@/lib/db';
150
+
151
+ describe('GET /api/{{feature}}s', () => {
152
+ beforeEach(() => vi.clearAllMocks());
153
+
154
+ it('401s when unauthenticated', async () => {
155
+ (auth as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
156
+ const res = await GET();
157
+ expect(res.status).toBe(401);
158
+ });
159
+
160
+ it('returns the current user's rows', async () => {
161
+ (auth as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ user: { id: 'u1' } });
162
+ (db.{{feature}}.findMany as ReturnType<typeof vi.fn>).mockResolvedValueOnce([{ id: 'r1', userId: 'u1' }]);
163
+ const res = await GET();
164
+ expect(res.status).toBe(200);
165
+ expect(await res.json()).toEqual([{ id: 'r1', userId: 'u1' }]);
166
+ });
167
+ });
168
+ ```
@@ -1,6 +1,6 @@
1
1
  # Testing setup (Vitest + RTL + Playwright)
2
2
 
3
- A new project ships with three real tests: a reducer test, a component test, and an E2E auth spec. Three is enough to make CI meaningful; the user adds more as features grow.
3
+ A new project ships with a small set of real tests: a Zod schema test, a Route Handler test, a component test, and an E2E auth spec. Just enough to make CI meaningful.
4
4
 
5
5
  ## `vitest.config.ts`
6
6
 
@@ -38,61 +38,123 @@ afterEach(() => {
38
38
  });
39
39
  ```
40
40
 
41
- ## `tests/unit/auth.slice.test.ts`
41
+ ## `tests/unit/customer.schema.test.ts`
42
42
 
43
43
  ```ts
44
44
  import { describe, expect, it } from 'vitest';
45
- import { authSlice, logout, setUser } from '@/redux/features/authSlice';
45
+ import { customerSchema } from '@/app/(app)/customers/schema';
46
46
 
47
- const initial = authSlice.getInitialState();
47
+ describe('customerSchema', () => {
48
+ it('accepts a valid payload', () => {
49
+ expect(customerSchema.safeParse({ name: 'Acme', email: 'ops@acme.test' }).success).toBe(true);
50
+ });
51
+
52
+ it('rejects an empty name', () => {
53
+ expect(customerSchema.safeParse({ name: '', email: 'ops@acme.test' }).success).toBe(false);
54
+ });
55
+
56
+ it('rejects an invalid email', () => {
57
+ expect(customerSchema.safeParse({ name: 'Acme', email: 'nope' }).success).toBe(false);
58
+ });
59
+ });
60
+ ```
61
+
62
+ This is the test that pays for itself most often. The same schema validates form input *and* the Route Handler's request body, so regressions on either side fail here.
63
+
64
+ ## `tests/unit/customers.handler.test.ts` (Route Handler test with mocked `auth` + `db`)
65
+
66
+ ```ts
67
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
68
+
69
+ vi.mock('@/auth', () => ({ auth: vi.fn() }));
70
+ vi.mock('@/lib/db', () => ({
71
+ db: {
72
+ customer: {
73
+ findMany: vi.fn(),
74
+ create: vi.fn(),
75
+ },
76
+ },
77
+ }));
48
78
 
49
- describe('authSlice', () => {
50
- it('starts logged out', () => {
51
- expect(initial.user).toBeNull();
52
- expect(initial.token).toBeNull();
79
+ import { GET, POST } from '@/app/api/customers/route';
80
+ import { auth } from '@/auth';
81
+ import { db } from '@/lib/db';
82
+
83
+ const mockAuth = auth as ReturnType<typeof vi.fn>;
84
+ const mockFindMany = db.customer.findMany as ReturnType<typeof vi.fn>;
85
+ const mockCreate = db.customer.create as ReturnType<typeof vi.fn>;
86
+
87
+ describe('GET /api/customers', () => {
88
+ beforeEach(() => vi.clearAllMocks());
89
+
90
+ it('401s when unauthenticated', async () => {
91
+ mockAuth.mockResolvedValueOnce(null);
92
+ const res = await GET();
93
+ expect(res.status).toBe(401);
53
94
  });
54
95
 
55
- it('sets the user', () => {
56
- const state = authSlice.reducer(initial, setUser({ id: '1', email: 'a@b.c', name: 'A', roles: [] }));
57
- expect(state.user?.id).toBe('1');
96
+ it('returns the current user\'s customers', async () => {
97
+ mockAuth.mockResolvedValueOnce({ user: { id: 'u1' } });
98
+ mockFindMany.mockResolvedValueOnce([{ id: 'c1', userId: 'u1', name: 'Acme' }]);
99
+ const res = await GET();
100
+ expect(res.status).toBe(200);
101
+ expect(await res.json()).toEqual([{ id: 'c1', userId: 'u1', name: 'Acme' }]);
58
102
  });
103
+ });
59
104
 
60
- it('logout resets state', () => {
61
- const seeded = { ...initial, user: { id: '1', email: 'a@b.c', name: 'A', roles: [] } };
62
- expect(authSlice.reducer(seeded, logout())).toEqual(initial);
105
+ describe('POST /api/customers', () => {
106
+ beforeEach(() => vi.clearAllMocks());
107
+
108
+ it('400s on an invalid payload', async () => {
109
+ mockAuth.mockResolvedValueOnce({ user: { id: 'u1' } });
110
+ const req = new Request('http://x/api/customers', {
111
+ method: 'POST',
112
+ body: JSON.stringify({ name: '', email: 'nope' }),
113
+ });
114
+ const res = await POST(req);
115
+ expect(res.status).toBe(400);
116
+ });
117
+
118
+ it('attaches the session userId to the created row', async () => {
119
+ mockAuth.mockResolvedValueOnce({ user: { id: 'u1' } });
120
+ mockCreate.mockResolvedValueOnce({ id: 'c2', userId: 'u1', name: 'Acme', email: 'ops@acme.test' });
121
+ const req = new Request('http://x/api/customers', {
122
+ method: 'POST',
123
+ body: JSON.stringify({ name: 'Acme', email: 'ops@acme.test' }),
124
+ });
125
+ await POST(req);
126
+ expect(mockCreate).toHaveBeenCalledWith({
127
+ data: { name: 'Acme', email: 'ops@acme.test', userId: 'u1' },
128
+ });
63
129
  });
64
130
  });
65
131
  ```
66
132
 
133
+ This test catches three common regressions: missing `requireSession`, body validation that silently accepts garbage, and userId tampering.
134
+
67
135
  ## `tests/unit/login-form.test.tsx`
68
136
 
69
137
  ```tsx
70
- import { describe, expect, it } from 'vitest';
138
+ import { describe, expect, it, vi } from 'vitest';
71
139
  import { render, screen } from '@testing-library/react';
72
140
  import userEvent from '@testing-library/user-event';
73
- import { Provider } from 'react-redux';
74
- import { configureStore } from '@reduxjs/toolkit';
75
- import { api } from '@/redux/api/api';
76
- import { authSlice } from '@/redux/features/authSlice';
77
141
  import { LoginForm } from '@/app/(public)/auth/login/_components/LoginForm';
78
142
 
79
- function renderWithStore(ui: React.ReactElement) {
80
- const store = configureStore({
81
- reducer: { [api.reducerPath]: api.reducer, auth: authSlice.reducer },
82
- middleware: (gdm) => gdm().concat(api.middleware),
83
- });
84
- return render(<Provider store={store}>{ui}</Provider>);
85
- }
143
+ vi.mock('next-auth/react', () => ({
144
+ signIn: vi.fn().mockResolvedValue({ ok: true, error: null }),
145
+ }));
86
146
 
87
147
  describe('<LoginForm />', () => {
88
- it('shows a validation error for an empty email', async () => {
89
- renderWithStore(<LoginForm />);
148
+ it('shows a validation error when fields are empty', async () => {
149
+ render(<LoginForm />);
90
150
  await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
91
151
  expect(await screen.findByText(/required|invalid/i)).toBeInTheDocument();
92
152
  });
93
153
  });
94
154
  ```
95
155
 
156
+ Mocking `next-auth/react` lets the form test run in isolation — we're testing the form, not NextAuth.
157
+
96
158
  ## `playwright.config.ts`
97
159
 
98
160
  ```ts
@@ -124,14 +186,26 @@ export default defineConfig({
124
186
  import { test, expect } from '@playwright/test';
125
187
 
126
188
  test('unauthenticated user is redirected to login', async ({ page }) => {
127
- await page.goto('/dashboard');
189
+ await page.goto('/app/dashboard');
128
190
  await expect(page).toHaveURL(/\/auth\/login/);
129
191
  await expect(page.getByRole('heading', { name: /sign in/i })).toBeVisible();
130
192
  });
193
+
194
+ // Requires `pnpm db:seed` to have run — provides test@example.com / test1234.
195
+ test('seeded user can sign in and land on the dashboard', async ({ page }) => {
196
+ await page.goto('/auth/login');
197
+ await page.getByLabel(/email/i).fill('test@example.com');
198
+ await page.getByLabel(/password/i).fill('test1234');
199
+ await page.getByRole('button', { name: /sign in/i }).click();
200
+ await expect(page).toHaveURL(/\/app\/dashboard/);
201
+ });
131
202
  ```
132
203
 
133
- That spec alone validates: middleware fires, the login page renders, the route group split works. If any of those regress, this test breaks.
204
+ Together those two specs validate: middleware fires, the login page renders, NextAuth's Credentials provider works against the seeded user, and the route-group split holds.
134
205
 
135
206
  **Notes:**
136
- - Do not include Playwright in the default `pnpm test` script — keep it as `pnpm test:e2e` so CI can run Vitest fast and Playwright separately (or in a different job).
207
+
208
+ - Do not include Playwright in the default `pnpm test` script — keep it as `pnpm test:e2e`.
209
+ - The seed-then-login test requires the CI workflow's `pnpm db:seed` step (see [`ci-and-hooks.md`](ci-and-hooks.md)).
137
210
  - Real backend mocking (MSW) is *not* in the default scaffold. Add only if the user asks.
211
+ - **Forbidden:** an `authSlice.test.ts`. There is no `authSlice` in this project — NextAuth owns session state. If you see a test importing `@/redux/features/authSlice`, delete it.