compress-claude 1.0.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.
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/cli.d.ts +17 -0
- package/dist/cli.js +73 -0
- package/dist/doctor.d.ts +2 -0
- package/dist/doctor.js +191 -0
- package/dist/generator.d.ts +35 -0
- package/dist/generator.js +3025 -0
- package/dist/hooks.d.ts +2 -0
- package/dist/hooks.js +101 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +42 -0
- package/dist/learning.d.ts +78 -0
- package/dist/learning.js +488 -0
- package/dist/license.d.ts +7 -0
- package/dist/license.js +75 -0
- package/dist/proxy/compressors.d.ts +5 -0
- package/dist/proxy/compressors.js +281 -0
- package/dist/proxy/middleware.d.ts +20 -0
- package/dist/proxy/middleware.js +77 -0
- package/dist/proxy/server.d.ts +8 -0
- package/dist/proxy/server.js +624 -0
- package/dist/proxy/transcript.d.ts +37 -0
- package/dist/proxy/transcript.js +164 -0
- package/dist/scanner.d.ts +21 -0
- package/dist/scanner.js +311 -0
- package/dist/utils/colors.d.ts +15 -0
- package/dist/utils/colors.js +34 -0
- package/dist/utils/logger.d.ts +8 -0
- package/dist/utils/logger.js +39 -0
- package/dist/utils/tokens.d.ts +8 -0
- package/dist/utils/tokens.js +21 -0
- package/package.json +48 -0
|
@@ -0,0 +1,3025 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ---- generator.ts ----
|
|
3
|
+
// Generated context files (CLAUDE.md, ai-docs/) for Claude Code sessions.
|
|
4
|
+
// Converted from claude-context.js to TypeScript.
|
|
5
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
6
|
+
if (k2 === undefined) k2 = k;
|
|
7
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
8
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
9
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
10
|
+
}
|
|
11
|
+
Object.defineProperty(o, k2, desc);
|
|
12
|
+
}) : (function(o, m, k, k2) {
|
|
13
|
+
if (k2 === undefined) k2 = k;
|
|
14
|
+
o[k2] = m[k];
|
|
15
|
+
}));
|
|
16
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
17
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
18
|
+
}) : function(o, v) {
|
|
19
|
+
o["default"] = v;
|
|
20
|
+
});
|
|
21
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
22
|
+
var ownKeys = function(o) {
|
|
23
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
24
|
+
var ar = [];
|
|
25
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
26
|
+
return ar;
|
|
27
|
+
};
|
|
28
|
+
return ownKeys(o);
|
|
29
|
+
};
|
|
30
|
+
return function (mod) {
|
|
31
|
+
if (mod && mod.__esModule) return mod;
|
|
32
|
+
var result = {};
|
|
33
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
34
|
+
__setModuleDefault(result, mod);
|
|
35
|
+
return result;
|
|
36
|
+
};
|
|
37
|
+
})();
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.EXTRA_FILES = exports.SKILL_FILES = exports.ALL_DOMAIN_DOCS = void 0;
|
|
40
|
+
exports.generateContextFiles = generateContextFiles;
|
|
41
|
+
exports.claudeMd = claudeMd;
|
|
42
|
+
exports.generateDomainDoc = generateDomainDoc;
|
|
43
|
+
exports.getDomainFiles = getDomainFiles;
|
|
44
|
+
exports.generatePromptFile = generatePromptFile;
|
|
45
|
+
exports.getPromptFiles = getPromptFiles;
|
|
46
|
+
exports.generateSkillFile = generateSkillFile;
|
|
47
|
+
exports.generateExtraFile = generateExtraFile;
|
|
48
|
+
const fs = __importStar(require("fs"));
|
|
49
|
+
const path = __importStar(require("path"));
|
|
50
|
+
const logger_1 = require("./utils/logger");
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Prompts Index
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
function promptsIndex(stack) {
|
|
55
|
+
return `# PROMPTS.md — Pattern Library Index
|
|
56
|
+
|
|
57
|
+
> Before writing any non-trivial code, check this index.
|
|
58
|
+
> Load the relevant category file, find the pattern, follow Build → Verify → Debug.
|
|
59
|
+
> This prevents hallucinated APIs, broken patterns, and wasted tokens.
|
|
60
|
+
|
|
61
|
+
## How to Use
|
|
62
|
+
1. Find your task in the index below
|
|
63
|
+
2. Load the category file: \`Read /ai-docs/prompts/[category].md\`
|
|
64
|
+
3. Follow the Build prompt exactly
|
|
65
|
+
4. Run the Verify checklist before moving on
|
|
66
|
+
5. If broken, use the Debug guide — don't guess
|
|
67
|
+
|
|
68
|
+
## Categories
|
|
69
|
+
|
|
70
|
+
| Category | File | Covers |
|
|
71
|
+
|----------|------|--------|
|
|
72
|
+
| Auth | \`/ai-docs/prompts/auth.md\` | Sign up, sign in, magic link, OAuth, session, middleware |
|
|
73
|
+
| Database | \`/ai-docs/prompts/database.md\` | Schema, queries, migrations, relations, transactions |
|
|
74
|
+
| Payments | \`/ai-docs/prompts/payments.md\` | Checkout, webhooks, portal, subscription gates, refunds |
|
|
75
|
+
| API | \`/ai-docs/prompts/api.md\` | Route handlers, validation, errors, rate limiting, middleware |
|
|
76
|
+
| Email | \`/ai-docs/prompts/email.md\` | Templates, triggers, testing, DNS |
|
|
77
|
+
| UI | \`/ai-docs/prompts/ui.md\` | Pages, components, forms, tables, modals, loading states |
|
|
78
|
+
| Deployment | \`/ai-docs/prompts/deployment.md\` | Railway deploy, env vars, crons, health checks, rollback |
|
|
79
|
+
| Analytics | \`/ai-docs/prompts/analytics.md\` | GA4 events, Search Console, conversions, sitemap |
|
|
80
|
+
| Security | \`/ai-docs/prompts/security.md\` | Auth checks, input validation, rate limiting, OWASP |
|
|
81
|
+
| Testing | \`/ai-docs/prompts/testing.md\` | Unit tests, integration, mocks, coverage |
|
|
82
|
+
|
|
83
|
+
## Quick Reference — Most Common Tasks
|
|
84
|
+
|
|
85
|
+
\`\`\`
|
|
86
|
+
Adding a new page → ui.md → "New Route/Page"
|
|
87
|
+
Adding a new API endpoint → api.md → "New Route Handler"
|
|
88
|
+
Changing DB schema → database.md → "Schema Change"
|
|
89
|
+
Adding auth to a route → auth.md → "Protect a Route"
|
|
90
|
+
New Stripe webhook event → payments.md → "Handle Webhook Event"
|
|
91
|
+
Sending a transactional email → email.md → "Send Transactional Email"
|
|
92
|
+
New cron job → deployment.md → "Add Cron Job"
|
|
93
|
+
Tracking a conversion → analytics.md → "Track Custom Event"
|
|
94
|
+
\`\`\`
|
|
95
|
+
|
|
96
|
+
## Token-Saving Rules
|
|
97
|
+
- Load ONE category file at a time — don't load all of them
|
|
98
|
+
- Only load domain docs relevant to the current task
|
|
99
|
+
- After finishing a task, you can unload the category file
|
|
100
|
+
- Check MISTAKES.md before starting any task you've attempted before
|
|
101
|
+
`;
|
|
102
|
+
}
|
|
103
|
+
;
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Prompts Auth
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
function authPrompts(stack) {
|
|
108
|
+
const auth = stack.auth || 'custom auth';
|
|
109
|
+
const isClerk = auth.includes('Clerk');
|
|
110
|
+
const isNextAuth = auth.includes('NextAuth');
|
|
111
|
+
const isSupabase = auth.includes('Supabase');
|
|
112
|
+
return `# Auth Prompts
|
|
113
|
+
|
|
114
|
+
## Protect a Route (Middleware)
|
|
115
|
+
|
|
116
|
+
### Build
|
|
117
|
+
${isClerk ? `\`\`\`typescript
|
|
118
|
+
// middleware.ts (root level)
|
|
119
|
+
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
|
|
120
|
+
|
|
121
|
+
const isPublicRoute = createRouteMatcher([
|
|
122
|
+
'/', '/sign-in(.*)', '/sign-up(.*)', '/api/webhooks(.*)', '/api/health'
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
export default clerkMiddleware((auth, req) => {
|
|
126
|
+
if (!isPublicRoute(req)) auth().protect();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
export const config = { matcher: ['/((?!_next|[^?]*\\\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)'] };
|
|
130
|
+
\`\`\`` : ''}
|
|
131
|
+
${isNextAuth ? `\`\`\`typescript
|
|
132
|
+
// middleware.ts
|
|
133
|
+
export { default } from 'next-auth/middleware';
|
|
134
|
+
export const config = { matcher: ['/dashboard/:path*', '/api/protected/:path*'] };
|
|
135
|
+
\`\`\`` : ''}
|
|
136
|
+
|
|
137
|
+
### Verify
|
|
138
|
+
- [ ] Public routes accessible without auth (/, /sign-in, /api/health, /api/webhooks/*)
|
|
139
|
+
- [ ] Protected routes redirect to sign-in when unauthenticated
|
|
140
|
+
- [ ] API routes return 401 JSON (not redirect) when unauthenticated
|
|
141
|
+
- [ ] Webhook endpoints explicitly excluded from auth middleware
|
|
142
|
+
|
|
143
|
+
### Debug
|
|
144
|
+
- Route redirecting when it shouldn't → check \`isPublicRoute\` matcher patterns
|
|
145
|
+
- Webhook 401s → ensure \`/api/webhooks(.*)\` is in public routes
|
|
146
|
+
- Middleware not running → check \`config.matcher\` excludes static files
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Get Current User (Server Component)
|
|
151
|
+
|
|
152
|
+
### Build
|
|
153
|
+
${isClerk ? `\`\`\`typescript
|
|
154
|
+
import { auth, currentUser } from '@clerk/nextjs/server';
|
|
155
|
+
|
|
156
|
+
// Just the ID (fastest — use this for auth checks)
|
|
157
|
+
const { userId } = auth();
|
|
158
|
+
if (!userId) redirect('/sign-in');
|
|
159
|
+
|
|
160
|
+
// Full user object (slower — only when you need name/email)
|
|
161
|
+
const user = await currentUser();
|
|
162
|
+
\`\`\`` : ''}
|
|
163
|
+
${isNextAuth ? `\`\`\`typescript
|
|
164
|
+
import { getServerSession } from 'next-auth';
|
|
165
|
+
import { authOptions } from '@/lib/auth';
|
|
166
|
+
|
|
167
|
+
const session = await getServerSession(authOptions);
|
|
168
|
+
if (!session) redirect('/sign-in');
|
|
169
|
+
const { user } = session; // user.id, user.email, user.role
|
|
170
|
+
\`\`\`` : ''}
|
|
171
|
+
${isSupabase ? `\`\`\`typescript
|
|
172
|
+
import { createServerClient } from '@supabase/auth-helpers-nextjs';
|
|
173
|
+
import { cookies } from 'next/headers';
|
|
174
|
+
|
|
175
|
+
const supabase = createServerClient(
|
|
176
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
177
|
+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
178
|
+
{ cookies: { get: (name) => cookies().get(name)?.value } }
|
|
179
|
+
);
|
|
180
|
+
const { data: { session } } = await supabase.auth.getSession();
|
|
181
|
+
if (!session) redirect('/sign-in');
|
|
182
|
+
\`\`\`` : ''}
|
|
183
|
+
|
|
184
|
+
### Verify
|
|
185
|
+
- [ ] Unauthenticated users redirected correctly
|
|
186
|
+
- [ ] userId/session is typed (not \`any\`)
|
|
187
|
+
- [ ] Not calling \`currentUser()\` when you only need \`userId\` (costs extra request)
|
|
188
|
+
|
|
189
|
+
### Debug
|
|
190
|
+
- \`userId\` is null in middleware but user is signed in → session cookie missing, check domain config
|
|
191
|
+
- \`currentUser()\` returns null → user exists in Clerk but not synced, check webhook
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Get Current User (API Route / Server Action)
|
|
196
|
+
|
|
197
|
+
### Build
|
|
198
|
+
${isClerk ? `\`\`\`typescript
|
|
199
|
+
// In API route or Server Action
|
|
200
|
+
import { auth } from '@clerk/nextjs/server';
|
|
201
|
+
|
|
202
|
+
export async function POST(req: Request) {
|
|
203
|
+
const { userId } = auth();
|
|
204
|
+
if (!userId) return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
|
205
|
+
// ... rest of handler
|
|
206
|
+
}
|
|
207
|
+
\`\`\`` : ''}
|
|
208
|
+
|
|
209
|
+
### Verify
|
|
210
|
+
- [ ] Returns 401 JSON (not redirect) for API routes
|
|
211
|
+
- [ ] userId checked BEFORE any DB query
|
|
212
|
+
- [ ] Never trust userId from request body — always from session
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## Sync User to Database (Webhook)
|
|
217
|
+
|
|
218
|
+
### Build
|
|
219
|
+
${isClerk ? `\`\`\`typescript
|
|
220
|
+
// app/api/webhooks/clerk/route.ts
|
|
221
|
+
import { Webhook } from 'svix';
|
|
222
|
+
import { headers } from 'next/headers';
|
|
223
|
+
import { db } from '@/lib/db';
|
|
224
|
+
|
|
225
|
+
export async function POST(req: Request) {
|
|
226
|
+
const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET;
|
|
227
|
+
if (!WEBHOOK_SECRET) throw new Error('Missing CLERK_WEBHOOK_SECRET');
|
|
228
|
+
|
|
229
|
+
const payload = await req.json();
|
|
230
|
+
const headerPayload = headers();
|
|
231
|
+
const svixHeaders = {
|
|
232
|
+
'svix-id': headerPayload.get('svix-id')!,
|
|
233
|
+
'svix-timestamp': headerPayload.get('svix-timestamp')!,
|
|
234
|
+
'svix-signature': headerPayload.get('svix-signature')!,
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const wh = new Webhook(WEBHOOK_SECRET);
|
|
238
|
+
let evt: any;
|
|
239
|
+
try {
|
|
240
|
+
evt = wh.verify(JSON.stringify(payload), svixHeaders);
|
|
241
|
+
} catch {
|
|
242
|
+
return Response.json({ error: 'Invalid signature' }, { status: 400 });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (evt.type === 'user.created') {
|
|
246
|
+
await db.user.create({
|
|
247
|
+
data: {
|
|
248
|
+
clerkId: evt.data.id,
|
|
249
|
+
email: evt.data.email_addresses[0].email_address,
|
|
250
|
+
name: \`\${evt.data.first_name} \${evt.data.last_name}\`.trim(),
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (evt.type === 'user.deleted') {
|
|
256
|
+
await db.user.update({
|
|
257
|
+
where: { clerkId: evt.data.id },
|
|
258
|
+
data: { deletedAt: new Date() },
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return Response.json({ received: true });
|
|
263
|
+
}
|
|
264
|
+
\`\`\`` : ''}
|
|
265
|
+
|
|
266
|
+
### Verify
|
|
267
|
+
- [ ] Webhook signature verified before processing
|
|
268
|
+
- [ ] Idempotent — re-running with same event doesn't create duplicates
|
|
269
|
+
- [ ] \`user.created\`, \`user.updated\`, \`user.deleted\` all handled
|
|
270
|
+
- [ ] Webhook secret in env vars, not hardcoded
|
|
271
|
+
- [ ] Route in public routes (not behind auth middleware)
|
|
272
|
+
|
|
273
|
+
### Debug
|
|
274
|
+
- 400 on webhook → signature mismatch, check \`CLERK_WEBHOOK_SECRET\` matches dashboard
|
|
275
|
+
- User not in DB → webhook not configured in Clerk dashboard, check endpoint URL
|
|
276
|
+
- Duplicate users → missing idempotency check, add \`upsert\` instead of \`create\`
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
## Role-Based Access Control
|
|
281
|
+
|
|
282
|
+
### Build
|
|
283
|
+
\`\`\`typescript
|
|
284
|
+
// src/lib/permissions.ts
|
|
285
|
+
export type Role = 'admin' | 'user' | 'viewer';
|
|
286
|
+
|
|
287
|
+
export const can = {
|
|
288
|
+
viewDashboard: (role: Role) => ['admin', 'user'].includes(role),
|
|
289
|
+
manageUsers: (role: Role) => role === 'admin',
|
|
290
|
+
exportData: (role: Role) => ['admin', 'user'].includes(role),
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
// Usage in Server Component or API route
|
|
294
|
+
const user = await getUserFromDb(userId);
|
|
295
|
+
if (!can.manageUsers(user.role)) {
|
|
296
|
+
return Response.json({ error: 'Forbidden' }, { status: 403 });
|
|
297
|
+
}
|
|
298
|
+
\`\`\`
|
|
299
|
+
|
|
300
|
+
### Verify
|
|
301
|
+
- [ ] Role checked server-side, never trusted from client
|
|
302
|
+
- [ ] 403 returned (not 401) for wrong role — user is authenticated, just not authorized
|
|
303
|
+
- [ ] Role stored in DB, not in JWT claims (claims can be stale)
|
|
304
|
+
|
|
305
|
+
### Debug
|
|
306
|
+
- Role check passing when it shouldn't → pulling role from JWT instead of DB, fix to query DB
|
|
307
|
+
`;
|
|
308
|
+
}
|
|
309
|
+
;
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// Prompts Database
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
function databasePrompts(stack) {
|
|
314
|
+
const db = stack.database || 'Prisma (PostgreSQL)';
|
|
315
|
+
const isPrisma = db.includes('Prisma');
|
|
316
|
+
const isDrizzle = db.includes('Drizzle');
|
|
317
|
+
return `# Database Prompts
|
|
318
|
+
|
|
319
|
+
## Schema Change (Add Table or Column)
|
|
320
|
+
|
|
321
|
+
### Build
|
|
322
|
+
${isPrisma ? `1. Edit \`prisma/schema.prisma\`
|
|
323
|
+
2. Run \`pnpm db:push\` (dev) or \`pnpm db:migrate\` (staging/prod)
|
|
324
|
+
3. Regenerate client: \`pnpm prisma generate\`
|
|
325
|
+
|
|
326
|
+
\`\`\`prisma
|
|
327
|
+
// New table pattern
|
|
328
|
+
model Post {
|
|
329
|
+
id String @id @default(cuid())
|
|
330
|
+
title String
|
|
331
|
+
content String?
|
|
332
|
+
published Boolean @default(false)
|
|
333
|
+
authorId String
|
|
334
|
+
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
|
335
|
+
createdAt DateTime @default(now())
|
|
336
|
+
updatedAt DateTime @updatedAt
|
|
337
|
+
deletedAt DateTime? // soft delete
|
|
338
|
+
|
|
339
|
+
@@index([authorId])
|
|
340
|
+
@@index([createdAt])
|
|
341
|
+
}
|
|
342
|
+
\`\`\`` : ''}
|
|
343
|
+
${isDrizzle ? `1. Edit \`src/db/schema.ts\`
|
|
344
|
+
2. Generate migration: \`pnpm drizzle-kit generate\`
|
|
345
|
+
3. Run migration: \`pnpm drizzle-kit migrate\`
|
|
346
|
+
|
|
347
|
+
\`\`\`typescript
|
|
348
|
+
// src/db/schema.ts
|
|
349
|
+
import { pgTable, text, boolean, timestamp, index } from 'drizzle-orm/pg-core';
|
|
350
|
+
import { createId } from '@paralleldrive/cuid2';
|
|
351
|
+
|
|
352
|
+
export const posts = pgTable('posts', {
|
|
353
|
+
id: text('id').primaryKey().$defaultFn(() => createId()),
|
|
354
|
+
title: text('title').notNull(),
|
|
355
|
+
content: text('content'),
|
|
356
|
+
published: boolean('published').default(false).notNull(),
|
|
357
|
+
authorId: text('author_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
|
358
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
359
|
+
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
360
|
+
deletedAt: timestamp('deleted_at'),
|
|
361
|
+
}, (table) => ({
|
|
362
|
+
authorIdx: index('posts_author_idx').on(table.authorId),
|
|
363
|
+
createdAtIdx: index('posts_created_at_idx').on(table.createdAt),
|
|
364
|
+
}));
|
|
365
|
+
\`\`\`` : ''}
|
|
366
|
+
|
|
367
|
+
### Verify
|
|
368
|
+
- [ ] Every table has: id, createdAt, updatedAt
|
|
369
|
+
- [ ] Foreign keys have \`onDelete\` set (Cascade or SetNull — never default)
|
|
370
|
+
- [ ] Indexes added for: all foreign keys + frequently filtered/sorted columns
|
|
371
|
+
- [ ] Migration file generated and committed (don't skip migration for prod)
|
|
372
|
+
- [ ] \`pnpm prisma generate\` run after schema change
|
|
373
|
+
|
|
374
|
+
### Debug
|
|
375
|
+
- Migration drift → \`pnpm prisma migrate reset\` (dev only!) then re-migrate
|
|
376
|
+
- Type errors after schema change → forgot to run \`prisma generate\`
|
|
377
|
+
- Slow queries → check missing indexes with \`EXPLAIN ANALYZE\` in Railway Postgres
|
|
378
|
+
|
|
379
|
+
---
|
|
380
|
+
|
|
381
|
+
## Query — Fetch with Relations
|
|
382
|
+
|
|
383
|
+
### Build
|
|
384
|
+
${isPrisma ? `\`\`\`typescript
|
|
385
|
+
// ✅ Correct — select only what you need
|
|
386
|
+
const posts = await prisma.post.findMany({
|
|
387
|
+
where: { authorId: userId, deletedAt: null },
|
|
388
|
+
select: {
|
|
389
|
+
id: true,
|
|
390
|
+
title: true,
|
|
391
|
+
published: true,
|
|
392
|
+
createdAt: true,
|
|
393
|
+
author: { select: { id: true, name: true } },
|
|
394
|
+
},
|
|
395
|
+
orderBy: { createdAt: 'desc' },
|
|
396
|
+
take: 20, // always paginate
|
|
397
|
+
skip: page * 20,
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// ❌ Wrong — never do this
|
|
401
|
+
const posts = await prisma.post.findMany(); // unbounded, returns everything
|
|
402
|
+
\`\`\`` : ''}
|
|
403
|
+
${isDrizzle ? `\`\`\`typescript
|
|
404
|
+
// ✅ Correct
|
|
405
|
+
const posts = await db
|
|
406
|
+
.select({
|
|
407
|
+
id: posts.id,
|
|
408
|
+
title: posts.title,
|
|
409
|
+
published: posts.published,
|
|
410
|
+
createdAt: posts.createdAt,
|
|
411
|
+
authorName: users.name,
|
|
412
|
+
})
|
|
413
|
+
.from(posts)
|
|
414
|
+
.leftJoin(users, eq(posts.authorId, users.id))
|
|
415
|
+
.where(and(eq(posts.authorId, userId), isNull(posts.deletedAt)))
|
|
416
|
+
.orderBy(desc(posts.createdAt))
|
|
417
|
+
.limit(20)
|
|
418
|
+
.offset(page * 20);
|
|
419
|
+
\`\`\`` : ''}
|
|
420
|
+
|
|
421
|
+
### Verify
|
|
422
|
+
- [ ] Query is paginated (never unbounded)
|
|
423
|
+
- [ ] Only selecting needed fields (not \`select *\` / full object)
|
|
424
|
+
- [ ] \`deletedAt: null\` filter if using soft deletes
|
|
425
|
+
- [ ] Sorted by a consistent field for stable pagination
|
|
426
|
+
|
|
427
|
+
### Debug
|
|
428
|
+
- N+1 queries → use \`include\`/\`join\` instead of looping and querying per item
|
|
429
|
+
- Slow query → add missing index, or check if filtering on non-indexed column
|
|
430
|
+
|
|
431
|
+
---
|
|
432
|
+
|
|
433
|
+
## Multi-Step Write (Transaction)
|
|
434
|
+
|
|
435
|
+
### Build
|
|
436
|
+
${isPrisma ? `\`\`\`typescript
|
|
437
|
+
// Always use transactions for multi-step writes
|
|
438
|
+
const result = await prisma.$transaction(async (tx) => {
|
|
439
|
+
const user = await tx.user.update({
|
|
440
|
+
where: { id: userId },
|
|
441
|
+
data: { credits: { decrement: 1 } },
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
if (user.credits < 0) {
|
|
445
|
+
throw new Error('INSUFFICIENT_CREDITS'); // rolls back automatically
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const job = await tx.job.create({
|
|
449
|
+
data: { userId, status: 'pending' },
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
return job;
|
|
453
|
+
});
|
|
454
|
+
\`\`\`` : ''}
|
|
455
|
+
${isDrizzle ? `\`\`\`typescript
|
|
456
|
+
const result = await db.transaction(async (tx) => {
|
|
457
|
+
const [updated] = await tx
|
|
458
|
+
.update(users)
|
|
459
|
+
.set({ credits: sql\`credits - 1\` })
|
|
460
|
+
.where(eq(users.id, userId))
|
|
461
|
+
.returning();
|
|
462
|
+
|
|
463
|
+
if (updated.credits < 0) throw new Error('INSUFFICIENT_CREDITS');
|
|
464
|
+
|
|
465
|
+
const [job] = await tx.insert(jobs).values({ userId, status: 'pending' }).returning();
|
|
466
|
+
return job;
|
|
467
|
+
});
|
|
468
|
+
\`\`\`` : ''}
|
|
469
|
+
|
|
470
|
+
### Verify
|
|
471
|
+
- [ ] All related writes inside a single transaction
|
|
472
|
+
- [ ] Throws on invalid state (so transaction rolls back)
|
|
473
|
+
- [ ] Return value is the final needed object, not intermediate results
|
|
474
|
+
|
|
475
|
+
### Debug
|
|
476
|
+
- Transaction timeout → operations inside are too slow, add indexes or split into background job
|
|
477
|
+
- Deadlock → two transactions updating same rows in different order, standardize update order
|
|
478
|
+
|
|
479
|
+
---
|
|
480
|
+
|
|
481
|
+
## Soft Delete Pattern
|
|
482
|
+
|
|
483
|
+
### Build
|
|
484
|
+
\`\`\`typescript
|
|
485
|
+
// Delete (soft)
|
|
486
|
+
await prisma.post.update({
|
|
487
|
+
where: { id: postId, authorId: userId }, // scope to owner!
|
|
488
|
+
data: { deletedAt: new Date() },
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// Always filter out deleted records in queries
|
|
492
|
+
where: { deletedAt: null }
|
|
493
|
+
|
|
494
|
+
// Hard delete (only for GDPR compliance / user data requests)
|
|
495
|
+
await prisma.post.delete({ where: { id: postId } });
|
|
496
|
+
\`\`\`
|
|
497
|
+
|
|
498
|
+
### Verify
|
|
499
|
+
- [ ] Soft delete scoped to owner (include \`authorId: userId\` in where)
|
|
500
|
+
- [ ] All list queries include \`deletedAt: null\` filter
|
|
501
|
+
- [ ] Hard delete available for GDPR requests only
|
|
502
|
+
|
|
503
|
+
---
|
|
504
|
+
|
|
505
|
+
## Seeding
|
|
506
|
+
|
|
507
|
+
### Build
|
|
508
|
+
\`\`\`typescript
|
|
509
|
+
// prisma/seed.ts or scripts/seed.ts
|
|
510
|
+
async function main() {
|
|
511
|
+
// Upsert so seed is idempotent (safe to re-run)
|
|
512
|
+
const admin = await prisma.user.upsert({
|
|
513
|
+
where: { email: 'admin@example.com' },
|
|
514
|
+
update: {},
|
|
515
|
+
create: {
|
|
516
|
+
email: 'admin@example.com',
|
|
517
|
+
name: 'Admin',
|
|
518
|
+
role: 'admin',
|
|
519
|
+
},
|
|
520
|
+
});
|
|
521
|
+
console.log('Seeded:', admin.email);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
main().catch(console.error).finally(() => prisma.$disconnect());
|
|
525
|
+
\`\`\`
|
|
526
|
+
|
|
527
|
+
### Verify
|
|
528
|
+
- [ ] Seed is idempotent — upsert not create
|
|
529
|
+
- [ ] No hardcoded passwords or real data
|
|
530
|
+
- [ ] Can be re-run safely in CI
|
|
531
|
+
`;
|
|
532
|
+
}
|
|
533
|
+
;
|
|
534
|
+
// ---------------------------------------------------------------------------
|
|
535
|
+
// Prompts Payments
|
|
536
|
+
// ---------------------------------------------------------------------------
|
|
537
|
+
function paymentsPrompts(stack) {
|
|
538
|
+
return `# Payments Prompts
|
|
539
|
+
|
|
540
|
+
## Create Checkout Session
|
|
541
|
+
|
|
542
|
+
### Build
|
|
543
|
+
\`\`\`typescript
|
|
544
|
+
// app/api/checkout/route.ts
|
|
545
|
+
import { stripe } from '@/lib/stripe';
|
|
546
|
+
import { auth } from '@clerk/nextjs/server';
|
|
547
|
+
import { db } from '@/lib/db';
|
|
548
|
+
|
|
549
|
+
export async function POST(req: Request) {
|
|
550
|
+
const { userId } = auth();
|
|
551
|
+
if (!userId) return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
|
552
|
+
|
|
553
|
+
const { priceId } = await req.json();
|
|
554
|
+
const user = await db.user.findUnique({ where: { clerkId: userId } });
|
|
555
|
+
if (!user) return Response.json({ error: 'User not found' }, { status: 404 });
|
|
556
|
+
|
|
557
|
+
// Ensure Stripe customer exists
|
|
558
|
+
let customerId = user.stripeCustomerId;
|
|
559
|
+
if (!customerId) {
|
|
560
|
+
const customer = await stripe.customers.create({
|
|
561
|
+
email: user.email,
|
|
562
|
+
metadata: { userId: user.id },
|
|
563
|
+
});
|
|
564
|
+
customerId = customer.id;
|
|
565
|
+
await db.user.update({ where: { id: user.id }, data: { stripeCustomerId: customerId } });
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const session = await stripe.checkout.sessions.create({
|
|
569
|
+
customer: customerId,
|
|
570
|
+
mode: 'subscription',
|
|
571
|
+
payment_method_types: ['card'],
|
|
572
|
+
line_items: [{ price: priceId, quantity: 1 }],
|
|
573
|
+
success_url: \`\${process.env.NEXT_PUBLIC_APP_URL}/dashboard?upgraded=true\`,
|
|
574
|
+
cancel_url: \`\${process.env.NEXT_PUBLIC_APP_URL}/pricing\`,
|
|
575
|
+
metadata: { userId: user.id },
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
return Response.json({ url: session.url });
|
|
579
|
+
}
|
|
580
|
+
\`\`\`
|
|
581
|
+
|
|
582
|
+
### Verify
|
|
583
|
+
- [ ] Auth checked before creating session
|
|
584
|
+
- [ ] Customer ID saved to DB (don't create duplicate customers)
|
|
585
|
+
- [ ] \`success_url\` and \`cancel_url\` use env var, not hardcoded domain
|
|
586
|
+
- [ ] Metadata includes \`userId\` for webhook reconciliation
|
|
587
|
+
- [ ] Works in both test mode (test keys) and live mode
|
|
588
|
+
|
|
589
|
+
### Debug
|
|
590
|
+
- Customer already exists error → check \`stripeCustomerId\` in DB before creating
|
|
591
|
+
- Redirect after checkout goes nowhere → check \`success_url\` env var is set in Railway
|
|
592
|
+
- Webhook not updating subscription → confirm webhook is pointed at prod URL, not localhost
|
|
593
|
+
|
|
594
|
+
---
|
|
595
|
+
|
|
596
|
+
## Handle Webhook Events
|
|
597
|
+
|
|
598
|
+
### Build
|
|
599
|
+
\`\`\`typescript
|
|
600
|
+
// app/api/webhooks/stripe/route.ts
|
|
601
|
+
import Stripe from 'stripe';
|
|
602
|
+
import { stripe } from '@/lib/stripe';
|
|
603
|
+
import { db } from '@/lib/db';
|
|
604
|
+
|
|
605
|
+
export async function POST(req: Request) {
|
|
606
|
+
const body = await req.text(); // MUST be text, not json()
|
|
607
|
+
const sig = req.headers.get('stripe-signature')!;
|
|
608
|
+
|
|
609
|
+
let event: Stripe.Event;
|
|
610
|
+
try {
|
|
611
|
+
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
|
|
612
|
+
} catch {
|
|
613
|
+
return Response.json({ error: 'Invalid signature' }, { status: 400 });
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Idempotency: check if already processed
|
|
617
|
+
const processed = await db.webhookEvent.findUnique({ where: { stripeEventId: event.id } });
|
|
618
|
+
if (processed) return Response.json({ received: true });
|
|
619
|
+
|
|
620
|
+
try {
|
|
621
|
+
switch (event.type) {
|
|
622
|
+
case 'checkout.session.completed': {
|
|
623
|
+
const session = event.data.object as Stripe.CheckoutSession;
|
|
624
|
+
await handleCheckoutComplete(session);
|
|
625
|
+
break;
|
|
626
|
+
}
|
|
627
|
+
case 'customer.subscription.updated':
|
|
628
|
+
case 'customer.subscription.deleted': {
|
|
629
|
+
const sub = event.data.object as Stripe.Subscription;
|
|
630
|
+
await handleSubscriptionChange(sub);
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
633
|
+
case 'invoice.payment_failed': {
|
|
634
|
+
const invoice = event.data.object as Stripe.Invoice;
|
|
635
|
+
await handlePaymentFailed(invoice);
|
|
636
|
+
break;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Mark as processed
|
|
641
|
+
await db.webhookEvent.create({ data: { stripeEventId: event.id, type: event.type } });
|
|
642
|
+
} catch (error) {
|
|
643
|
+
console.error('[STRIPE_WEBHOOK]', event.type, error);
|
|
644
|
+
return Response.json({ error: 'Webhook handler failed' }, { status: 500 });
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
return Response.json({ received: true });
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async function handleCheckoutComplete(session: Stripe.CheckoutSession) {
|
|
651
|
+
const userId = session.metadata?.userId;
|
|
652
|
+
if (!userId) throw new Error('No userId in session metadata');
|
|
653
|
+
|
|
654
|
+
const subscription = await stripe.subscriptions.retrieve(session.subscription as string);
|
|
655
|
+
await db.user.update({
|
|
656
|
+
where: { id: userId },
|
|
657
|
+
data: {
|
|
658
|
+
subscriptionId: subscription.id,
|
|
659
|
+
subscriptionStatus: subscription.status,
|
|
660
|
+
subscriptionPriceId: subscription.items.data[0].price.id,
|
|
661
|
+
subscriptionCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
|
|
662
|
+
},
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async function handleSubscriptionChange(sub: Stripe.Subscription) {
|
|
667
|
+
await db.user.update({
|
|
668
|
+
where: { stripeCustomerId: sub.customer as string },
|
|
669
|
+
data: {
|
|
670
|
+
subscriptionStatus: sub.status,
|
|
671
|
+
subscriptionPriceId: sub.items.data[0]?.price.id ?? null,
|
|
672
|
+
subscriptionCurrentPeriodEnd: new Date(sub.current_period_end * 1000),
|
|
673
|
+
},
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
async function handlePaymentFailed(invoice: Stripe.Invoice) {
|
|
678
|
+
// Send dunning email via Resend
|
|
679
|
+
const user = await db.user.findUnique({ where: { stripeCustomerId: invoice.customer as string } });
|
|
680
|
+
if (user) {
|
|
681
|
+
// await sendPaymentFailedEmail(user.email);
|
|
682
|
+
console.log('[PAYMENT_FAILED] User:', user.email);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
\`\`\`
|
|
686
|
+
|
|
687
|
+
### Verify
|
|
688
|
+
- [ ] \`req.text()\` used (not \`req.json()\`) — signature breaks with json parsing
|
|
689
|
+
- [ ] Signature verified BEFORE processing
|
|
690
|
+
- [ ] Idempotency check — same event can arrive multiple times
|
|
691
|
+
- [ ] All relevant event types handled: checkout.completed, subscription.updated, subscription.deleted, invoice.payment_failed
|
|
692
|
+
- [ ] \`STRIPE_WEBHOOK_SECRET\` is the webhook endpoint secret (not API key)
|
|
693
|
+
- [ ] Route excluded from auth middleware
|
|
694
|
+
|
|
695
|
+
### Debug
|
|
696
|
+
- 400 on webhook → almost always \`req.json()\` instead of \`req.text()\`
|
|
697
|
+
- Duplicate subscription updates → missing idempotency check
|
|
698
|
+
- Wrong secret → using API key instead of webhook endpoint secret (get it from Stripe dashboard → Webhooks → your endpoint)
|
|
699
|
+
|
|
700
|
+
---
|
|
701
|
+
|
|
702
|
+
## Subscription Gate (Feature Access)
|
|
703
|
+
|
|
704
|
+
### Build
|
|
705
|
+
\`\`\`typescript
|
|
706
|
+
// src/lib/subscription.ts
|
|
707
|
+
export type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid';
|
|
708
|
+
|
|
709
|
+
export function isSubscriptionActive(status: SubscriptionStatus | null): boolean {
|
|
710
|
+
return status === 'active' || status === 'trialing';
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Usage in Server Component
|
|
714
|
+
const user = await db.user.findUnique({ where: { clerkId: userId } });
|
|
715
|
+
if (!isSubscriptionActive(user?.subscriptionStatus)) {
|
|
716
|
+
redirect('/pricing?reason=subscription_required');
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Usage in API route
|
|
720
|
+
if (!isSubscriptionActive(user?.subscriptionStatus)) {
|
|
721
|
+
return Response.json({ error: 'Subscription required', code: 'SUBSCRIPTION_REQUIRED' }, { status: 403 });
|
|
722
|
+
}
|
|
723
|
+
\`\`\`
|
|
724
|
+
|
|
725
|
+
### Verify
|
|
726
|
+
- [ ] Check subscription in DB — never trust client-side claims
|
|
727
|
+
- [ ] \`trialing\` treated same as \`active\` for access
|
|
728
|
+
- [ ] \`past_due\` shows warning but doesn't fully lock out (give grace period)
|
|
729
|
+
- [ ] Redirect includes reason param so pricing page can show contextual message
|
|
730
|
+
|
|
731
|
+
---
|
|
732
|
+
|
|
733
|
+
## Customer Billing Portal
|
|
734
|
+
|
|
735
|
+
### Build
|
|
736
|
+
\`\`\`typescript
|
|
737
|
+
// app/api/billing/portal/route.ts
|
|
738
|
+
export async function POST(req: Request) {
|
|
739
|
+
const { userId } = auth();
|
|
740
|
+
if (!userId) return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
|
741
|
+
|
|
742
|
+
const user = await db.user.findUnique({ where: { clerkId: userId } });
|
|
743
|
+
if (!user?.stripeCustomerId) {
|
|
744
|
+
return Response.json({ error: 'No billing account found' }, { status: 404 });
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const session = await stripe.billingPortal.sessions.create({
|
|
748
|
+
customer: user.stripeCustomerId,
|
|
749
|
+
return_url: \`\${process.env.NEXT_PUBLIC_APP_URL}/dashboard/settings\`,
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
return Response.json({ url: session.url });
|
|
753
|
+
}
|
|
754
|
+
\`\`\`
|
|
755
|
+
|
|
756
|
+
### Verify
|
|
757
|
+
- [ ] Customer portal enabled in Stripe dashboard settings
|
|
758
|
+
- [ ] \`return_url\` uses env var
|
|
759
|
+
- [ ] User has \`stripeCustomerId\` before attempting (guard against new users)
|
|
760
|
+
`;
|
|
761
|
+
}
|
|
762
|
+
;
|
|
763
|
+
// ---------------------------------------------------------------------------
|
|
764
|
+
// Prompts UI, API, Deployment, Email, Analytics, Security
|
|
765
|
+
// ---------------------------------------------------------------------------
|
|
766
|
+
function uiPrompts(stack) {
|
|
767
|
+
return `# UI Prompts
|
|
768
|
+
|
|
769
|
+
## New Page / Route
|
|
770
|
+
|
|
771
|
+
### Build
|
|
772
|
+
\`\`\`typescript
|
|
773
|
+
// app/dashboard/posts/page.tsx
|
|
774
|
+
import { auth } from '@clerk/nextjs/server';
|
|
775
|
+
import { redirect } from 'next/navigation';
|
|
776
|
+
import { db } from '@/lib/db';
|
|
777
|
+
|
|
778
|
+
export const metadata = { title: 'Posts | MyApp' };
|
|
779
|
+
|
|
780
|
+
export default async function PostsPage() {
|
|
781
|
+
const { userId } = auth();
|
|
782
|
+
if (!userId) redirect('/sign-in');
|
|
783
|
+
|
|
784
|
+
const posts = await db.post.findMany({
|
|
785
|
+
where: { authorId: userId, deletedAt: null },
|
|
786
|
+
orderBy: { createdAt: 'desc' },
|
|
787
|
+
take: 20,
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
return (
|
|
791
|
+
<div className="container mx-auto py-8">
|
|
792
|
+
<h1 className="text-2xl font-bold mb-6">Posts</h1>
|
|
793
|
+
{posts.length === 0 ? (
|
|
794
|
+
<EmptyState message="No posts yet" action={{ label: 'Create post', href: '/dashboard/posts/new' }} />
|
|
795
|
+
) : (
|
|
796
|
+
<PostList posts={posts} />
|
|
797
|
+
)}
|
|
798
|
+
</div>
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
\`\`\`
|
|
802
|
+
|
|
803
|
+
### Verify
|
|
804
|
+
- [ ] Auth check at top (before any DB query)
|
|
805
|
+
- [ ] \`metadata\` exported for SEO
|
|
806
|
+
- [ ] Empty state handled
|
|
807
|
+
- [ ] Loading state (add \`loading.tsx\` sibling file if needed)
|
|
808
|
+
- [ ] Error state (add \`error.tsx\` sibling file if needed)
|
|
809
|
+
- [ ] Paginated if list could exceed 20 items
|
|
810
|
+
|
|
811
|
+
### Debug
|
|
812
|
+
- Page flickers on load → missing Suspense boundary, wrap async component
|
|
813
|
+
- Auth redirecting logged-in users → middleware matcher too broad
|
|
814
|
+
|
|
815
|
+
---
|
|
816
|
+
|
|
817
|
+
## Form with Server Action
|
|
818
|
+
|
|
819
|
+
### Build
|
|
820
|
+
\`\`\`typescript
|
|
821
|
+
// app/dashboard/posts/new/page.tsx + actions/posts.ts
|
|
822
|
+
|
|
823
|
+
// actions/posts.ts
|
|
824
|
+
'use server';
|
|
825
|
+
import { auth } from '@clerk/nextjs/server';
|
|
826
|
+
import { z } from 'zod';
|
|
827
|
+
import { db } from '@/lib/db';
|
|
828
|
+
import { revalidatePath } from 'next/cache';
|
|
829
|
+
|
|
830
|
+
const CreatePostSchema = z.object({
|
|
831
|
+
title: z.string().min(1).max(200),
|
|
832
|
+
content: z.string().min(1),
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
export async function createPost(formData: FormData) {
|
|
836
|
+
const { userId } = auth();
|
|
837
|
+
if (!userId) return { error: 'Unauthorized' };
|
|
838
|
+
|
|
839
|
+
const raw = { title: formData.get('title'), content: formData.get('content') };
|
|
840
|
+
const parsed = CreatePostSchema.safeParse(raw);
|
|
841
|
+
if (!parsed.success) return { error: parsed.error.flatten().fieldErrors };
|
|
842
|
+
|
|
843
|
+
const post = await db.post.create({
|
|
844
|
+
data: { ...parsed.data, authorId: userId },
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
revalidatePath('/dashboard/posts');
|
|
848
|
+
return { success: true, postId: post.id };
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// Component
|
|
852
|
+
'use client';
|
|
853
|
+
import { useTransition } from 'react';
|
|
854
|
+
import { createPost } from '@/actions/posts';
|
|
855
|
+
|
|
856
|
+
export function CreatePostForm() {
|
|
857
|
+
const [isPending, startTransition] = useTransition();
|
|
858
|
+
const [error, setError] = useState<string | null>(null);
|
|
859
|
+
|
|
860
|
+
function handleSubmit(formData: FormData) {
|
|
861
|
+
startTransition(async () => {
|
|
862
|
+
const result = await createPost(formData);
|
|
863
|
+
if (result.error) setError(typeof result.error === 'string' ? result.error : 'Validation failed');
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
return (
|
|
868
|
+
<form action={handleSubmit} className="space-y-4">
|
|
869
|
+
<input name="title" required className="w-full border rounded px-3 py-2" />
|
|
870
|
+
<textarea name="content" required className="w-full border rounded px-3 py-2" />
|
|
871
|
+
{error && <p className="text-red-500 text-sm">{error}</p>}
|
|
872
|
+
<button type="submit" disabled={isPending}>
|
|
873
|
+
{isPending ? 'Creating...' : 'Create Post'}
|
|
874
|
+
</button>
|
|
875
|
+
</form>
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
\`\`\`
|
|
879
|
+
|
|
880
|
+
### Verify
|
|
881
|
+
- [ ] Server Action has auth check at top
|
|
882
|
+
- [ ] Zod validation on server (never trust client-side validation alone)
|
|
883
|
+
- [ ] \`revalidatePath\` called after mutation to refresh cached data
|
|
884
|
+
- [ ] Loading state shown while pending
|
|
885
|
+
- [ ] Error displayed to user (not just console.log)
|
|
886
|
+
- [ ] No \`<form>\` with \`method="post"\` — use Server Actions
|
|
887
|
+
|
|
888
|
+
---
|
|
889
|
+
|
|
890
|
+
## Data Table with Pagination
|
|
891
|
+
|
|
892
|
+
### Build
|
|
893
|
+
\`\`\`typescript
|
|
894
|
+
// Use URL search params for pagination state (shareable, no useState)
|
|
895
|
+
// app/dashboard/posts/page.tsx
|
|
896
|
+
|
|
897
|
+
export default async function PostsPage({ searchParams }: { searchParams: { page?: string } }) {
|
|
898
|
+
const page = Number(searchParams.page ?? 1) - 1;
|
|
899
|
+
const pageSize = 20;
|
|
900
|
+
|
|
901
|
+
const [posts, total] = await Promise.all([
|
|
902
|
+
db.post.findMany({
|
|
903
|
+
where: { deletedAt: null },
|
|
904
|
+
orderBy: { createdAt: 'desc' },
|
|
905
|
+
take: pageSize,
|
|
906
|
+
skip: page * pageSize,
|
|
907
|
+
}),
|
|
908
|
+
db.post.count({ where: { deletedAt: null } }),
|
|
909
|
+
]);
|
|
910
|
+
|
|
911
|
+
const totalPages = Math.ceil(total / pageSize);
|
|
912
|
+
|
|
913
|
+
return (
|
|
914
|
+
<div>
|
|
915
|
+
<PostTable posts={posts} />
|
|
916
|
+
<Pagination currentPage={page + 1} totalPages={totalPages} />
|
|
917
|
+
</div>
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
\`\`\`
|
|
921
|
+
|
|
922
|
+
### Verify
|
|
923
|
+
- [ ] Pagination uses URL params (not useState) — shareable URLs
|
|
924
|
+
- [ ] Total count fetched in parallel with data (\`Promise.all\`)
|
|
925
|
+
- [ ] Page out of range handled gracefully (redirect to page 1)
|
|
926
|
+
- [ ] Empty state shown when no results
|
|
927
|
+
|
|
928
|
+
---
|
|
929
|
+
|
|
930
|
+
## Loading & Error States
|
|
931
|
+
|
|
932
|
+
### Build
|
|
933
|
+
\`\`\`typescript
|
|
934
|
+
// app/dashboard/posts/loading.tsx — automatic Suspense fallback
|
|
935
|
+
export default function Loading() {
|
|
936
|
+
return <PostTableSkeleton />;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// app/dashboard/posts/error.tsx — catches thrown errors
|
|
940
|
+
'use client';
|
|
941
|
+
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
|
|
942
|
+
return (
|
|
943
|
+
<div className="text-center py-12">
|
|
944
|
+
<p className="text-red-500 mb-4">Something went wrong</p>
|
|
945
|
+
<button onClick={reset}>Try again</button>
|
|
946
|
+
</div>
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
\`\`\`
|
|
950
|
+
|
|
951
|
+
### Verify
|
|
952
|
+
- [ ] Every async page has a \`loading.tsx\` sibling
|
|
953
|
+
- [ ] Every page that fetches data has an \`error.tsx\` sibling
|
|
954
|
+
- [ ] Skeleton matches layout of loaded content (prevents layout shift)
|
|
955
|
+
`;
|
|
956
|
+
}
|
|
957
|
+
;
|
|
958
|
+
function apiPrompts(stack) {
|
|
959
|
+
return `# API Prompts
|
|
960
|
+
|
|
961
|
+
## New Route Handler
|
|
962
|
+
|
|
963
|
+
### Build
|
|
964
|
+
\`\`\`typescript
|
|
965
|
+
// app/api/posts/route.ts
|
|
966
|
+
import { auth } from '@clerk/nextjs/server';
|
|
967
|
+
import { z } from 'zod';
|
|
968
|
+
import { db } from '@/lib/db';
|
|
969
|
+
|
|
970
|
+
const CreatePostSchema = z.object({
|
|
971
|
+
title: z.string().min(1).max(200),
|
|
972
|
+
content: z.string().optional(),
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
export async function POST(req: Request) {
|
|
976
|
+
// 1. Auth
|
|
977
|
+
const { userId } = auth();
|
|
978
|
+
if (!userId) return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
|
979
|
+
|
|
980
|
+
// 2. Parse & validate
|
|
981
|
+
let body: unknown;
|
|
982
|
+
try { body = await req.json(); }
|
|
983
|
+
catch { return Response.json({ error: 'Invalid JSON' }, { status: 400 }); }
|
|
984
|
+
|
|
985
|
+
const parsed = CreatePostSchema.safeParse(body);
|
|
986
|
+
if (!parsed.success) {
|
|
987
|
+
return Response.json({ error: 'Validation failed', details: parsed.error.flatten() }, { status: 422 });
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// 3. Business logic
|
|
991
|
+
const post = await db.post.create({
|
|
992
|
+
data: { ...parsed.data, authorId: userId },
|
|
993
|
+
select: { id: true, title: true, createdAt: true },
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
return Response.json({ data: post }, { status: 201 });
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
export async function GET(req: Request) {
|
|
1000
|
+
const { userId } = auth();
|
|
1001
|
+
if (!userId) return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
|
1002
|
+
|
|
1003
|
+
const { searchParams } = new URL(req.url);
|
|
1004
|
+
const page = Number(searchParams.get('page') ?? 1) - 1;
|
|
1005
|
+
|
|
1006
|
+
const posts = await db.post.findMany({
|
|
1007
|
+
where: { authorId: userId, deletedAt: null },
|
|
1008
|
+
orderBy: { createdAt: 'desc' },
|
|
1009
|
+
take: 20,
|
|
1010
|
+
skip: page * 20,
|
|
1011
|
+
select: { id: true, title: true, published: true, createdAt: true },
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
return Response.json({ data: posts });
|
|
1015
|
+
}
|
|
1016
|
+
\`\`\`
|
|
1017
|
+
|
|
1018
|
+
### Verify
|
|
1019
|
+
- [ ] Auth check is first line (before JSON parsing)
|
|
1020
|
+
- [ ] JSON parse wrapped in try/catch
|
|
1021
|
+
- [ ] Zod validation with specific error response
|
|
1022
|
+
- [ ] Returns \`{ data: T }\` on success, \`{ error: string }\` on failure
|
|
1023
|
+
- [ ] Correct HTTP status codes (201 for create, 200 for get, 422 for validation, 401/403 for auth)
|
|
1024
|
+
- [ ] Only returns fields needed (\`select\`)
|
|
1025
|
+
|
|
1026
|
+
### Debug
|
|
1027
|
+
- 400 on valid request → JSON parse issue, check Content-Type header sent by client
|
|
1028
|
+
- Zod errors not showing → not returning \`parsed.error.flatten()\` in response
|
|
1029
|
+
- Auth passing but DB query wrong user's data → using \`userId\` from Clerk but querying with DB \`id\` (join on clerkId first)
|
|
1030
|
+
`;
|
|
1031
|
+
}
|
|
1032
|
+
;
|
|
1033
|
+
function deploymentPrompts(stack) {
|
|
1034
|
+
return `# Deployment Prompts
|
|
1035
|
+
|
|
1036
|
+
## Add Cron Job (Railway)
|
|
1037
|
+
|
|
1038
|
+
### Build
|
|
1039
|
+
1. In Railway dashboard: New Service → Cron
|
|
1040
|
+
2. Set schedule (standard cron syntax)
|
|
1041
|
+
3. Set command: \`curl -X GET https://yourapp.com/api/cron/job-name -H "x-cron-secret: $CRON_SECRET"\`
|
|
1042
|
+
|
|
1043
|
+
\`\`\`typescript
|
|
1044
|
+
// app/api/cron/[job]/route.ts
|
|
1045
|
+
export async function GET(req: Request, { params }: { params: { job: string } }) {
|
|
1046
|
+
// Auth
|
|
1047
|
+
const secret = req.headers.get('x-cron-secret');
|
|
1048
|
+
if (secret !== process.env.CRON_SECRET) {
|
|
1049
|
+
return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const startTime = Date.now();
|
|
1053
|
+
console.log(\`[CRON:\${params.job}] Starting\`);
|
|
1054
|
+
|
|
1055
|
+
try {
|
|
1056
|
+
switch (params.job) {
|
|
1057
|
+
case 'send-digests':
|
|
1058
|
+
await sendWeeklyDigests();
|
|
1059
|
+
break;
|
|
1060
|
+
case 'expire-trials':
|
|
1061
|
+
await expireTrials();
|
|
1062
|
+
break;
|
|
1063
|
+
default:
|
|
1064
|
+
return Response.json({ error: 'Unknown job' }, { status: 404 });
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
const duration = Date.now() - startTime;
|
|
1068
|
+
console.log(\`[CRON:\${params.job}] Done in \${duration}ms\`);
|
|
1069
|
+
return Response.json({ success: true, duration });
|
|
1070
|
+
} catch (error) {
|
|
1071
|
+
console.error(\`[CRON:\${params.job}] Failed\`, error);
|
|
1072
|
+
return Response.json({ error: 'Job failed' }, { status: 500 });
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
\`\`\`
|
|
1076
|
+
|
|
1077
|
+
### Verify
|
|
1078
|
+
- [ ] \`CRON_SECRET\` set in Railway env vars
|
|
1079
|
+
- [ ] Job is idempotent (safe to run multiple times)
|
|
1080
|
+
- [ ] Execution time logged
|
|
1081
|
+
- [ ] Error logged with full context
|
|
1082
|
+
- [ ] Route in public routes (not behind auth middleware)
|
|
1083
|
+
- [ ] Job name validated (no default fallthrough)
|
|
1084
|
+
|
|
1085
|
+
### Debug
|
|
1086
|
+
- 401 on cron → \`CRON_SECRET\` env var not set in Railway, or secret mismatch
|
|
1087
|
+
- Job runs but does nothing → check idempotency logic isn't skipping everything
|
|
1088
|
+
- Timeout → Railway cron has 10min timeout, split large jobs into batches
|
|
1089
|
+
|
|
1090
|
+
---
|
|
1091
|
+
|
|
1092
|
+
## New Environment Variable
|
|
1093
|
+
|
|
1094
|
+
### Build
|
|
1095
|
+
1. Add to \`.env.example\` (with fake value — never real credentials)
|
|
1096
|
+
2. Add to \`.env.local\` (real dev value — gitignored)
|
|
1097
|
+
3. Add to Railway dashboard under service → Variables
|
|
1098
|
+
4. Add to \`src/env.ts\` validation (if using t3-env or similar)
|
|
1099
|
+
|
|
1100
|
+
\`\`\`typescript
|
|
1101
|
+
// src/env.ts — validate all env vars at startup
|
|
1102
|
+
import { z } from 'zod';
|
|
1103
|
+
|
|
1104
|
+
const envSchema = z.object({
|
|
1105
|
+
DATABASE_URL: z.string().url(),
|
|
1106
|
+
CRON_SECRET: z.string().min(32),
|
|
1107
|
+
RESEND_API_KEY: z.string().startsWith('re_'),
|
|
1108
|
+
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
|
|
1109
|
+
STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
|
|
1110
|
+
NEXT_PUBLIC_APP_URL: z.string().url(),
|
|
1111
|
+
NEXT_PUBLIC_GA_MEASUREMENT_ID: z.string().startsWith('G-').optional(),
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
export const env = envSchema.parse(process.env);
|
|
1115
|
+
\`\`\`
|
|
1116
|
+
|
|
1117
|
+
### Verify
|
|
1118
|
+
- [ ] Added to \`.env.example\` with placeholder
|
|
1119
|
+
- [ ] Added to Railway dashboard (staging + prod environments separately if applicable)
|
|
1120
|
+
- [ ] Validated in \`src/env.ts\` startup check
|
|
1121
|
+
- [ ] \`NEXT_PUBLIC_\` prefix only for vars that must be client-accessible
|
|
1122
|
+
- [ ] Never committed real values to git
|
|
1123
|
+
|
|
1124
|
+
### Debug
|
|
1125
|
+
- Undefined in production → added to \`.env.local\` but not Railway dashboard
|
|
1126
|
+
- Client component getting undefined → missing \`NEXT_PUBLIC_\` prefix
|
|
1127
|
+
`;
|
|
1128
|
+
}
|
|
1129
|
+
;
|
|
1130
|
+
function emailPrompts(stack) {
|
|
1131
|
+
return `# Email Prompts
|
|
1132
|
+
|
|
1133
|
+
## Send Transactional Email
|
|
1134
|
+
|
|
1135
|
+
### Build
|
|
1136
|
+
\`\`\`typescript
|
|
1137
|
+
// src/lib/send-email.ts — wrapper with logging
|
|
1138
|
+
import { resend } from '@/lib/email';
|
|
1139
|
+
|
|
1140
|
+
interface SendEmailOptions {
|
|
1141
|
+
to: string;
|
|
1142
|
+
subject: string;
|
|
1143
|
+
react: React.ReactElement;
|
|
1144
|
+
userId?: string; // for logging
|
|
1145
|
+
type: string; // for logging
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
export async function sendEmail({ to, subject, react, userId, type }: SendEmailOptions) {
|
|
1149
|
+
try {
|
|
1150
|
+
const { data, error } = await resend.emails.send({
|
|
1151
|
+
from: process.env.EMAIL_FROM!,
|
|
1152
|
+
to,
|
|
1153
|
+
subject,
|
|
1154
|
+
react,
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
if (error) {
|
|
1158
|
+
console.error(\`[EMAIL:\${type}] Failed\`, { error, to, userId });
|
|
1159
|
+
return { success: false, error };
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
console.log(\`[EMAIL:\${type}] Sent\`, { id: data?.id, to, userId });
|
|
1163
|
+
return { success: true, id: data?.id };
|
|
1164
|
+
} catch (err) {
|
|
1165
|
+
console.error(\`[EMAIL:\${type}] Exception\`, { err, to, userId });
|
|
1166
|
+
return { success: false, error: err };
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// Usage — never block the main flow on email
|
|
1171
|
+
const emailResult = await sendEmail({
|
|
1172
|
+
to: user.email,
|
|
1173
|
+
subject: 'Welcome!',
|
|
1174
|
+
react: <WelcomeEmail name={user.name} />,
|
|
1175
|
+
userId: user.id,
|
|
1176
|
+
type: 'welcome',
|
|
1177
|
+
});
|
|
1178
|
+
// Continue even if email fails — log but don't throw
|
|
1179
|
+
\`\`\`
|
|
1180
|
+
|
|
1181
|
+
### Verify
|
|
1182
|
+
- [ ] Called from server only (Server Action, API route, webhook handler)
|
|
1183
|
+
- [ ] Never blocks the user-facing response — fire after main operation succeeds
|
|
1184
|
+
- [ ] Errors logged with context but not thrown
|
|
1185
|
+
- [ ] \`EMAIL_FROM\` uses env var (domain verified in Resend)
|
|
1186
|
+
- [ ] \`type\` field used for log filtering
|
|
1187
|
+
|
|
1188
|
+
### Debug
|
|
1189
|
+
- Emails not arriving → check Resend dashboard logs, verify domain DNS records
|
|
1190
|
+
- "From address not verified" → domain not verified in Resend, check DNS TXT/DKIM records
|
|
1191
|
+
- Email in spam → missing DKIM records, check Cloudflare DNS (must be grey cloud, not proxied)
|
|
1192
|
+
|
|
1193
|
+
---
|
|
1194
|
+
|
|
1195
|
+
## New Email Template
|
|
1196
|
+
|
|
1197
|
+
### Build
|
|
1198
|
+
\`\`\`typescript
|
|
1199
|
+
// src/emails/payment-failed.tsx
|
|
1200
|
+
import {
|
|
1201
|
+
Html, Head, Body, Container, Heading,
|
|
1202
|
+
Text, Button, Hr
|
|
1203
|
+
} from '@react-email/components';
|
|
1204
|
+
|
|
1205
|
+
interface PaymentFailedEmailProps {
|
|
1206
|
+
userName: string;
|
|
1207
|
+
updatePaymentUrl: string;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
export function PaymentFailedEmail({ userName, updatePaymentUrl }: PaymentFailedEmailProps) {
|
|
1211
|
+
return (
|
|
1212
|
+
<Html>
|
|
1213
|
+
<Head />
|
|
1214
|
+
<Body style={{ fontFamily: 'sans-serif', backgroundColor: '#f9f9f9' }}>
|
|
1215
|
+
<Container style={{ maxWidth: '560px', margin: '0 auto', padding: '40px 20px' }}>
|
|
1216
|
+
<Heading>Payment failed</Heading>
|
|
1217
|
+
<Text>Hi {userName},</Text>
|
|
1218
|
+
<Text>
|
|
1219
|
+
We couldn't process your payment. Please update your payment method
|
|
1220
|
+
to continue using the service.
|
|
1221
|
+
</Text>
|
|
1222
|
+
<Button href={updatePaymentUrl} style={{ backgroundColor: '#000', color: '#fff', padding: '12px 24px' }}>
|
|
1223
|
+
Update payment method
|
|
1224
|
+
</Button>
|
|
1225
|
+
<Hr />
|
|
1226
|
+
<Text style={{ fontSize: '12px', color: '#666' }}>
|
|
1227
|
+
If you have questions, reply to this email.
|
|
1228
|
+
</Text>
|
|
1229
|
+
</Container>
|
|
1230
|
+
</Body>
|
|
1231
|
+
</Html>
|
|
1232
|
+
);
|
|
1233
|
+
}
|
|
1234
|
+
\`\`\`
|
|
1235
|
+
|
|
1236
|
+
### Verify
|
|
1237
|
+
- [ ] Props are typed (no \`any\`)
|
|
1238
|
+
- [ ] Preview with \`email.react\` package: \`pnpm email dev\`
|
|
1239
|
+
- [ ] Mobile-friendly (single column, large tap targets)
|
|
1240
|
+
- [ ] Fallback plain text (Resend handles this automatically)
|
|
1241
|
+
- [ ] No tracking pixels unless consent obtained
|
|
1242
|
+
`;
|
|
1243
|
+
}
|
|
1244
|
+
;
|
|
1245
|
+
function analyticsPrompts(stack) {
|
|
1246
|
+
return `# Analytics Prompts
|
|
1247
|
+
|
|
1248
|
+
## Track Custom Event (GA4)
|
|
1249
|
+
|
|
1250
|
+
### Build
|
|
1251
|
+
\`\`\`typescript
|
|
1252
|
+
// src/lib/analytics.ts
|
|
1253
|
+
export function trackEvent(event: {
|
|
1254
|
+
action: string;
|
|
1255
|
+
category: string;
|
|
1256
|
+
label?: string;
|
|
1257
|
+
value?: number;
|
|
1258
|
+
}) {
|
|
1259
|
+
if (typeof window === 'undefined') return;
|
|
1260
|
+
if (!process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID) return;
|
|
1261
|
+
|
|
1262
|
+
window.gtag('event', event.action, {
|
|
1263
|
+
event_category: event.category,
|
|
1264
|
+
event_label: event.label,
|
|
1265
|
+
value: event.value,
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// Standard events — use these names for GA4 reports
|
|
1270
|
+
export const events = {
|
|
1271
|
+
signUp: (method: string) => trackEvent({ action: 'sign_up', category: 'auth', label: method }),
|
|
1272
|
+
login: (method: string) => trackEvent({ action: 'login', category: 'auth', label: method }),
|
|
1273
|
+
beginCheckout: (plan: string) => trackEvent({ action: 'begin_checkout', category: 'ecommerce', label: plan }),
|
|
1274
|
+
purchase: (plan: string, value: number) => trackEvent({ action: 'purchase', category: 'ecommerce', label: plan, value }),
|
|
1275
|
+
featureUsed: (feature: string) => trackEvent({ action: 'feature_used', category: 'engagement', label: feature }),
|
|
1276
|
+
cancelSubscription: () => trackEvent({ action: 'cancel_subscription', category: 'billing' }),
|
|
1277
|
+
};
|
|
1278
|
+
\`\`\`
|
|
1279
|
+
|
|
1280
|
+
### Verify
|
|
1281
|
+
- [ ] \`typeof window !== 'undefined'\` guard (runs in Server Components too)
|
|
1282
|
+
- [ ] Event names match GA4 recommended event names where possible
|
|
1283
|
+
- [ ] No PII in event labels (no emails, names, IDs)
|
|
1284
|
+
- [ ] Only fires if GA Measurement ID is present (safe in preview/dev)
|
|
1285
|
+
|
|
1286
|
+
### Debug
|
|
1287
|
+
- Events not showing in GA4 → check real-time report in GA4 (takes up to 24h for standard reports)
|
|
1288
|
+
- Blocked by ad blocker → use GA4 Measurement Protocol for server-side events instead
|
|
1289
|
+
- \`gtag is not defined\` → GA script not loaded, check layout.tsx Script tags
|
|
1290
|
+
`;
|
|
1291
|
+
}
|
|
1292
|
+
;
|
|
1293
|
+
function securityPrompts(stack) {
|
|
1294
|
+
return `# Security Prompts
|
|
1295
|
+
|
|
1296
|
+
## Input Validation (All Entry Points)
|
|
1297
|
+
|
|
1298
|
+
### Build
|
|
1299
|
+
\`\`\`typescript
|
|
1300
|
+
// Every API route and Server Action validates ALL inputs with Zod
|
|
1301
|
+
// Never trust: req.body, req.query, req.params, formData, searchParams
|
|
1302
|
+
|
|
1303
|
+
import { z } from 'zod';
|
|
1304
|
+
|
|
1305
|
+
// Strict schemas — reject anything unexpected
|
|
1306
|
+
const schema = z.object({
|
|
1307
|
+
email: z.string().email().toLowerCase(),
|
|
1308
|
+
name: z.string().min(1).max(100).trim(),
|
|
1309
|
+
amount: z.number().int().positive().max(100_000), // cents
|
|
1310
|
+
role: z.enum(['user', 'viewer']), // never accept 'admin' from input
|
|
1311
|
+
});
|
|
1312
|
+
|
|
1313
|
+
// File uploads
|
|
1314
|
+
const fileSchema = z.object({
|
|
1315
|
+
size: z.number().max(5 * 1024 * 1024), // 5MB max
|
|
1316
|
+
type: z.enum(['image/jpeg', 'image/png', 'image/webp']),
|
|
1317
|
+
});
|
|
1318
|
+
\`\`\`
|
|
1319
|
+
|
|
1320
|
+
### Verify
|
|
1321
|
+
- [ ] All API routes validate with Zod
|
|
1322
|
+
- [ ] Roles can never be escalated via input (never accept \`role: 'admin'\` from request)
|
|
1323
|
+
- [ ] String lengths bounded (prevents oversized payload attacks)
|
|
1324
|
+
- [ ] Email lowercased and trimmed
|
|
1325
|
+
- [ ] File uploads: size + mime type validated server-side
|
|
1326
|
+
|
|
1327
|
+
---
|
|
1328
|
+
|
|
1329
|
+
## Secure Direct Object References
|
|
1330
|
+
|
|
1331
|
+
### Build
|
|
1332
|
+
\`\`\`typescript
|
|
1333
|
+
// ALWAYS scope queries to the current user's data
|
|
1334
|
+
// Never trust a resource ID without verifying ownership
|
|
1335
|
+
|
|
1336
|
+
// ❌ Wrong — IDOR vulnerability
|
|
1337
|
+
const post = await db.post.findUnique({ where: { id: postId } });
|
|
1338
|
+
|
|
1339
|
+
// ✅ Correct — always include userId in where clause
|
|
1340
|
+
const post = await db.post.findUnique({
|
|
1341
|
+
where: { id: postId, authorId: userId }, // fails if not owner
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
if (!post) return Response.json({ error: 'Not found' }, { status: 404 });
|
|
1345
|
+
// Note: return 404, not 403 — don't reveal resource existence
|
|
1346
|
+
\`\`\`
|
|
1347
|
+
|
|
1348
|
+
### Verify
|
|
1349
|
+
- [ ] Every query for user-owned data includes \`userId\` in where clause
|
|
1350
|
+
- [ ] Returns 404 (not 403) when resource not found or not owned
|
|
1351
|
+
- [ ] Admin endpoints have separate admin-only middleware
|
|
1352
|
+
|
|
1353
|
+
---
|
|
1354
|
+
|
|
1355
|
+
## Rate Limiting
|
|
1356
|
+
|
|
1357
|
+
### Build
|
|
1358
|
+
\`\`\`typescript
|
|
1359
|
+
// Use Cloudflare for coarse rate limiting (set in dashboard)
|
|
1360
|
+
// Use in-memory or Upstash Redis for fine-grained app-level limits
|
|
1361
|
+
|
|
1362
|
+
// Simple in-memory rate limiter (good for low traffic)
|
|
1363
|
+
const rateLimit = new Map<string, { count: number; resetAt: number }>();
|
|
1364
|
+
|
|
1365
|
+
export function checkRateLimit(key: string, limit: number, windowMs: number): boolean {
|
|
1366
|
+
const now = Date.now();
|
|
1367
|
+
const entry = rateLimit.get(key);
|
|
1368
|
+
|
|
1369
|
+
if (!entry || now > entry.resetAt) {
|
|
1370
|
+
rateLimit.set(key, { count: 1, resetAt: now + windowMs });
|
|
1371
|
+
return true; // allowed
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
if (entry.count >= limit) return false; // blocked
|
|
1375
|
+
|
|
1376
|
+
entry.count++;
|
|
1377
|
+
return true; // allowed
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// Usage in API route
|
|
1381
|
+
const allowed = checkRateLimit(\`auth:\${ip}\`, 5, 60_000); // 5 per minute
|
|
1382
|
+
if (!allowed) {
|
|
1383
|
+
return Response.json({ error: 'Too many requests' }, { status: 429 });
|
|
1384
|
+
}
|
|
1385
|
+
\`\`\`
|
|
1386
|
+
|
|
1387
|
+
### Verify
|
|
1388
|
+
- [ ] Auth endpoints rate limited (sign-in, magic link, password reset)
|
|
1389
|
+
- [ ] Rate limit keyed by IP (for unauthed) or userId (for authed)
|
|
1390
|
+
- [ ] 429 returned with \`Retry-After\` header when possible
|
|
1391
|
+
- [ ] Cloudflare WAF rules set for aggressive bots (see cloudflare.md)
|
|
1392
|
+
`;
|
|
1393
|
+
}
|
|
1394
|
+
;
|
|
1395
|
+
// ---------------------------------------------------------------------------
|
|
1396
|
+
// Skills
|
|
1397
|
+
// ---------------------------------------------------------------------------
|
|
1398
|
+
function frontendSkill() {
|
|
1399
|
+
return `# SKILL: Frontend Engineer
|
|
1400
|
+
|
|
1401
|
+
> Load this at the start of a UI-heavy session.
|
|
1402
|
+
> Tell Claude: "Enter frontend mode" or "Load /ai-docs/skills/frontend.md"
|
|
1403
|
+
|
|
1404
|
+
## Persona
|
|
1405
|
+
You are a senior frontend engineer. You think in components, user experience, and performance. You write clean, accessible, maintainable React code.
|
|
1406
|
+
|
|
1407
|
+
## Priorities (in order)
|
|
1408
|
+
1. **Correctness** — does it work for all states (loading, error, empty, populated)?
|
|
1409
|
+
2. **Accessibility** — keyboard nav, ARIA labels, focus management
|
|
1410
|
+
3. **Performance** — no unnecessary re-renders, lazy load heavy components
|
|
1411
|
+
4. **Simplicity** — fewest components needed, no over-engineering
|
|
1412
|
+
|
|
1413
|
+
## Defaults
|
|
1414
|
+
- Server Components unless you need client interactivity
|
|
1415
|
+
- Tailwind for all styling — never inline styles, never CSS modules
|
|
1416
|
+
- \`cn()\` (clsx + tailwind-merge) for conditional classes
|
|
1417
|
+
- \`useTransition\` for Server Actions (not \`useState\` + \`useEffect\` workarounds)
|
|
1418
|
+
- URL state for filters/pagination (not useState)
|
|
1419
|
+
- Zod validation in Server Actions — not just client-side
|
|
1420
|
+
|
|
1421
|
+
## Component Checklist (before finishing)
|
|
1422
|
+
- [ ] Loading state handled
|
|
1423
|
+
- [ ] Error state handled
|
|
1424
|
+
- [ ] Empty state handled
|
|
1425
|
+
- [ ] Mobile responsive (test at 375px)
|
|
1426
|
+
- [ ] Keyboard navigable
|
|
1427
|
+
- [ ] No hardcoded colors or spacing — Tailwind tokens only
|
|
1428
|
+
- [ ] TypeScript types complete (no \`any\`)
|
|
1429
|
+
|
|
1430
|
+
## Common Mistakes to Avoid
|
|
1431
|
+
- \`useEffect\` to fetch data → use Server Component or React Query instead
|
|
1432
|
+
- useState for URL-based state → use searchParams
|
|
1433
|
+
- Prop drilling more than 2 levels → use context or Zustand
|
|
1434
|
+
- \`className\` string concatenation → use \`cn()\`
|
|
1435
|
+
- Missing \`key\` prop on lists
|
|
1436
|
+
- onClick on div → use button for accessibility
|
|
1437
|
+
|
|
1438
|
+
## Token-Saving Mode
|
|
1439
|
+
When working on UI tasks:
|
|
1440
|
+
- Write the component first, then ask if you want tests
|
|
1441
|
+
- Don't explain every Tailwind class — just write them
|
|
1442
|
+
- Skip boilerplate comments unless asked
|
|
1443
|
+
- If the pattern is in /ai-docs/prompts/ui.md, follow it exactly — don't improvise
|
|
1444
|
+
`;
|
|
1445
|
+
}
|
|
1446
|
+
function backendSkill() {
|
|
1447
|
+
return `# SKILL: Backend Engineer
|
|
1448
|
+
|
|
1449
|
+
> Load this for API routes, database work, Server Actions, background jobs.
|
|
1450
|
+
> Tell Claude: "Enter backend mode" or "Load /ai-docs/skills/backend.md"
|
|
1451
|
+
|
|
1452
|
+
## Persona
|
|
1453
|
+
You are a senior backend engineer. You think in data flow, correctness, and reliability. You write APIs that are secure by default, handle errors explicitly, and never trust user input.
|
|
1454
|
+
|
|
1455
|
+
## Priorities (in order)
|
|
1456
|
+
1. **Security** — auth checked, input validated, ownership verified
|
|
1457
|
+
2. **Correctness** — handles all error states, atomic writes, idempotent operations
|
|
1458
|
+
3. **Performance** — indexed queries, paginated results, no N+1
|
|
1459
|
+
4. **Simplicity** — no premature abstraction, no over-engineering
|
|
1460
|
+
|
|
1461
|
+
## Defaults
|
|
1462
|
+
- Validate everything with Zod — API routes AND Server Actions
|
|
1463
|
+
- Auth check is ALWAYS the first line of any handler
|
|
1464
|
+
- Transactions for multi-step writes
|
|
1465
|
+
- Soft deletes (deletedAt) for user data
|
|
1466
|
+
- Always paginate — never unbounded queries
|
|
1467
|
+
- Structured logging: \`console.error('[CONTEXT]', { userId, operation, error })\`
|
|
1468
|
+
|
|
1469
|
+
## Response Shape (always consistent)
|
|
1470
|
+
\`\`\`typescript
|
|
1471
|
+
// Success
|
|
1472
|
+
{ data: T, error: null } // or just Response.json({ data: T })
|
|
1473
|
+
|
|
1474
|
+
// Error
|
|
1475
|
+
{ data: null, error: { message: string, code: string } }
|
|
1476
|
+
\`\`\`
|
|
1477
|
+
|
|
1478
|
+
## HTTP Status Codes
|
|
1479
|
+
- 200: success (GET, PUT, PATCH)
|
|
1480
|
+
- 201: created (POST)
|
|
1481
|
+
- 400: bad request (malformed JSON)
|
|
1482
|
+
- 401: unauthenticated (no session)
|
|
1483
|
+
- 403: unauthorized (wrong permissions / not owner)
|
|
1484
|
+
- 404: not found (use this even for "forbidden" to avoid IDOR)
|
|
1485
|
+
- 422: validation error (Zod failed)
|
|
1486
|
+
- 429: rate limited
|
|
1487
|
+
- 500: server error
|
|
1488
|
+
|
|
1489
|
+
## Security Checklist (every endpoint)
|
|
1490
|
+
- [ ] Auth checked before any DB access
|
|
1491
|
+
- [ ] Input validated with Zod
|
|
1492
|
+
- [ ] Resource ownership verified (userId in where clause)
|
|
1493
|
+
- [ ] Returns 404 (not 403) for not-owned resources
|
|
1494
|
+
|
|
1495
|
+
## Token-Saving Mode
|
|
1496
|
+
- Write the handler, then verify checklist items
|
|
1497
|
+
- Don't explain what each Zod field does
|
|
1498
|
+
- Follow /ai-docs/prompts/api.md patterns exactly
|
|
1499
|
+
- If it's a webhook, check /ai-docs/prompts/payments.md first
|
|
1500
|
+
`;
|
|
1501
|
+
}
|
|
1502
|
+
function devopsSkill() {
|
|
1503
|
+
return `# SKILL: DevOps / Infrastructure
|
|
1504
|
+
|
|
1505
|
+
> Load this for deployment, env vars, cron jobs, Railway config, Cloudflare.
|
|
1506
|
+
> Tell Claude: "Enter devops mode" or "Load /ai-docs/skills/devops.md"
|
|
1507
|
+
|
|
1508
|
+
## Persona
|
|
1509
|
+
You are a senior DevOps engineer. You think in reliability, observability, and zero-downtime deploys. You treat configuration as code.
|
|
1510
|
+
|
|
1511
|
+
## Stack
|
|
1512
|
+
- **Hosting:** Railway (web + postgres + cron)
|
|
1513
|
+
- **CDN/DNS:** Cloudflare
|
|
1514
|
+
- **Email:** Resend (transactional)
|
|
1515
|
+
- **Analytics:** GA4 + Google Search Console
|
|
1516
|
+
|
|
1517
|
+
## Deployment Principles
|
|
1518
|
+
- Every env var validated at startup (fail fast, not silently)
|
|
1519
|
+
- Migrations run before the app starts (not after)
|
|
1520
|
+
- Health check at \`/api/health\` always available
|
|
1521
|
+
- Never store secrets in code or logs
|
|
1522
|
+
|
|
1523
|
+
## Railway Checklist
|
|
1524
|
+
- [ ] \`DATABASE_URL\` linked from Railway PostgreSQL plugin (not hardcoded)
|
|
1525
|
+
- [ ] Start command includes migrations: \`pnpm db:migrate && pnpm start\`
|
|
1526
|
+
- [ ] Health check configured in Railway service settings
|
|
1527
|
+
- [ ] Custom domain added + SSL auto-generated
|
|
1528
|
+
- [ ] Cloudflare DNS pointing to Railway URL (proxied)
|
|
1529
|
+
|
|
1530
|
+
## Cloudflare Checklist
|
|
1531
|
+
- [ ] SSL mode: Full (strict)
|
|
1532
|
+
- [ ] Cache rules set (static assets cached, /api/* bypassed)
|
|
1533
|
+
- [ ] Mail DNS records NOT proxied (Resend DKIM breaks behind Cloudflare proxy)
|
|
1534
|
+
- [ ] Security rules: rate limit /api/auth/signin
|
|
1535
|
+
|
|
1536
|
+
## Cron Job Checklist
|
|
1537
|
+
- [ ] CRON_SECRET set (min 32 chars)
|
|
1538
|
+
- [ ] Job is idempotent
|
|
1539
|
+
- [ ] Job has timeout handling (Railway max: 10 min)
|
|
1540
|
+
- [ ] Job logs start time + duration
|
|
1541
|
+
- [ ] Railway cron service pointed at correct URL
|
|
1542
|
+
|
|
1543
|
+
## Env Var Naming Convention
|
|
1544
|
+
\`\`\`
|
|
1545
|
+
DATABASE_URL # always
|
|
1546
|
+
CRON_SECRET # for cron auth
|
|
1547
|
+
STRIPE_SECRET_KEY # sk_test_... / sk_live_...
|
|
1548
|
+
STRIPE_WEBHOOK_SECRET # whsec_...
|
|
1549
|
+
RESEND_API_KEY # re_...
|
|
1550
|
+
CLERK_SECRET_KEY # sk_...
|
|
1551
|
+
CLERK_WEBHOOK_SECRET # whsec_...
|
|
1552
|
+
NEXT_PUBLIC_APP_URL # https://yourapp.com
|
|
1553
|
+
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY # pk_...
|
|
1554
|
+
NEXT_PUBLIC_GA_MEASUREMENT_ID # G-XXXXXXX
|
|
1555
|
+
CLOUDFLARE_ZONE_ID # for cache purge
|
|
1556
|
+
CLOUDFLARE_API_TOKEN # scoped to cache purge only
|
|
1557
|
+
\`\`\`
|
|
1558
|
+
|
|
1559
|
+
## Token-Saving Mode
|
|
1560
|
+
- Follow /ai-docs/deployment.md exactly
|
|
1561
|
+
- Don't explain Railway's UI — give the config values
|
|
1562
|
+
- List checklist items, don't prose-explain each one
|
|
1563
|
+
`;
|
|
1564
|
+
}
|
|
1565
|
+
function reviewerSkill() {
|
|
1566
|
+
return `# SKILL: Code Reviewer
|
|
1567
|
+
|
|
1568
|
+
> Load this when you want Claude to critically review code before shipping.
|
|
1569
|
+
> Tell Claude: "Review this as a senior engineer" or "Load /ai-docs/skills/reviewer.md"
|
|
1570
|
+
|
|
1571
|
+
## Persona
|
|
1572
|
+
You are a principal engineer doing a pre-ship code review. You are thorough, specific, and constructive. You catch what junior engineers miss.
|
|
1573
|
+
|
|
1574
|
+
## Review Order
|
|
1575
|
+
1. **Security holes** — auth missing, IDOR, unvalidated input, secret exposure
|
|
1576
|
+
2. **Correctness** — edge cases, error handling, race conditions
|
|
1577
|
+
3. **Performance** — N+1 queries, missing indexes, unbounded queries
|
|
1578
|
+
4. **Maintainability** — naming, complexity, duplication
|
|
1579
|
+
5. **Style** — only if everything above is clean
|
|
1580
|
+
|
|
1581
|
+
## Security Red Flags (always call out)
|
|
1582
|
+
- Resource fetched without scoping to userId
|
|
1583
|
+
- Missing auth check in API route or Server Action
|
|
1584
|
+
- \`any\` type hiding a validation gap
|
|
1585
|
+
- User input used in DB query without Zod first
|
|
1586
|
+
- Secrets hardcoded or logged
|
|
1587
|
+
|
|
1588
|
+
## Correctness Red Flags
|
|
1589
|
+
- Missing loading/error/empty states
|
|
1590
|
+
- \`useEffect\` with missing dependencies
|
|
1591
|
+
- Mutation without \`revalidatePath\` (stale cache)
|
|
1592
|
+
- Webhook without idempotency check
|
|
1593
|
+
- Multi-step write without transaction
|
|
1594
|
+
|
|
1595
|
+
## Output Format
|
|
1596
|
+
For each issue:
|
|
1597
|
+
\`\`\`
|
|
1598
|
+
🔴 CRITICAL (Security/Data loss): [issue] → [fix]
|
|
1599
|
+
🟡 WARNING (Correctness/Performance): [issue] → [fix]
|
|
1600
|
+
🟢 SUGGESTION (Style/Maintainability): [issue] → [fix]
|
|
1601
|
+
\`\`\`
|
|
1602
|
+
|
|
1603
|
+
Only show 🟢 if there are no 🔴 or 🟡 issues.
|
|
1604
|
+
Stop at 🔴 — fix security issues before reviewing anything else.
|
|
1605
|
+
|
|
1606
|
+
## Token-Saving Mode
|
|
1607
|
+
- Lead with the most critical issue
|
|
1608
|
+
- Don't compliment the code
|
|
1609
|
+
- Don't explain why security matters — just state the issue and fix
|
|
1610
|
+
- If code is clean, say "Looks good — no issues found" and stop
|
|
1611
|
+
`;
|
|
1612
|
+
}
|
|
1613
|
+
// ---------------------------------------------------------------------------
|
|
1614
|
+
// Extras (Security, Handover, Mistakes, Token Rules)
|
|
1615
|
+
// ---------------------------------------------------------------------------
|
|
1616
|
+
function securityDoc(stack) {
|
|
1617
|
+
return `# Security
|
|
1618
|
+
|
|
1619
|
+
## OWASP Top 10 — Applied to This Stack
|
|
1620
|
+
|
|
1621
|
+
### A01 Broken Access Control
|
|
1622
|
+
**Risk:** User accesses another user's data by guessing IDs
|
|
1623
|
+
**Protection:**
|
|
1624
|
+
- Always include \`userId\` in DB queries for user-owned data
|
|
1625
|
+
- Return 404 (not 403) when resource not owned — don't reveal existence
|
|
1626
|
+
- Admin routes protected by role check, not just auth check
|
|
1627
|
+
- Test: log in as user A, try to access user B's resource by ID
|
|
1628
|
+
|
|
1629
|
+
### A02 Cryptographic Failures
|
|
1630
|
+
**Risk:** Sensitive data exposed in logs, responses, or storage
|
|
1631
|
+
**Protection:**
|
|
1632
|
+
- Never log: passwords, tokens, full credit card numbers, SSNs
|
|
1633
|
+
- Never return: password hashes, internal IDs, raw Stripe keys
|
|
1634
|
+
- DB connection over SSL (Railway enforces this)
|
|
1635
|
+
- Env vars for all secrets — never in code
|
|
1636
|
+
|
|
1637
|
+
### A03 Injection
|
|
1638
|
+
**Risk:** SQL injection via unsanitized input
|
|
1639
|
+
**Protection:** Prisma/Drizzle parameterize all queries automatically
|
|
1640
|
+
**Watch out for:** Raw SQL with template literals — use \`prisma.$queryRaw\` tag safely:
|
|
1641
|
+
\`\`\`typescript
|
|
1642
|
+
// ❌ Vulnerable
|
|
1643
|
+
prisma.$queryRawUnsafe(\`SELECT * FROM users WHERE id = '\${userId}'\`);
|
|
1644
|
+
|
|
1645
|
+
// ✅ Safe
|
|
1646
|
+
prisma.$queryRaw\`SELECT * FROM users WHERE id = \${userId}\`;
|
|
1647
|
+
\`\`\`
|
|
1648
|
+
|
|
1649
|
+
### A04 Insecure Design
|
|
1650
|
+
**Risk:** Business logic flaws (negative balances, skipping payment, etc.)
|
|
1651
|
+
**Protection:**
|
|
1652
|
+
- Validate business rules server-side: check credits before spending, check subscription before access
|
|
1653
|
+
- Use transactions for multi-step operations (can't spend credits without creating the job)
|
|
1654
|
+
- Never trust client on anything financial
|
|
1655
|
+
|
|
1656
|
+
### A05 Security Misconfiguration
|
|
1657
|
+
**Risk:** Debug info exposed, unnecessary features enabled
|
|
1658
|
+
**Checklist:**
|
|
1659
|
+
- [ ] \`NODE_ENV=production\` in Railway
|
|
1660
|
+
- [ ] No \`.env\` files committed
|
|
1661
|
+
- [ ] Error responses don't expose stack traces (check production error responses)
|
|
1662
|
+
- [ ] Stripe in live mode (not test mode) in production
|
|
1663
|
+
- [ ] Clerk in production instance (not development)
|
|
1664
|
+
|
|
1665
|
+
### A07 Identification & Authentication Failures
|
|
1666
|
+
**Risk:** Brute force, session fixation, credential stuffing
|
|
1667
|
+
**Protection:**
|
|
1668
|
+
- Clerk handles session management (don't roll your own)
|
|
1669
|
+
- Rate limit auth endpoints: 5 attempts per minute per IP (Cloudflare rule)
|
|
1670
|
+
- Magic link / OTP expiry: 15 minutes max
|
|
1671
|
+
- Never store session tokens in localStorage
|
|
1672
|
+
|
|
1673
|
+
### A08 Software & Data Integrity
|
|
1674
|
+
**Risk:** Supply chain attacks via npm packages
|
|
1675
|
+
**Protection:**
|
|
1676
|
+
- Lock file committed (\`pnpm-lock.yaml\`)
|
|
1677
|
+
- Stripe webhook signature verified before processing
|
|
1678
|
+
- Clerk webhook (Svix) signature verified before processing
|
|
1679
|
+
|
|
1680
|
+
### A09 Security Logging & Monitoring
|
|
1681
|
+
**What to log:**
|
|
1682
|
+
\`\`\`typescript
|
|
1683
|
+
// Auth events
|
|
1684
|
+
console.log('[AUTH]', { event: 'sign_in', userId, ip, method });
|
|
1685
|
+
console.error('[AUTH_FAIL]', { event: 'invalid_token', ip });
|
|
1686
|
+
|
|
1687
|
+
// Financial events
|
|
1688
|
+
console.log('[BILLING]', { event: 'subscription_created', userId, plan, stripeSubId });
|
|
1689
|
+
console.error('[BILLING_FAIL]', { event: 'payment_failed', userId, invoiceId });
|
|
1690
|
+
|
|
1691
|
+
// Security events
|
|
1692
|
+
console.error('[SECURITY]', { event: 'invalid_webhook_sig', source: 'stripe', ip });
|
|
1693
|
+
console.error('[SECURITY]', { event: 'rate_limit_exceeded', ip, endpoint });
|
|
1694
|
+
\`\`\`
|
|
1695
|
+
|
|
1696
|
+
---
|
|
1697
|
+
|
|
1698
|
+
## STRIDE Threat Model
|
|
1699
|
+
|
|
1700
|
+
| Threat | Example | Mitigation |
|
|
1701
|
+
|--------|---------|------------|
|
|
1702
|
+
| **S**poofing | Faking another user's session | Clerk session management, verify userId server-side |
|
|
1703
|
+
| **T**ampering | Modifying request body to escalate role | Zod validation, never accept role from input |
|
|
1704
|
+
| **R**epudiation | User denies making a purchase | Stripe webhook log + DB WebhookEvent table |
|
|
1705
|
+
| **I**nfo Disclosure | Error leaks stack trace or DB schema | Generic error messages in production |
|
|
1706
|
+
| **D**oS | Flood API with requests | Cloudflare rate limiting + app-level rate limiter |
|
|
1707
|
+
| **E**levation | User accesses admin endpoints | Role check in admin middleware, not just auth |
|
|
1708
|
+
|
|
1709
|
+
---
|
|
1710
|
+
|
|
1711
|
+
## Pre-Ship Security Checklist
|
|
1712
|
+
|
|
1713
|
+
### Authentication
|
|
1714
|
+
- [ ] All protected routes behind auth middleware
|
|
1715
|
+
- [ ] Webhook endpoints excluded from auth middleware
|
|
1716
|
+
- [ ] No auth secrets in client-side code (\`NEXT_PUBLIC_\`)
|
|
1717
|
+
|
|
1718
|
+
### Authorization
|
|
1719
|
+
- [ ] Every DB query for user data scoped to \`userId\`
|
|
1720
|
+
- [ ] Admin-only operations check \`role === 'admin'\`
|
|
1721
|
+
- [ ] Resource not found = 404, not 403
|
|
1722
|
+
|
|
1723
|
+
### Input
|
|
1724
|
+
- [ ] All API routes validate with Zod
|
|
1725
|
+
- [ ] All Server Actions validate with Zod
|
|
1726
|
+
- [ ] File uploads: size + mime type validated
|
|
1727
|
+
|
|
1728
|
+
### Financial
|
|
1729
|
+
- [ ] Stripe webhook signature verified
|
|
1730
|
+
- [ ] Checkout session created server-side only
|
|
1731
|
+
- [ ] Subscription status checked from DB, never client
|
|
1732
|
+
|
|
1733
|
+
### Secrets
|
|
1734
|
+
- [ ] \`.env\` in \`.gitignore\`
|
|
1735
|
+
- [ ] No secrets in client bundle
|
|
1736
|
+
- [ ] All secrets in Railway env vars
|
|
1737
|
+
`;
|
|
1738
|
+
}
|
|
1739
|
+
function handoverTemplate() {
|
|
1740
|
+
return `# HANDOVER.md — Session Handover
|
|
1741
|
+
|
|
1742
|
+
> Claude writes this at the end of a long session (or when asked).
|
|
1743
|
+
> The next session loads this file to resume instantly without re-exploration.
|
|
1744
|
+
> Command: "Write a handover doc for this session"
|
|
1745
|
+
|
|
1746
|
+
---
|
|
1747
|
+
|
|
1748
|
+
## Session Date
|
|
1749
|
+
[Date]
|
|
1750
|
+
|
|
1751
|
+
## What We Were Building
|
|
1752
|
+
[1-2 sentence description of the feature/fix being worked on]
|
|
1753
|
+
|
|
1754
|
+
## Current Status
|
|
1755
|
+
- [ ] **In progress** / **Completed** / **Blocked**
|
|
1756
|
+
- [What is done]
|
|
1757
|
+
- [What is NOT done yet]
|
|
1758
|
+
|
|
1759
|
+
## Files Changed This Session
|
|
1760
|
+
\`\`\`
|
|
1761
|
+
src/app/dashboard/posts/page.tsx - Added pagination
|
|
1762
|
+
src/actions/posts.ts - Added createPost Server Action
|
|
1763
|
+
src/components/PostList.tsx - New component
|
|
1764
|
+
prisma/schema.prisma - Added Post model
|
|
1765
|
+
\`\`\`
|
|
1766
|
+
|
|
1767
|
+
## Next Steps (in order)
|
|
1768
|
+
1. [First thing to do next session]
|
|
1769
|
+
2. [Second thing]
|
|
1770
|
+
3. [Third thing]
|
|
1771
|
+
|
|
1772
|
+
## Decisions Made
|
|
1773
|
+
- [Decision]: [Reasoning] — e.g. "Used Server Actions over API routes: simpler, fewer files"
|
|
1774
|
+
- [Decision]: [Reasoning]
|
|
1775
|
+
|
|
1776
|
+
## Blockers / Questions
|
|
1777
|
+
- [Any unresolved question or blocker]
|
|
1778
|
+
|
|
1779
|
+
## Context to Load Next Session
|
|
1780
|
+
\`\`\`
|
|
1781
|
+
Read CLAUDE.md
|
|
1782
|
+
Read ai-docs/[relevant domain].md
|
|
1783
|
+
Read ai-docs/prompts/[relevant category].md
|
|
1784
|
+
Read HANDOVER.md
|
|
1785
|
+
Then continue from: [exact next step]
|
|
1786
|
+
\`\`\`
|
|
1787
|
+
|
|
1788
|
+
---
|
|
1789
|
+
*Tip: Start next session with "Load HANDOVER.md and continue where we left off"*
|
|
1790
|
+
`;
|
|
1791
|
+
}
|
|
1792
|
+
function mistakesTemplate() {
|
|
1793
|
+
return `# MISTAKES.md — Learn From These
|
|
1794
|
+
|
|
1795
|
+
> Before starting any task, check if it's listed here.
|
|
1796
|
+
> Claude: if you're about to do something in this list, stop and do it the right way instead.
|
|
1797
|
+
> Add new mistakes here when they're discovered.
|
|
1798
|
+
|
|
1799
|
+
## How to Add an Entry
|
|
1800
|
+
When Claude makes a mistake, add:
|
|
1801
|
+
\`\`\`
|
|
1802
|
+
### [Short title]
|
|
1803
|
+
**Mistake:** What was done wrong
|
|
1804
|
+
**Why it fails:** What breaks or why it's wrong
|
|
1805
|
+
**Correct approach:** What to do instead
|
|
1806
|
+
\`\`\`
|
|
1807
|
+
|
|
1808
|
+
---
|
|
1809
|
+
|
|
1810
|
+
## Auth
|
|
1811
|
+
|
|
1812
|
+
### Using req.json() in Stripe Webhook Handler
|
|
1813
|
+
**Mistake:** \`const body = await req.json()\`
|
|
1814
|
+
**Why it fails:** Parses body before signature verification, destroys raw body — always 400
|
|
1815
|
+
**Correct approach:** \`const body = await req.text()\` — then verify signature, then parse
|
|
1816
|
+
|
|
1817
|
+
### Redirecting API Routes Instead of Returning 401
|
|
1818
|
+
**Mistake:** \`redirect('/sign-in')\` in an API route handler
|
|
1819
|
+
**Why it fails:** Client gets a redirect response, not JSON — breaks fetch() callers
|
|
1820
|
+
**Correct approach:** \`return Response.json({ error: 'Unauthorized' }, { status: 401 })\`
|
|
1821
|
+
|
|
1822
|
+
---
|
|
1823
|
+
|
|
1824
|
+
## Database
|
|
1825
|
+
|
|
1826
|
+
### Forgetting to Run prisma generate After Schema Change
|
|
1827
|
+
**Mistake:** Changing \`schema.prisma\` and running the app without regenerating client
|
|
1828
|
+
**Why it fails:** TypeScript types don't match new schema, runtime errors on new fields
|
|
1829
|
+
**Correct approach:** Always run \`pnpm prisma generate\` after any schema change
|
|
1830
|
+
|
|
1831
|
+
### Unbounded Queries
|
|
1832
|
+
**Mistake:** \`db.post.findMany()\` with no \`take\` limit
|
|
1833
|
+
**Why it fails:** Returns all rows — kills performance, high DB cost
|
|
1834
|
+
**Correct approach:** Always add \`take: 20\` and pagination
|
|
1835
|
+
|
|
1836
|
+
---
|
|
1837
|
+
|
|
1838
|
+
## Payments
|
|
1839
|
+
|
|
1840
|
+
### Wrong Webhook Secret
|
|
1841
|
+
**Mistake:** Using Stripe API key as webhook secret
|
|
1842
|
+
**Why it fails:** \`sk_...\` is the API key — webhook secret is \`whsec_...\` from the webhook endpoint page
|
|
1843
|
+
**Correct approach:** Get the secret from Stripe Dashboard → Webhooks → your endpoint → Signing secret
|
|
1844
|
+
|
|
1845
|
+
---
|
|
1846
|
+
|
|
1847
|
+
## Deployment
|
|
1848
|
+
|
|
1849
|
+
### Adding Env Var to .env.local But Not Railway
|
|
1850
|
+
**Mistake:** Works locally, undefined in production
|
|
1851
|
+
**Why it fails:** Railway has its own env var store, separate from local files
|
|
1852
|
+
**Correct approach:** Add to Railway dashboard (service → Variables) AND .env.example
|
|
1853
|
+
|
|
1854
|
+
---
|
|
1855
|
+
|
|
1856
|
+
## Next.js
|
|
1857
|
+
|
|
1858
|
+
### Calling currentUser() When Only userId Is Needed
|
|
1859
|
+
**Mistake:** \`const user = await currentUser()\` just to get the ID
|
|
1860
|
+
**Why it fails:** Makes an extra network request to Clerk on every request — wastes time + money
|
|
1861
|
+
**Correct approach:** \`const { userId } = auth()\` — only call \`currentUser()\` when you need name/email/etc.
|
|
1862
|
+
|
|
1863
|
+
### Missing revalidatePath After Mutation
|
|
1864
|
+
**Mistake:** Server Action mutates DB but doesn't call \`revalidatePath\`
|
|
1865
|
+
**Why it fails:** Next.js cache not invalidated — UI shows stale data
|
|
1866
|
+
**Correct approach:** Always call \`revalidatePath('/relevant/path')\` at end of Server Action
|
|
1867
|
+
`;
|
|
1868
|
+
}
|
|
1869
|
+
function tokenRulesDoc() {
|
|
1870
|
+
return `# TOKEN-RULES.md — Stay Lean
|
|
1871
|
+
|
|
1872
|
+
> Claude reads this at the start of sessions to minimize token waste.
|
|
1873
|
+
> These rules override default verbosity.
|
|
1874
|
+
|
|
1875
|
+
## Core Rule
|
|
1876
|
+
**Do less. Say less. Ask before adding.**
|
|
1877
|
+
|
|
1878
|
+
Every unnecessary token costs money and eats into rate limits.
|
|
1879
|
+
Follow these rules every session.
|
|
1880
|
+
|
|
1881
|
+
---
|
|
1882
|
+
|
|
1883
|
+
## Response Rules
|
|
1884
|
+
|
|
1885
|
+
### Be Terse
|
|
1886
|
+
- Answer the question asked — nothing more
|
|
1887
|
+
- Skip preamble: "Great question!" / "Sure, I can help with that" — just start
|
|
1888
|
+
- Skip postamble: "Let me know if you need anything else!" — just stop
|
|
1889
|
+
- Skip explaining obvious things: don't explain what Tailwind does, what TypeScript is
|
|
1890
|
+
|
|
1891
|
+
### Code First
|
|
1892
|
+
- Write code immediately, explain only what's non-obvious
|
|
1893
|
+
- Don't describe what you're about to write — just write it
|
|
1894
|
+
- Don't re-show code that hasn't changed
|
|
1895
|
+
|
|
1896
|
+
### Don't Gold-Plate
|
|
1897
|
+
- Build exactly what was asked — no "bonus" features
|
|
1898
|
+
- Don't add error handling that wasn't asked for (but do add it if it's critical)
|
|
1899
|
+
- Don't refactor adjacent code that wasn't mentioned
|
|
1900
|
+
- Don't add tests unless asked
|
|
1901
|
+
|
|
1902
|
+
### Don't Re-Explain the Codebase
|
|
1903
|
+
- CLAUDE.md was already loaded — don't re-describe the stack
|
|
1904
|
+
- If you've already established a pattern, follow it silently
|
|
1905
|
+
- Don't say "as we discussed" — just use the established pattern
|
|
1906
|
+
|
|
1907
|
+
---
|
|
1908
|
+
|
|
1909
|
+
## Session Rules
|
|
1910
|
+
|
|
1911
|
+
### Load Context Surgically
|
|
1912
|
+
- Load ONE domain doc at a time — only what the current task needs
|
|
1913
|
+
- Don't load all of /ai-docs/ at session start — load on demand
|
|
1914
|
+
- After finishing a task domain, you can stop referencing that doc
|
|
1915
|
+
|
|
1916
|
+
### Check Before Exploring
|
|
1917
|
+
- Before reading multiple files to understand the codebase, ask: "Which file handles X?"
|
|
1918
|
+
- Don't grep the entire repo when you can ask
|
|
1919
|
+
- Don't read a file "just in case" — only read files directly relevant to the task
|
|
1920
|
+
|
|
1921
|
+
### When Stuck, Ask — Don't Spiral
|
|
1922
|
+
- If uncertain about an approach, ask one clarifying question
|
|
1923
|
+
- Don't write 3 alternative implementations and ask which to use
|
|
1924
|
+
- Don't explore 5 files hunting for context — ask where to look
|
|
1925
|
+
|
|
1926
|
+
---
|
|
1927
|
+
|
|
1928
|
+
## Mistakes That Waste Tokens
|
|
1929
|
+
|
|
1930
|
+
| Mistake | Cost | Fix |
|
|
1931
|
+
|---------|------|-----|
|
|
1932
|
+
| Re-reading CLAUDE.md mid-session | ~500 tokens | Trust the loaded context |
|
|
1933
|
+
| Explaining the stack after it's established | ~200 tokens | Skip it |
|
|
1934
|
+
| Writing 3 versions of the same code | 3x tokens | Write one, confirm approach first if unsure |
|
|
1935
|
+
| Exploring unrelated files | Variable | Ask which file to look at |
|
|
1936
|
+
| Long error explanations | ~300 tokens | State error + fix, skip the lecture |
|
|
1937
|
+
| Adding unasked features | Variable | Ask before adding |
|
|
1938
|
+
|
|
1939
|
+
---
|
|
1940
|
+
|
|
1941
|
+
## When to Ask vs Just Do
|
|
1942
|
+
|
|
1943
|
+
**Just do it (no asking needed):**
|
|
1944
|
+
- Task is unambiguous
|
|
1945
|
+
- Pattern exists in /ai-docs/prompts/
|
|
1946
|
+
- You've done this before in the session
|
|
1947
|
+
|
|
1948
|
+
**Ask first (one question max):**
|
|
1949
|
+
- Two valid approaches with different tradeoffs
|
|
1950
|
+
- The task scope is unclear
|
|
1951
|
+
- A decision will be hard to reverse
|
|
1952
|
+
|
|
1953
|
+
**Never ask:**
|
|
1954
|
+
- "Should I add comments?" — no, unless the code is complex
|
|
1955
|
+
- "Want me to add tests?" — only if testing was mentioned
|
|
1956
|
+
- "Would you like me to refactor?" — only if asked
|
|
1957
|
+
`;
|
|
1958
|
+
}
|
|
1959
|
+
// ---------------------------------------------------------------------------
|
|
1960
|
+
// Domain Doc Templates
|
|
1961
|
+
// ---------------------------------------------------------------------------
|
|
1962
|
+
function claudeMd(stack, answers) {
|
|
1963
|
+
const pm = stack.packageManager || 'npm';
|
|
1964
|
+
const run = (script) => pm === 'npm' ? `npm run ${script}` : `${pm} ${script}`;
|
|
1965
|
+
const lines = [];
|
|
1966
|
+
lines.push(`# CLAUDE.md — AI Context for ${stack.name}`);
|
|
1967
|
+
lines.push('');
|
|
1968
|
+
lines.push('> This file is automatically loaded by Claude Code every session.');
|
|
1969
|
+
lines.push('> Keep it under 300 tokens. For deeper context, see /ai-docs/.');
|
|
1970
|
+
lines.push('');
|
|
1971
|
+
// Project
|
|
1972
|
+
lines.push('## Project');
|
|
1973
|
+
lines.push(`**Name:** ${stack.name}`);
|
|
1974
|
+
lines.push(`**Description:** ${answers.description}`);
|
|
1975
|
+
if (stack.framework)
|
|
1976
|
+
lines.push(`**Framework:** ${stack.framework}`);
|
|
1977
|
+
if (stack.language)
|
|
1978
|
+
lines.push(`**Language:** ${stack.language}`);
|
|
1979
|
+
if (stack.database)
|
|
1980
|
+
lines.push(`**Database:** ${stack.database}`);
|
|
1981
|
+
if (stack.auth)
|
|
1982
|
+
lines.push(`**Auth:** ${stack.auth}`);
|
|
1983
|
+
if (stack.payments)
|
|
1984
|
+
lines.push(`**Payments:** ${stack.payments}`);
|
|
1985
|
+
if (stack.styling)
|
|
1986
|
+
lines.push(`**Styling:** ${stack.styling}`);
|
|
1987
|
+
if (stack.testing)
|
|
1988
|
+
lines.push(`**Testing:** ${stack.testing}`);
|
|
1989
|
+
if (stack.extras.length > 0)
|
|
1990
|
+
lines.push(`**Extras:** ${stack.extras.join(', ')}`);
|
|
1991
|
+
lines.push('');
|
|
1992
|
+
// Commands
|
|
1993
|
+
if (Object.keys(stack.commands).length > 0) {
|
|
1994
|
+
lines.push('## Key Commands');
|
|
1995
|
+
if (stack.commands.dev)
|
|
1996
|
+
lines.push(`- **Dev:** \`${stack.commands.dev}\``);
|
|
1997
|
+
if (stack.commands.build)
|
|
1998
|
+
lines.push(`- **Build:** \`${stack.commands.build}\``);
|
|
1999
|
+
if (stack.commands.test)
|
|
2000
|
+
lines.push(`- **Test:** \`${stack.commands.test}\``);
|
|
2001
|
+
if (stack.commands.lint)
|
|
2002
|
+
lines.push(`- **Lint:** \`${stack.commands.lint}\``);
|
|
2003
|
+
if (stack.commands.dbPush)
|
|
2004
|
+
lines.push(`- **DB push:** \`${stack.commands.dbPush}\``);
|
|
2005
|
+
if (stack.commands.dbMigrate)
|
|
2006
|
+
lines.push(`- **DB migrate:** \`${stack.commands.dbMigrate}\``);
|
|
2007
|
+
if (stack.commands.dbStudio)
|
|
2008
|
+
lines.push(`- **DB studio:** \`${stack.commands.dbStudio}\``);
|
|
2009
|
+
lines.push('');
|
|
2010
|
+
}
|
|
2011
|
+
// Conventions
|
|
2012
|
+
if (answers.conventions && answers.conventions.length > 0) {
|
|
2013
|
+
lines.push('## Coding Conventions (Always Follow)');
|
|
2014
|
+
answers.conventions.forEach(c => lines.push(`- ${c}`));
|
|
2015
|
+
lines.push('');
|
|
2016
|
+
}
|
|
2017
|
+
// Never do
|
|
2018
|
+
if (answers.never && answers.never.length > 0) {
|
|
2019
|
+
lines.push('## Never Do');
|
|
2020
|
+
answers.never.forEach(n => lines.push(`- ❌ ${n}`));
|
|
2021
|
+
lines.push('');
|
|
2022
|
+
}
|
|
2023
|
+
// Git
|
|
2024
|
+
if (answers.gitFlow !== undefined) {
|
|
2025
|
+
lines.push('## Git Workflow');
|
|
2026
|
+
if (answers.gitFlow) {
|
|
2027
|
+
lines.push('- Feature branches for all changes');
|
|
2028
|
+
lines.push('- PRs required before merging to main');
|
|
2029
|
+
lines.push('- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`');
|
|
2030
|
+
}
|
|
2031
|
+
else {
|
|
2032
|
+
lines.push('- Direct commits to main branch');
|
|
2033
|
+
lines.push('- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`');
|
|
2034
|
+
}
|
|
2035
|
+
lines.push('');
|
|
2036
|
+
}
|
|
2037
|
+
// Extra
|
|
2038
|
+
if (answers.extra) {
|
|
2039
|
+
lines.push('## Additional Context');
|
|
2040
|
+
lines.push(answers.extra);
|
|
2041
|
+
lines.push('');
|
|
2042
|
+
}
|
|
2043
|
+
// Domain docs pointer
|
|
2044
|
+
lines.push('## AI Docs (Load When Relevant)');
|
|
2045
|
+
lines.push('Deeper context files in `/ai-docs/` — load these when working on that domain:');
|
|
2046
|
+
lines.push('');
|
|
2047
|
+
const domainFiles = getDomainFiles(stack, answers);
|
|
2048
|
+
domainFiles.forEach(d => lines.push(`- \`/ai-docs/${d.filename}\` — ${d.label}`));
|
|
2049
|
+
lines.push('');
|
|
2050
|
+
lines.push('---');
|
|
2051
|
+
lines.push('*Generated by [claude-context](https://github.com/your-username/claude-context)*');
|
|
2052
|
+
return lines.join('\n');
|
|
2053
|
+
}
|
|
2054
|
+
function databaseDoc(stack) {
|
|
2055
|
+
const db = stack.database || 'your database';
|
|
2056
|
+
return `# Database Context
|
|
2057
|
+
|
|
2058
|
+
## Stack
|
|
2059
|
+
**ORM / Client:** ${db}
|
|
2060
|
+
${stack.hasMigrations ? '**Migrations:** Yes — always run migrations before deploying' : ''}
|
|
2061
|
+
|
|
2062
|
+
## Patterns
|
|
2063
|
+
|
|
2064
|
+
### Querying
|
|
2065
|
+
${db.includes('Prisma') ? `- Use \`prisma\` client from \`@/lib/prisma\`
|
|
2066
|
+
- Always use \`prisma.$transaction\` for multi-step writes
|
|
2067
|
+
- Use \`select\` to limit returned fields — never return full objects to the client
|
|
2068
|
+
- Soft deletes with \`deletedAt\` field where applicable` : ''}
|
|
2069
|
+
${db.includes('Supabase') ? `- Use the Supabase server client for server-side queries
|
|
2070
|
+
- Use the Supabase browser client only for real-time subscriptions
|
|
2071
|
+
- Row Level Security (RLS) is enabled — always check policies` : ''}
|
|
2072
|
+
${db.includes('Drizzle') ? `- Use \`db\` from \`@/lib/db\`
|
|
2073
|
+
- Prefer \`db.select().from(table).where(...)\` over raw SQL
|
|
2074
|
+
- Use \`db.transaction()\` for multi-step writes` : ''}
|
|
2075
|
+
${db.includes('Mongoose') ? `- Use models from \`@/models/\`
|
|
2076
|
+
- Always use \`.lean()\` for read-only queries
|
|
2077
|
+
- Use \`session\` for multi-document transactions` : ''}
|
|
2078
|
+
|
|
2079
|
+
### Error Handling
|
|
2080
|
+
- Wrap all DB calls in try/catch
|
|
2081
|
+
- Log errors with context (userId, operation, tableName)
|
|
2082
|
+
- Return typed errors, never throw raw DB errors to the client
|
|
2083
|
+
|
|
2084
|
+
### Performance
|
|
2085
|
+
- Add indexes for all foreign keys and frequently queried fields
|
|
2086
|
+
- Use pagination — never return unbounded lists
|
|
2087
|
+
- Avoid N+1 queries — use \`include\`/\`join\` or batch queries
|
|
2088
|
+
|
|
2089
|
+
## Schema Conventions
|
|
2090
|
+
- All tables have: \`id\`, \`createdAt\`, \`updatedAt\`
|
|
2091
|
+
- Foreign key naming: \`userId\`, \`postId\` (camelCase)
|
|
2092
|
+
- Boolean fields: \`isActive\`, \`isPublished\` (not \`active\`, \`published\`)
|
|
2093
|
+
- Timestamps: \`createdAt\`, \`deletedAt\` — ISO 8601 strings
|
|
2094
|
+
|
|
2095
|
+
## Files
|
|
2096
|
+
- Schema definition: \`prisma/schema.prisma\` or \`src/db/schema.ts\`
|
|
2097
|
+
- DB client: \`src/lib/db.ts\` or \`src/lib/prisma.ts\`
|
|
2098
|
+
- Seed data: \`prisma/seed.ts\` or \`scripts/seed.ts\`
|
|
2099
|
+
`;
|
|
2100
|
+
}
|
|
2101
|
+
function authDoc(stack) {
|
|
2102
|
+
const auth = stack.auth || 'custom auth';
|
|
2103
|
+
return `# Authentication Context
|
|
2104
|
+
|
|
2105
|
+
## Stack
|
|
2106
|
+
**Provider:** ${auth}
|
|
2107
|
+
|
|
2108
|
+
## How Auth Works in This App
|
|
2109
|
+
${auth.includes('NextAuth') ? `- Session managed by NextAuth.js
|
|
2110
|
+
- \`getServerSession()\` for server-side session access
|
|
2111
|
+
- \`useSession()\` hook for client-side
|
|
2112
|
+
- Session includes: \`user.id\`, \`user.email\`, \`user.role\`
|
|
2113
|
+
- Protected routes use middleware in \`middleware.ts\`` : ''}
|
|
2114
|
+
${auth.includes('Clerk') ? `- Auth managed by Clerk
|
|
2115
|
+
- \`auth()\` for server components, \`useAuth()\` for client
|
|
2116
|
+
- \`currentUser()\` to get full user object server-side
|
|
2117
|
+
- Middleware in \`middleware.ts\` protects all non-public routes
|
|
2118
|
+
- Webhooks at \`/api/webhooks/clerk\` sync users to DB` : ''}
|
|
2119
|
+
${auth.includes('Supabase') ? `- Auth managed by Supabase
|
|
2120
|
+
- \`createServerClient()\` for server-side auth
|
|
2121
|
+
- \`createBrowserClient()\` for client-side auth
|
|
2122
|
+
- Always verify session server-side — never trust client claims` : ''}
|
|
2123
|
+
${auth.includes('JWT') ? `- JWT tokens stored in httpOnly cookies
|
|
2124
|
+
- Verify token in middleware for protected routes
|
|
2125
|
+
- Access token expires in 15min, refresh token in 7 days
|
|
2126
|
+
- Never store JWTs in localStorage` : ''}
|
|
2127
|
+
|
|
2128
|
+
## Rules
|
|
2129
|
+
- ❌ Never expose user passwords or tokens in responses
|
|
2130
|
+
- ❌ Never trust user-supplied IDs without verifying against session
|
|
2131
|
+
- ✅ Always check auth on the server, not just the client
|
|
2132
|
+
- ✅ Use middleware for route protection, not per-page checks
|
|
2133
|
+
|
|
2134
|
+
## Roles & Permissions
|
|
2135
|
+
- Roles stored in \`user.role\` (e.g. \`admin\`, \`user\`, \`viewer\`)
|
|
2136
|
+
- Check permissions server-side before any sensitive operation
|
|
2137
|
+
- Use typed enums for roles — never hardcode string comparisons
|
|
2138
|
+
|
|
2139
|
+
## Files
|
|
2140
|
+
- Auth config: \`src/lib/auth.ts\` or \`auth.config.ts\`
|
|
2141
|
+
- Middleware: \`middleware.ts\` (root level)
|
|
2142
|
+
- Auth API routes: \`app/api/auth/\` or \`pages/api/auth/\`
|
|
2143
|
+
`;
|
|
2144
|
+
}
|
|
2145
|
+
function paymentsDoc(stack) {
|
|
2146
|
+
const pay = stack.payments || 'Stripe';
|
|
2147
|
+
return `# Payments Context
|
|
2148
|
+
|
|
2149
|
+
## Stack
|
|
2150
|
+
**Provider:** ${pay}
|
|
2151
|
+
|
|
2152
|
+
## Architecture
|
|
2153
|
+
${pay.includes('Stripe') ? `- Stripe handles all payment processing — we never store card data
|
|
2154
|
+
- Webhooks at \`/api/webhooks/stripe\` update subscription state
|
|
2155
|
+
- Customer ID stored in DB: \`users.stripeCustomerId\`
|
|
2156
|
+
- Subscription status: \`active\`, \`trialing\`, \`canceled\`, \`past_due\`
|
|
2157
|
+
|
|
2158
|
+
## Key Flows
|
|
2159
|
+
**Checkout:** User → \`/api/checkout\` → Stripe Checkout Session → redirect → webhook confirms
|
|
2160
|
+
**Portal:** User → \`/api/billing/portal\` → Stripe Customer Portal → webhook on change
|
|
2161
|
+
**Webhook:** Stripe → \`/api/webhooks/stripe\` → verify signature → update DB` : ''}
|
|
2162
|
+
${pay.includes('Lemon') ? `- Lemon Squeezy handles all payment processing
|
|
2163
|
+
- Webhooks at \`/api/webhooks/lemonsqueezy\`
|
|
2164
|
+
- Subscription status synced to DB on every webhook event` : ''}
|
|
2165
|
+
|
|
2166
|
+
## Rules
|
|
2167
|
+
- ❌ Never process payments client-side
|
|
2168
|
+
- ❌ Never trust subscription status from the client — always check DB
|
|
2169
|
+
- ✅ Always verify webhook signatures before processing
|
|
2170
|
+
- ✅ Idempotency: check if webhook event was already processed
|
|
2171
|
+
|
|
2172
|
+
## Subscription Gates
|
|
2173
|
+
- Check \`user.subscriptionStatus === 'active'\` before gating features
|
|
2174
|
+
- Handle \`trialing\`, \`past_due\` states gracefully
|
|
2175
|
+
- Free tier limits enforced server-side
|
|
2176
|
+
|
|
2177
|
+
## Files
|
|
2178
|
+
- Stripe client: \`src/lib/stripe.ts\`
|
|
2179
|
+
- Webhook handler: \`app/api/webhooks/stripe/route.ts\`
|
|
2180
|
+
- Checkout handler: \`app/api/checkout/route.ts\`
|
|
2181
|
+
`;
|
|
2182
|
+
}
|
|
2183
|
+
function apiDoc(stack) {
|
|
2184
|
+
return `# API Design Context
|
|
2185
|
+
|
|
2186
|
+
## Pattern
|
|
2187
|
+
${stack.framework === 'Next.js' ? `- Route Handlers in \`app/api/\` for REST endpoints
|
|
2188
|
+
- Server Actions in \`app/actions/\` for form mutations
|
|
2189
|
+
- Use Server Actions over API routes for form submissions` : '- RESTful endpoints, grouped by resource'}
|
|
2190
|
+
|
|
2191
|
+
## Request / Response Shape
|
|
2192
|
+
\`\`\`typescript
|
|
2193
|
+
// Success
|
|
2194
|
+
{ data: T, error: null }
|
|
2195
|
+
|
|
2196
|
+
// Error
|
|
2197
|
+
{ data: null, error: { message: string, code: string } }
|
|
2198
|
+
\`\`\`
|
|
2199
|
+
|
|
2200
|
+
## Rules
|
|
2201
|
+
- ✅ Always validate request bodies with Zod (or equivalent)
|
|
2202
|
+
- ✅ Return consistent error shapes
|
|
2203
|
+
- ✅ Include HTTP status codes that match the outcome
|
|
2204
|
+
- ❌ Never return raw DB errors to the client
|
|
2205
|
+
- ❌ Never expose internal IDs in public-facing APIs
|
|
2206
|
+
|
|
2207
|
+
## Auth on API Routes
|
|
2208
|
+
- Check auth at the top of every protected handler
|
|
2209
|
+
- Return 401 for unauthenticated, 403 for unauthorized
|
|
2210
|
+
\`\`\`typescript
|
|
2211
|
+
const session = await getServerSession();
|
|
2212
|
+
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
2213
|
+
\`\`\`
|
|
2214
|
+
|
|
2215
|
+
## Validation
|
|
2216
|
+
- Use Zod schemas defined in \`src/lib/validations/\`
|
|
2217
|
+
- Validate both incoming and outgoing data
|
|
2218
|
+
- Strip unknown fields before passing to DB
|
|
2219
|
+
|
|
2220
|
+
## Rate Limiting
|
|
2221
|
+
- Apply rate limiting to all public endpoints
|
|
2222
|
+
- Use IP-based limiting for unauthenticated routes
|
|
2223
|
+
`;
|
|
2224
|
+
}
|
|
2225
|
+
function frontendDoc(stack) {
|
|
2226
|
+
return `# Frontend Patterns Context
|
|
2227
|
+
|
|
2228
|
+
## Component Architecture
|
|
2229
|
+
${stack.framework === 'Next.js' ? `- Default to Server Components — use \`'use client'\` only when needed
|
|
2230
|
+
- Client components needed for: hooks, event listeners, browser APIs, animations
|
|
2231
|
+
- Keep client components small — push state down, data up` : '- Functional components only, no class components'}
|
|
2232
|
+
|
|
2233
|
+
## File Conventions
|
|
2234
|
+
\`\`\`
|
|
2235
|
+
components/
|
|
2236
|
+
ui/ # Generic, reusable (Button, Input, Modal)
|
|
2237
|
+
[feature]/ # Feature-specific (UserProfile, PaymentForm)
|
|
2238
|
+
app/ or pages/ # Route components only — minimal logic here
|
|
2239
|
+
hooks/ # Custom React hooks
|
|
2240
|
+
lib/ # Utilities, helpers, configs
|
|
2241
|
+
types/ # TypeScript interfaces and types
|
|
2242
|
+
\`\`\`
|
|
2243
|
+
|
|
2244
|
+
## Styling
|
|
2245
|
+
${stack.styling === 'Tailwind CSS' ? `- Tailwind CSS for all styling — no inline styles, no CSS modules
|
|
2246
|
+
- Use \`cn()\` utility (clsx + tailwind-merge) for conditional classes
|
|
2247
|
+
- Responsive: mobile-first, \`sm:\`, \`md:\`, \`lg:\` breakpoints
|
|
2248
|
+
- Dark mode: use \`dark:\` variants, not manual class toggling` : `- Follow the existing styling pattern in the codebase`}
|
|
2249
|
+
|
|
2250
|
+
## State Management
|
|
2251
|
+
${stack.extras.includes('Zustand') ? '- Global state in Zustand stores — \`src/stores/\`' : ''}
|
|
2252
|
+
${stack.extras.includes('React Query') ? '- Server state via React Query — never duplicate in local state' : ''}
|
|
2253
|
+
- Local UI state: useState/useReducer
|
|
2254
|
+
- URL state: searchParams for filters/pagination
|
|
2255
|
+
- Avoid prop drilling beyond 2 levels — use context or stores
|
|
2256
|
+
|
|
2257
|
+
## Rules
|
|
2258
|
+
- ❌ Never fetch data directly in client components — use Server Components or React Query
|
|
2259
|
+
- ❌ Never hardcode colors or spacing — use Tailwind tokens
|
|
2260
|
+
- ✅ All interactive elements must have accessible labels
|
|
2261
|
+
- ✅ Loading and error states required for all async operations
|
|
2262
|
+
`;
|
|
2263
|
+
}
|
|
2264
|
+
function errorHandlingDoc(stack) {
|
|
2265
|
+
return `# Error Handling Context
|
|
2266
|
+
|
|
2267
|
+
## Philosophy
|
|
2268
|
+
- Errors are expected — handle them gracefully, log them faithfully
|
|
2269
|
+
- User-facing errors: friendly messages, no stack traces
|
|
2270
|
+
- Server logs: full context (userId, operation, input shape, stack trace)
|
|
2271
|
+
|
|
2272
|
+
## Layers
|
|
2273
|
+
|
|
2274
|
+
### Server (API Routes / Server Actions)
|
|
2275
|
+
\`\`\`typescript
|
|
2276
|
+
try {
|
|
2277
|
+
const result = await someOperation();
|
|
2278
|
+
return { data: result, error: null };
|
|
2279
|
+
} catch (error) {
|
|
2280
|
+
console.error('[OPERATION_NAME]', { error, userId, input });
|
|
2281
|
+
return { data: null, error: { message: 'Something went wrong', code: 'INTERNAL_ERROR' } };
|
|
2282
|
+
}
|
|
2283
|
+
\`\`\`
|
|
2284
|
+
|
|
2285
|
+
### Client (React Components)
|
|
2286
|
+
\`\`\`typescript
|
|
2287
|
+
// Async operations
|
|
2288
|
+
const [error, setError] = useState<string | null>(null);
|
|
2289
|
+
// Handle errors in catch, display with error toast or inline message
|
|
2290
|
+
|
|
2291
|
+
// Form submissions
|
|
2292
|
+
if (!result.success) {
|
|
2293
|
+
toast.error(result.error.message);
|
|
2294
|
+
return;
|
|
2295
|
+
}
|
|
2296
|
+
\`\`\`
|
|
2297
|
+
|
|
2298
|
+
## Error Codes
|
|
2299
|
+
Define typed error codes in \`src/lib/errors.ts\`:
|
|
2300
|
+
- \`UNAUTHORIZED\` — user not logged in
|
|
2301
|
+
- \`FORBIDDEN\` — logged in but not allowed
|
|
2302
|
+
- \`NOT_FOUND\` — resource doesn't exist
|
|
2303
|
+
- \`VALIDATION_ERROR\` — invalid input
|
|
2304
|
+
- \`INTERNAL_ERROR\` — unexpected server error
|
|
2305
|
+
|
|
2306
|
+
## What NOT to Do
|
|
2307
|
+
- ❌ Empty catch blocks
|
|
2308
|
+
- ❌ \`console.log\` instead of \`console.error\` for errors
|
|
2309
|
+
- ❌ Returning raw error.message from database errors to the client
|
|
2310
|
+
- ❌ Using generic "Something went wrong" for validation errors — be specific
|
|
2311
|
+
`;
|
|
2312
|
+
}
|
|
2313
|
+
function testingDoc(stack) {
|
|
2314
|
+
const testLib = stack.testing || 'Vitest';
|
|
2315
|
+
return `# Testing Context
|
|
2316
|
+
|
|
2317
|
+
## Stack
|
|
2318
|
+
**Framework:** ${testLib}
|
|
2319
|
+
|
|
2320
|
+
## What to Test
|
|
2321
|
+
- ✅ Business logic / utility functions — unit tests
|
|
2322
|
+
- ✅ API route handlers — integration tests with mocked DB
|
|
2323
|
+
- ✅ Critical user flows — E2E tests (checkout, auth, core feature)
|
|
2324
|
+
- ❌ Don't test implementation details — test behavior
|
|
2325
|
+
|
|
2326
|
+
## File Conventions
|
|
2327
|
+
\`\`\`
|
|
2328
|
+
src/
|
|
2329
|
+
lib/
|
|
2330
|
+
utils.ts
|
|
2331
|
+
utils.test.ts # Co-located unit tests
|
|
2332
|
+
components/
|
|
2333
|
+
Button.tsx
|
|
2334
|
+
Button.test.tsx # Component tests
|
|
2335
|
+
tests/
|
|
2336
|
+
e2e/ # End-to-end tests
|
|
2337
|
+
\`\`\`
|
|
2338
|
+
|
|
2339
|
+
## Test Patterns
|
|
2340
|
+
\`\`\`typescript
|
|
2341
|
+
// Unit test example
|
|
2342
|
+
describe('formatPrice', () => {
|
|
2343
|
+
it('formats cents to dollars', () => {
|
|
2344
|
+
expect(formatPrice(1999)).toBe('$19.99');
|
|
2345
|
+
});
|
|
2346
|
+
it('handles zero', () => {
|
|
2347
|
+
expect(formatPrice(0)).toBe('$0.00');
|
|
2348
|
+
});
|
|
2349
|
+
});
|
|
2350
|
+
\`\`\`
|
|
2351
|
+
|
|
2352
|
+
## Mocking
|
|
2353
|
+
- Mock external services (Stripe, email, DB) in tests
|
|
2354
|
+
- Use \`vi.mock()\` (Vitest) or \`jest.mock()\` for module mocks
|
|
2355
|
+
- Don't mock internal utilities — test them directly
|
|
2356
|
+
|
|
2357
|
+
## CI
|
|
2358
|
+
- Tests run on every PR
|
|
2359
|
+
- No merging with failing tests
|
|
2360
|
+
- Coverage threshold: 70% for critical paths
|
|
2361
|
+
`;
|
|
2362
|
+
}
|
|
2363
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2364
|
+
// Deployment doc — Railway-specific
|
|
2365
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2366
|
+
function deploymentDoc(stack) {
|
|
2367
|
+
return `# Deployment Context
|
|
2368
|
+
|
|
2369
|
+
## Platform: Railway
|
|
2370
|
+
|
|
2371
|
+
### Overview
|
|
2372
|
+
- All services deployed to Railway (web app + PostgreSQL + Redis if needed)
|
|
2373
|
+
- Deployments trigger automatically on push to \`main\`
|
|
2374
|
+
- Each PR can get an ephemeral preview environment if configured
|
|
2375
|
+
- Environment variables set in Railway dashboard — never hardcode
|
|
2376
|
+
|
|
2377
|
+
### Services Layout
|
|
2378
|
+
\`\`\`
|
|
2379
|
+
Railway Project
|
|
2380
|
+
├── web ← Next.js app (or API)
|
|
2381
|
+
├── postgres ← Railway-managed PostgreSQL
|
|
2382
|
+
└── worker ← Background jobs (if needed)
|
|
2383
|
+
\`\`\`
|
|
2384
|
+
|
|
2385
|
+
### Environment Variables
|
|
2386
|
+
Always required in Railway dashboard:
|
|
2387
|
+
\`\`\`
|
|
2388
|
+
DATABASE_URL # Provided by Railway PostgreSQL plugin
|
|
2389
|
+
NODE_ENV # production
|
|
2390
|
+
NEXTAUTH_URL # https://your-domain.com (or Railway URL)
|
|
2391
|
+
\`\`\`
|
|
2392
|
+
|
|
2393
|
+
Access in code:
|
|
2394
|
+
\`\`\`typescript
|
|
2395
|
+
// Always use process.env — never import.meta.env in Node context
|
|
2396
|
+
const db = process.env.DATABASE_URL;
|
|
2397
|
+
\`\`\`
|
|
2398
|
+
|
|
2399
|
+
### Database: PostgreSQL on Railway
|
|
2400
|
+
- Connection string format: \`postgresql://user:pass@host:port/dbname\`
|
|
2401
|
+
- Railway provides \`DATABASE_URL\` automatically when PostgreSQL is added
|
|
2402
|
+
- Use connection pooling for production (PgBouncer or Prisma's \`?pgbouncer=true\`)
|
|
2403
|
+
- SSL required: append \`?sslmode=require\` if not using Prisma
|
|
2404
|
+
|
|
2405
|
+
\`\`\`typescript
|
|
2406
|
+
// Prisma — railway-safe config in schema.prisma
|
|
2407
|
+
datasource db {
|
|
2408
|
+
provider = "postgresql"
|
|
2409
|
+
url = env("DATABASE_URL")
|
|
2410
|
+
directUrl = env("DIRECT_URL") // for migrations only
|
|
2411
|
+
}
|
|
2412
|
+
\`\`\`
|
|
2413
|
+
|
|
2414
|
+
\`\`\`typescript
|
|
2415
|
+
// Drizzle — railway-safe config
|
|
2416
|
+
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
2417
|
+
import { Pool } from 'pg';
|
|
2418
|
+
|
|
2419
|
+
const pool = new Pool({
|
|
2420
|
+
connectionString: process.env.DATABASE_URL,
|
|
2421
|
+
ssl: { rejectUnauthorized: false }, // required on Railway
|
|
2422
|
+
});
|
|
2423
|
+
export const db = drizzle(pool);
|
|
2424
|
+
\`\`\`
|
|
2425
|
+
|
|
2426
|
+
### Migrations on Railway
|
|
2427
|
+
- Run migrations as part of the deploy command, not a separate step
|
|
2428
|
+
- Set Railway start command to: \`npm run db:migrate && npm run start\`
|
|
2429
|
+
- Or use \`railway run prisma migrate deploy\` in CI
|
|
2430
|
+
|
|
2431
|
+
### Cron Jobs on Railway
|
|
2432
|
+
Railway supports cron jobs as separate services:
|
|
2433
|
+
|
|
2434
|
+
\`\`\`
|
|
2435
|
+
# In Railway: add a Cron service
|
|
2436
|
+
# Schedule format: standard cron (minute hour day month weekday)
|
|
2437
|
+
0 9 * * 1-5 # 9am weekdays
|
|
2438
|
+
0 0 * * * # midnight daily
|
|
2439
|
+
*/15 * * * * # every 15 minutes
|
|
2440
|
+
\`\`\`
|
|
2441
|
+
|
|
2442
|
+
Cron job patterns:
|
|
2443
|
+
\`\`\`typescript
|
|
2444
|
+
// app/api/cron/[job]/route.ts
|
|
2445
|
+
export async function GET(req: Request) {
|
|
2446
|
+
// Verify it's coming from Railway / internal
|
|
2447
|
+
const secret = req.headers.get('x-cron-secret');
|
|
2448
|
+
if (secret !== process.env.CRON_SECRET) {
|
|
2449
|
+
return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
// Always make cron handlers idempotent
|
|
2453
|
+
// Check if job already ran for this period before doing work
|
|
2454
|
+
try {
|
|
2455
|
+
await runJob();
|
|
2456
|
+
return Response.json({ success: true });
|
|
2457
|
+
} catch (error) {
|
|
2458
|
+
console.error('[CRON ERROR]', error);
|
|
2459
|
+
return Response.json({ error: 'Job failed' }, { status: 500 });
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
\`\`\`
|
|
2463
|
+
|
|
2464
|
+
### Cloudflare in Front of Railway
|
|
2465
|
+
- Railway app sits behind Cloudflare (DNS proxy enabled)
|
|
2466
|
+
- Cloudflare handles: SSL termination, DDoS protection, caching static assets
|
|
2467
|
+
- Set Railway custom domain → Cloudflare DNS → Railway
|
|
2468
|
+
|
|
2469
|
+
Cache rules (set in Cloudflare dashboard):
|
|
2470
|
+
- Cache static assets (\`/_next/static/*\`): Cache Everything, Edge TTL 1 month
|
|
2471
|
+
- API routes (\`/api/*\`): Bypass Cache
|
|
2472
|
+
- Pages: Browser cache only, no edge cache (unless explicitly static)
|
|
2473
|
+
|
|
2474
|
+
Headers to set in Railway app:
|
|
2475
|
+
\`\`\`typescript
|
|
2476
|
+
// next.config.js
|
|
2477
|
+
headers: [
|
|
2478
|
+
{
|
|
2479
|
+
source: '/_next/static/:path*',
|
|
2480
|
+
headers: [{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }],
|
|
2481
|
+
},
|
|
2482
|
+
]
|
|
2483
|
+
\`\`\`
|
|
2484
|
+
|
|
2485
|
+
### Health Check
|
|
2486
|
+
Railway pings \`/api/health\` — always implement it:
|
|
2487
|
+
\`\`\`typescript
|
|
2488
|
+
// app/api/health/route.ts
|
|
2489
|
+
export async function GET() {
|
|
2490
|
+
return Response.json({ status: 'ok', ts: Date.now() });
|
|
2491
|
+
}
|
|
2492
|
+
\`\`\`
|
|
2493
|
+
|
|
2494
|
+
### Deploy Checklist
|
|
2495
|
+
- [ ] All env vars set in Railway dashboard
|
|
2496
|
+
- [ ] \`DATABASE_URL\` connected to Railway PostgreSQL service
|
|
2497
|
+
- [ ] Migrations run as part of start command
|
|
2498
|
+
- [ ] Health check endpoint live at \`/api/health\`
|
|
2499
|
+
- [ ] Custom domain added + Cloudflare DNS configured
|
|
2500
|
+
- [ ] Stripe webhook URL updated to production domain
|
|
2501
|
+
- [ ] \`CRON_SECRET\` set if using cron jobs
|
|
2502
|
+
`;
|
|
2503
|
+
}
|
|
2504
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2505
|
+
// Email doc — Resend-specific
|
|
2506
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2507
|
+
function emailDoc(stack) {
|
|
2508
|
+
return `# Email Context
|
|
2509
|
+
|
|
2510
|
+
## Provider: Resend
|
|
2511
|
+
|
|
2512
|
+
### Setup
|
|
2513
|
+
\`\`\`typescript
|
|
2514
|
+
// src/lib/email.ts
|
|
2515
|
+
import { Resend } from 'resend';
|
|
2516
|
+
|
|
2517
|
+
export const resend = new Resend(process.env.RESEND_API_KEY);
|
|
2518
|
+
\`\`\`
|
|
2519
|
+
|
|
2520
|
+
Required env vars:
|
|
2521
|
+
\`\`\`
|
|
2522
|
+
RESEND_API_KEY=re_xxxxxxxxxxxx
|
|
2523
|
+
EMAIL_FROM=noreply@yourdomain.com # must be verified in Resend
|
|
2524
|
+
\`\`\`
|
|
2525
|
+
|
|
2526
|
+
### Sending Emails
|
|
2527
|
+
\`\`\`typescript
|
|
2528
|
+
// Always send from a server action or API route — never client-side
|
|
2529
|
+
import { resend } from '@/lib/email';
|
|
2530
|
+
|
|
2531
|
+
const { data, error } = await resend.emails.send({
|
|
2532
|
+
from: process.env.EMAIL_FROM!,
|
|
2533
|
+
to: user.email,
|
|
2534
|
+
subject: 'Welcome to the app',
|
|
2535
|
+
react: <WelcomeEmail name={user.name} />, // or html: '...'
|
|
2536
|
+
});
|
|
2537
|
+
|
|
2538
|
+
if (error) {
|
|
2539
|
+
console.error('[EMAIL_SEND_ERROR]', error);
|
|
2540
|
+
// Don't throw — email failure shouldn't block the user flow
|
|
2541
|
+
// Log and continue, or queue for retry
|
|
2542
|
+
}
|
|
2543
|
+
\`\`\`
|
|
2544
|
+
|
|
2545
|
+
### Email Templates
|
|
2546
|
+
Store React email components in \`src/emails/\`:
|
|
2547
|
+
\`\`\`
|
|
2548
|
+
src/
|
|
2549
|
+
emails/
|
|
2550
|
+
welcome.tsx # Welcome / onboarding
|
|
2551
|
+
magic-link.tsx # Auth magic link
|
|
2552
|
+
payment-receipt.tsx # Post-purchase
|
|
2553
|
+
subscription.tsx # Subscription changes
|
|
2554
|
+
password-reset.tsx # Password reset
|
|
2555
|
+
\`\`\`
|
|
2556
|
+
|
|
2557
|
+
Template pattern:
|
|
2558
|
+
\`\`\`typescript
|
|
2559
|
+
// src/emails/welcome.tsx
|
|
2560
|
+
import { Html, Head, Body, Container, Text, Button } from '@react-email/components';
|
|
2561
|
+
|
|
2562
|
+
interface WelcomeEmailProps {
|
|
2563
|
+
name: string;
|
|
2564
|
+
loginUrl: string;
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
export function WelcomeEmail({ name, loginUrl }: WelcomeEmailProps) {
|
|
2568
|
+
return (
|
|
2569
|
+
<Html>
|
|
2570
|
+
<Head />
|
|
2571
|
+
<Body>
|
|
2572
|
+
<Container>
|
|
2573
|
+
<Text>Hi {name},</Text>
|
|
2574
|
+
<Button href={loginUrl}>Get started</Button>
|
|
2575
|
+
</Container>
|
|
2576
|
+
</Body>
|
|
2577
|
+
</Html>
|
|
2578
|
+
);
|
|
2579
|
+
}
|
|
2580
|
+
\`\`\`
|
|
2581
|
+
|
|
2582
|
+
### Rules
|
|
2583
|
+
- ❌ Never send emails from client components
|
|
2584
|
+
- ❌ Never hardcode \`from\` addresses — always use env var
|
|
2585
|
+
- ❌ Never block user flows on email failure — log and continue
|
|
2586
|
+
- ✅ Always verify the \`from\` domain in Resend dashboard
|
|
2587
|
+
- ✅ Log email send failures with context (userId, type, error)
|
|
2588
|
+
- ✅ Use React Email for HTML emails — never raw HTML strings
|
|
2589
|
+
- ✅ Test with Resend's test mode before going live
|
|
2590
|
+
|
|
2591
|
+
### Transactional Email Triggers
|
|
2592
|
+
| Event | Template | Timing |
|
|
2593
|
+
|-------|----------|--------|
|
|
2594
|
+
| User signs up | welcome.tsx | Immediate |
|
|
2595
|
+
| Magic link / OTP | magic-link.tsx | Immediate |
|
|
2596
|
+
| Payment successful | payment-receipt.tsx | On Stripe webhook |
|
|
2597
|
+
| Subscription canceled | subscription.tsx | On Stripe webhook |
|
|
2598
|
+
| Password reset | password-reset.tsx | On request |
|
|
2599
|
+
|
|
2600
|
+
### DNS Setup (Cloudflare)
|
|
2601
|
+
Add Resend's DNS records in Cloudflare:
|
|
2602
|
+
- SPF TXT record on root domain
|
|
2603
|
+
- DKIM CNAME records (Resend provides these)
|
|
2604
|
+
- Set proxy status to **DNS only** (grey cloud) for mail records — not proxied
|
|
2605
|
+
`;
|
|
2606
|
+
}
|
|
2607
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2608
|
+
// Analytics doc — GA4 + Google Search Console
|
|
2609
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2610
|
+
function analyticsDoc(stack) {
|
|
2611
|
+
return `# Analytics Context
|
|
2612
|
+
|
|
2613
|
+
## Stack
|
|
2614
|
+
- **Google Analytics 4 (GA4)** — user behaviour, conversions, funnels
|
|
2615
|
+
- **Google Search Console** — SEO performance, indexing, search queries
|
|
2616
|
+
|
|
2617
|
+
---
|
|
2618
|
+
|
|
2619
|
+
## Google Analytics 4
|
|
2620
|
+
|
|
2621
|
+
### Setup
|
|
2622
|
+
\`\`\`
|
|
2623
|
+
GA_MEASUREMENT_ID=G-XXXXXXXXXX # in .env
|
|
2624
|
+
\`\`\`
|
|
2625
|
+
|
|
2626
|
+
\`\`\`typescript
|
|
2627
|
+
// src/lib/analytics.ts
|
|
2628
|
+
export const GA_ID = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID!;
|
|
2629
|
+
|
|
2630
|
+
// Page view
|
|
2631
|
+
export function pageview(url: string) {
|
|
2632
|
+
if (typeof window === 'undefined' || !GA_ID) return;
|
|
2633
|
+
window.gtag('config', GA_ID, { page_path: url });
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
// Custom event
|
|
2637
|
+
export function trackEvent(
|
|
2638
|
+
action: string,
|
|
2639
|
+
category: string,
|
|
2640
|
+
label?: string,
|
|
2641
|
+
value?: number
|
|
2642
|
+
) {
|
|
2643
|
+
if (typeof window === 'undefined' || !GA_ID) return;
|
|
2644
|
+
window.gtag('event', action, {
|
|
2645
|
+
event_category: category,
|
|
2646
|
+
event_label: label,
|
|
2647
|
+
value,
|
|
2648
|
+
});
|
|
2649
|
+
}
|
|
2650
|
+
\`\`\`
|
|
2651
|
+
|
|
2652
|
+
### Next.js Integration
|
|
2653
|
+
\`\`\`typescript
|
|
2654
|
+
// app/layout.tsx — add GA script
|
|
2655
|
+
import Script from 'next/script';
|
|
2656
|
+
|
|
2657
|
+
<Script
|
|
2658
|
+
src={\`https://www.googletagmanager.com/gtag/js?id=\${GA_ID}\`}
|
|
2659
|
+
strategy="afterInteractive"
|
|
2660
|
+
/>
|
|
2661
|
+
<Script id="ga-init" strategy="afterInteractive">
|
|
2662
|
+
{\`
|
|
2663
|
+
window.dataLayer = window.dataLayer || [];
|
|
2664
|
+
function gtag(){dataLayer.push(arguments);}
|
|
2665
|
+
gtag('js', new Date());
|
|
2666
|
+
gtag('config', '\${GA_ID}', { page_path: window.location.pathname });
|
|
2667
|
+
\`}
|
|
2668
|
+
</Script>
|
|
2669
|
+
\`\`\`
|
|
2670
|
+
|
|
2671
|
+
### Key Events to Track
|
|
2672
|
+
\`\`\`typescript
|
|
2673
|
+
// Conversion events — track these
|
|
2674
|
+
trackEvent('sign_up', 'auth', method); // User registers
|
|
2675
|
+
trackEvent('login', 'auth', method); // User logs in
|
|
2676
|
+
trackEvent('begin_checkout', 'ecommerce', plan); // Hits checkout
|
|
2677
|
+
trackEvent('purchase', 'ecommerce', plan, amount); // Payment complete
|
|
2678
|
+
trackEvent('cancel_subscription', 'billing'); // Cancels plan
|
|
2679
|
+
|
|
2680
|
+
// Engagement events
|
|
2681
|
+
trackEvent('feature_used', 'engagement', featureName);
|
|
2682
|
+
trackEvent('share', 'engagement', contentType);
|
|
2683
|
+
\`\`\`
|
|
2684
|
+
|
|
2685
|
+
### Cloudflare + GA4
|
|
2686
|
+
- Cloudflare can block GA4 if Rocket Loader is enabled — disable it or defer GA scripts
|
|
2687
|
+
- Use \`strategy="afterInteractive"\` in Next.js to avoid blocking render
|
|
2688
|
+
|
|
2689
|
+
### Rules
|
|
2690
|
+
- ❌ Never track PII (emails, names) in GA4 events
|
|
2691
|
+
- ❌ Never fire events server-side — GA4 is client-only (use GA4 Measurement Protocol for server events)
|
|
2692
|
+
- ✅ Always check \`typeof window !== 'undefined'\` before calling gtag
|
|
2693
|
+
- ✅ Respect user consent — don't load GA until cookie consent given (if required by region)
|
|
2694
|
+
|
|
2695
|
+
---
|
|
2696
|
+
|
|
2697
|
+
## Google Search Console
|
|
2698
|
+
|
|
2699
|
+
### Setup
|
|
2700
|
+
1. Add property in Search Console: \`https://yourdomain.com\`
|
|
2701
|
+
2. Verify via Cloudflare DNS TXT record (easiest method)
|
|
2702
|
+
3. Submit sitemap: \`https://yourdomain.com/sitemap.xml\`
|
|
2703
|
+
|
|
2704
|
+
### Sitemap in Next.js
|
|
2705
|
+
\`\`\`typescript
|
|
2706
|
+
// app/sitemap.ts
|
|
2707
|
+
import { MetadataRoute } from 'next';
|
|
2708
|
+
|
|
2709
|
+
export default function sitemap(): MetadataRoute.Sitemap {
|
|
2710
|
+
return [
|
|
2711
|
+
{ url: 'https://yourdomain.com', lastModified: new Date(), changeFrequency: 'weekly', priority: 1 },
|
|
2712
|
+
{ url: 'https://yourdomain.com/pricing', lastModified: new Date(), changeFrequency: 'monthly', priority: 0.8 },
|
|
2713
|
+
// Add dynamic pages here
|
|
2714
|
+
];
|
|
2715
|
+
}
|
|
2716
|
+
\`\`\`
|
|
2717
|
+
|
|
2718
|
+
### Robots.txt
|
|
2719
|
+
\`\`\`typescript
|
|
2720
|
+
// app/robots.ts
|
|
2721
|
+
export default function robots() {
|
|
2722
|
+
return {
|
|
2723
|
+
rules: { userAgent: '*', allow: '/', disallow: ['/api/', '/dashboard/'] },
|
|
2724
|
+
sitemap: 'https://yourdomain.com/sitemap.xml',
|
|
2725
|
+
};
|
|
2726
|
+
}
|
|
2727
|
+
\`\`\`
|
|
2728
|
+
|
|
2729
|
+
### What to Monitor Weekly
|
|
2730
|
+
- **Coverage** — indexing errors, excluded pages
|
|
2731
|
+
- **Performance** — clicks, impressions, CTR, average position
|
|
2732
|
+
- **Core Web Vitals** — LCP, FID, CLS scores
|
|
2733
|
+
- **Search queries** — what terms bring people in
|
|
2734
|
+
`;
|
|
2735
|
+
}
|
|
2736
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2737
|
+
// Cloudflare doc
|
|
2738
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2739
|
+
function cloudflareDoc(stack) {
|
|
2740
|
+
return `# Cloudflare Context
|
|
2741
|
+
|
|
2742
|
+
## Role in This Stack
|
|
2743
|
+
Cloudflare sits in front of Railway:
|
|
2744
|
+
\`\`\`
|
|
2745
|
+
User → Cloudflare (DNS + CDN + DDoS) → Railway (app) → PostgreSQL
|
|
2746
|
+
\`\`\`
|
|
2747
|
+
|
|
2748
|
+
## DNS Setup
|
|
2749
|
+
- Domain managed in Cloudflare DNS
|
|
2750
|
+
- Railway custom domain → CNAME pointing to Railway-provided URL
|
|
2751
|
+
- Proxy status: **Proxied** (orange cloud) for the main app
|
|
2752
|
+
- Proxy status: **DNS only** (grey cloud) for: mail records (Resend), Railway health checks
|
|
2753
|
+
|
|
2754
|
+
## SSL/TLS
|
|
2755
|
+
- Mode: **Full (strict)** — Cloudflare ↔ Railway both use SSL
|
|
2756
|
+
- Always Use HTTPS: **On**
|
|
2757
|
+
- Min TLS Version: **TLS 1.2**
|
|
2758
|
+
- Railway: enable "Generate Certificate" in Railway domain settings
|
|
2759
|
+
|
|
2760
|
+
## Caching Rules
|
|
2761
|
+
Set in Cloudflare **Cache Rules** (not Page Rules — those are legacy):
|
|
2762
|
+
|
|
2763
|
+
| Pattern | Cache Behaviour |
|
|
2764
|
+
|---------|----------------|
|
|
2765
|
+
| \`/_next/static/*\` | Cache Everything, Edge TTL: 1 month |
|
|
2766
|
+
| \`/images/*\` | Cache Everything, Edge TTL: 1 week |
|
|
2767
|
+
| \`/api/*\` | Bypass Cache |
|
|
2768
|
+
| \`/dashboard*\` | Bypass Cache |
|
|
2769
|
+
| Everything else | Standard (Cloudflare decides) |
|
|
2770
|
+
|
|
2771
|
+
## Security Rules
|
|
2772
|
+
Firewall → Custom Rules:
|
|
2773
|
+
|
|
2774
|
+
\`\`\`
|
|
2775
|
+
# Block suspicious bots (example rule in Cloudflare expression syntax)
|
|
2776
|
+
(cf.client.bot) and not (cf.verified_bot_category in {"Search Engine Crawlers"})
|
|
2777
|
+
→ Action: Challenge
|
|
2778
|
+
|
|
2779
|
+
# Rate limit login attempts
|
|
2780
|
+
(http.request.uri.path eq "/api/auth/signin" and http.request.method eq "POST")
|
|
2781
|
+
→ Rate limit: 10 req/min per IP → Action: Block
|
|
2782
|
+
\`\`\`
|
|
2783
|
+
|
|
2784
|
+
## Workers (if needed)
|
|
2785
|
+
Use Cloudflare Workers for:
|
|
2786
|
+
- Edge redirects (faster than Next.js middleware for simple redirects)
|
|
2787
|
+
- A/B testing at the edge
|
|
2788
|
+
- Geo-based routing
|
|
2789
|
+
|
|
2790
|
+
Avoid Workers for:
|
|
2791
|
+
- Anything needing DB access (use Railway API instead)
|
|
2792
|
+
- Complex business logic (keep in Next.js)
|
|
2793
|
+
|
|
2794
|
+
## Headers to Set in Next.js (Railway)
|
|
2795
|
+
These complement Cloudflare's protections:
|
|
2796
|
+
\`\`\`typescript
|
|
2797
|
+
// next.config.js
|
|
2798
|
+
const securityHeaders = [
|
|
2799
|
+
{ key: 'X-Frame-Options', value: 'DENY' },
|
|
2800
|
+
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
|
2801
|
+
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
|
2802
|
+
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
|
2803
|
+
];
|
|
2804
|
+
\`\`\`
|
|
2805
|
+
|
|
2806
|
+
## Purge Cache After Deploy
|
|
2807
|
+
After Railway deploys:
|
|
2808
|
+
\`\`\`bash
|
|
2809
|
+
# Purge Cloudflare cache via API
|
|
2810
|
+
curl -X POST "https://api.cloudflare.com/client/v4/zones/ZONE_ID/purge_cache" \\
|
|
2811
|
+
-H "Authorization: Bearer CF_API_TOKEN" \\
|
|
2812
|
+
-H "Content-Type: application/json" \\
|
|
2813
|
+
--data '{"purge_everything":true}'
|
|
2814
|
+
\`\`\`
|
|
2815
|
+
Or add this to Railway deploy hooks.
|
|
2816
|
+
|
|
2817
|
+
## Env Vars Needed
|
|
2818
|
+
\`\`\`
|
|
2819
|
+
CLOUDFLARE_ZONE_ID=xxxx # For cache purge scripts
|
|
2820
|
+
CLOUDFLARE_API_TOKEN=xxxx # Scoped to Cache Purge permission only
|
|
2821
|
+
\`\`\`
|
|
2822
|
+
|
|
2823
|
+
## Rules
|
|
2824
|
+
- ❌ Never put real-time API responses behind Cloudflare cache
|
|
2825
|
+
- ❌ Never proxy mail DNS records through Cloudflare (breaks Resend DKIM)
|
|
2826
|
+
- ✅ Always use Full (strict) SSL mode — never Flexible
|
|
2827
|
+
- ✅ Purge cache after significant deploys
|
|
2828
|
+
- ✅ Use Cloudflare Analytics as a secondary source (privacy-friendly, no cookies)
|
|
2829
|
+
`;
|
|
2830
|
+
}
|
|
2831
|
+
// ---------------------------------------------------------------------------
|
|
2832
|
+
// Constants & Lookup Functions
|
|
2833
|
+
// ---------------------------------------------------------------------------
|
|
2834
|
+
const ALL_DOMAIN_DOCS = [
|
|
2835
|
+
{ filename: 'database.md', label: 'DB patterns, queries, schema conventions', key: 'database' },
|
|
2836
|
+
{ filename: 'auth.md', label: 'Auth patterns, session access, route protection', key: 'auth' },
|
|
2837
|
+
{ filename: 'payments.md', label: 'Payment flows, webhooks, subscription gates', key: 'payments' },
|
|
2838
|
+
{ filename: 'api.md', label: 'API design, validation, error shapes', key: 'api' },
|
|
2839
|
+
{ filename: 'frontend.md', label: 'Component patterns, styling, state management', key: 'frontend' },
|
|
2840
|
+
{ filename: 'errors.md', label: 'Error handling patterns, logging, error codes', key: 'errors' },
|
|
2841
|
+
{ filename: 'testing.md', label: 'Testing strategy, patterns, what to test', key: 'testing' },
|
|
2842
|
+
{ filename: 'deployment.md', label: 'Railway deploy, crons, PostgreSQL, Cloudflare', key: 'deployment' },
|
|
2843
|
+
{ filename: 'email.md', label: 'Resend setup, templates, triggers', key: 'email' },
|
|
2844
|
+
{ filename: 'analytics.md', label: 'GA4 events, Search Console, sitemap', key: 'analytics' },
|
|
2845
|
+
{ filename: 'cloudflare.md', label: 'DNS, caching rules, security, SSL', key: 'cloudflare' },
|
|
2846
|
+
];
|
|
2847
|
+
exports.ALL_DOMAIN_DOCS = ALL_DOMAIN_DOCS;
|
|
2848
|
+
const SKILL_FILES = [
|
|
2849
|
+
{ filename: 'skills/frontend.md', label: 'Frontend Engineer persona' },
|
|
2850
|
+
{ filename: 'skills/backend.md', label: 'Backend Engineer persona' },
|
|
2851
|
+
{ filename: 'skills/devops.md', label: 'DevOps / Infrastructure persona' },
|
|
2852
|
+
{ filename: 'skills/reviewer.md', label: 'Code Reviewer persona' },
|
|
2853
|
+
];
|
|
2854
|
+
exports.SKILL_FILES = SKILL_FILES;
|
|
2855
|
+
const EXTRA_FILES = [
|
|
2856
|
+
{ filename: 'security.md', label: 'OWASP + STRIDE threat model for this stack' },
|
|
2857
|
+
{ filename: 'HANDOVER.md', label: 'Session handover template (resume sessions cheaply)' },
|
|
2858
|
+
{ filename: 'MISTAKES.md', label: 'Living mistake log (never repeat the same error)' },
|
|
2859
|
+
{ filename: 'TOKEN-RULES.md', label: 'Token-saving rules for Claude' },
|
|
2860
|
+
];
|
|
2861
|
+
exports.EXTRA_FILES = EXTRA_FILES;
|
|
2862
|
+
function getDomainFiles(stack, answers) {
|
|
2863
|
+
const selected = (answers.domains || []).map((d) => d.toLowerCase());
|
|
2864
|
+
const alwaysDocs = answers.alwaysDocs || [];
|
|
2865
|
+
const files = [];
|
|
2866
|
+
for (const doc of ALL_DOMAIN_DOCS) {
|
|
2867
|
+
const shouldInclude = alwaysDocs.includes(doc.key) ||
|
|
2868
|
+
selected.some((d) => d.includes(doc.key)) ||
|
|
2869
|
+
(doc.key === 'database' && !!stack.database) ||
|
|
2870
|
+
(doc.key === 'auth' && !!stack.auth) ||
|
|
2871
|
+
(doc.key === 'payments' && !!stack.payments);
|
|
2872
|
+
if (shouldInclude)
|
|
2873
|
+
files.push(doc);
|
|
2874
|
+
}
|
|
2875
|
+
if (files.length === 0) {
|
|
2876
|
+
return ALL_DOMAIN_DOCS.filter(d => ['api', 'frontend', 'deployment'].includes(d.key));
|
|
2877
|
+
}
|
|
2878
|
+
return files;
|
|
2879
|
+
}
|
|
2880
|
+
function generateDomainDoc(filename, stack) {
|
|
2881
|
+
switch (filename) {
|
|
2882
|
+
case 'database.md': return databaseDoc(stack);
|
|
2883
|
+
case 'auth.md': return authDoc(stack);
|
|
2884
|
+
case 'payments.md': return paymentsDoc(stack);
|
|
2885
|
+
case 'api.md': return apiDoc(stack);
|
|
2886
|
+
case 'frontend.md': return frontendDoc(stack);
|
|
2887
|
+
case 'errors.md': return errorHandlingDoc(stack);
|
|
2888
|
+
case 'testing.md': return testingDoc(stack);
|
|
2889
|
+
case 'deployment.md': return deploymentDoc(stack);
|
|
2890
|
+
case 'email.md': return emailDoc(stack);
|
|
2891
|
+
case 'analytics.md': return analyticsDoc(stack);
|
|
2892
|
+
case 'cloudflare.md': return cloudflareDoc(stack);
|
|
2893
|
+
default: return `# ${filename}\n\nAdd your context here.\n`;
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
function generatePromptFile(filename, stack) {
|
|
2897
|
+
switch (filename) {
|
|
2898
|
+
case 'PROMPTS.md': return promptsIndex(stack);
|
|
2899
|
+
case 'prompts/auth.md': return authPrompts(stack);
|
|
2900
|
+
case 'prompts/database.md': return databasePrompts(stack);
|
|
2901
|
+
case 'prompts/payments.md': return paymentsPrompts(stack);
|
|
2902
|
+
case 'prompts/ui.md': return uiPrompts(stack);
|
|
2903
|
+
case 'prompts/api.md': return apiPrompts(stack);
|
|
2904
|
+
case 'prompts/deployment.md': return deploymentPrompts(stack);
|
|
2905
|
+
case 'prompts/email.md': return emailPrompts(stack);
|
|
2906
|
+
case 'prompts/analytics.md': return analyticsPrompts(stack);
|
|
2907
|
+
case 'prompts/security.md': return securityPrompts(stack);
|
|
2908
|
+
default: return `# ${filename}\n\nAdd prompt patterns here.\n`;
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
function getPromptFiles(stack, answers) {
|
|
2912
|
+
const files = [{ filename: 'PROMPTS.md', label: 'Pattern library index' }];
|
|
2913
|
+
const domains = answers.domains || [];
|
|
2914
|
+
if (stack.auth || domains.includes('auth'))
|
|
2915
|
+
files.push({ filename: 'prompts/auth.md', label: 'Auth Build/Verify/Debug patterns' });
|
|
2916
|
+
if (stack.database || domains.includes('database'))
|
|
2917
|
+
files.push({ filename: 'prompts/database.md', label: 'Database Build/Verify/Debug patterns' });
|
|
2918
|
+
if (stack.payments || domains.includes('payments'))
|
|
2919
|
+
files.push({ filename: 'prompts/payments.md', label: 'Payments Build/Verify/Debug patterns' });
|
|
2920
|
+
files.push({ filename: 'prompts/ui.md', label: 'UI / component patterns' });
|
|
2921
|
+
files.push({ filename: 'prompts/api.md', label: 'API route patterns' });
|
|
2922
|
+
if (domains.includes('deployment') || answers.alwaysDocs?.includes('deployment'))
|
|
2923
|
+
files.push({ filename: 'prompts/deployment.md', label: 'Deployment / cron patterns' });
|
|
2924
|
+
if (domains.includes('email') || answers.alwaysDocs?.includes('email'))
|
|
2925
|
+
files.push({ filename: 'prompts/email.md', label: 'Email send patterns' });
|
|
2926
|
+
if (domains.includes('analytics') || answers.alwaysDocs?.includes('analytics'))
|
|
2927
|
+
files.push({ filename: 'prompts/analytics.md', label: 'Analytics event patterns' });
|
|
2928
|
+
files.push({ filename: 'prompts/security.md', label: 'Security / OWASP patterns' });
|
|
2929
|
+
return files;
|
|
2930
|
+
}
|
|
2931
|
+
function generateSkillFile(filename) {
|
|
2932
|
+
switch (filename) {
|
|
2933
|
+
case 'skills/frontend.md': return frontendSkill();
|
|
2934
|
+
case 'skills/backend.md': return backendSkill();
|
|
2935
|
+
case 'skills/devops.md': return devopsSkill();
|
|
2936
|
+
case 'skills/reviewer.md': return reviewerSkill();
|
|
2937
|
+
default: return '';
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
function generateExtraFile(filename, stack) {
|
|
2941
|
+
switch (filename) {
|
|
2942
|
+
case 'security.md': return securityDoc(stack);
|
|
2943
|
+
case 'HANDOVER.md': return handoverTemplate();
|
|
2944
|
+
case 'MISTAKES.md': return mistakesTemplate();
|
|
2945
|
+
case 'TOKEN-RULES.md': return tokenRulesDoc();
|
|
2946
|
+
default: return '';
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
// ---------------------------------------------------------------------------
|
|
2950
|
+
// File Writing Helpers
|
|
2951
|
+
// ---------------------------------------------------------------------------
|
|
2952
|
+
function writeFileSync(filePath, content) {
|
|
2953
|
+
const dir = path.dirname(filePath);
|
|
2954
|
+
if (!fs.existsSync(dir))
|
|
2955
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
2956
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
2957
|
+
}
|
|
2958
|
+
function checkExisting(cwd) {
|
|
2959
|
+
const existing = [];
|
|
2960
|
+
if (fs.existsSync(path.join(cwd, 'CLAUDE.md')))
|
|
2961
|
+
existing.push('CLAUDE.md');
|
|
2962
|
+
if (fs.existsSync(path.join(cwd, 'ai-docs')))
|
|
2963
|
+
existing.push('ai-docs/');
|
|
2964
|
+
return existing;
|
|
2965
|
+
}
|
|
2966
|
+
// ---------------------------------------------------------------------------
|
|
2967
|
+
// Main Generation Function
|
|
2968
|
+
// ---------------------------------------------------------------------------
|
|
2969
|
+
function generateContextFiles(projectDir, stack, options, answers = {}) {
|
|
2970
|
+
const { force = false, dryRun = false } = options;
|
|
2971
|
+
(0, logger_1.log)('GENERATOR', 'start', 'Generating context files', { projectDir, dryRun });
|
|
2972
|
+
// Check for existing files
|
|
2973
|
+
const existing = checkExisting(projectDir);
|
|
2974
|
+
if (existing.length > 0 && !force) {
|
|
2975
|
+
(0, logger_1.logWarn)('GENERATOR', 'existing', 'Existing files found, use --force to overwrite', { existing });
|
|
2976
|
+
return;
|
|
2977
|
+
}
|
|
2978
|
+
// Determine which files to generate
|
|
2979
|
+
const domainFiles = getDomainFiles(stack, answers);
|
|
2980
|
+
const promptFiles = getPromptFiles(stack, answers);
|
|
2981
|
+
if (dryRun) {
|
|
2982
|
+
(0, logger_1.log)('GENERATOR', 'dry-run', 'Dry run - would generate:');
|
|
2983
|
+
console.log('Layer 1: CLAUDE.md');
|
|
2984
|
+
domainFiles.forEach((f) => console.log('Layer 2: ai-docs/' + f.filename));
|
|
2985
|
+
promptFiles.forEach((f) => console.log('Layer 3: ai-docs/' + f.filename));
|
|
2986
|
+
SKILL_FILES.forEach((f) => console.log('Skills: ai-docs/' + f.filename));
|
|
2987
|
+
EXTRA_FILES.forEach((f) => console.log('Extras: ai-docs/' + f.filename));
|
|
2988
|
+
return;
|
|
2989
|
+
}
|
|
2990
|
+
// Layer 1 - CLAUDE.md
|
|
2991
|
+
(0, logger_1.log)('GENERATOR', 'write', 'Writing Layer 1 - CLAUDE.md');
|
|
2992
|
+
const claudeMdContent = claudeMd(stack, answers);
|
|
2993
|
+
writeFileSync(path.join(projectDir, 'CLAUDE.md'), claudeMdContent);
|
|
2994
|
+
(0, logger_1.log)('GENERATOR', 'write', 'CLAUDE.md', { tokens: Math.round(claudeMdContent.length / 4) });
|
|
2995
|
+
// Layer 2 - Domain Docs
|
|
2996
|
+
(0, logger_1.log)('GENERATOR', 'write', 'Writing Layer 2 - Domain Docs');
|
|
2997
|
+
for (const f of domainFiles) {
|
|
2998
|
+
const content = generateDomainDoc(f.filename, stack);
|
|
2999
|
+
writeFileSync(path.join(projectDir, 'ai-docs', f.filename), content);
|
|
3000
|
+
(0, logger_1.log)('GENERATOR', 'write', f.filename, { label: f.label, tokens: Math.round(content.length / 4) });
|
|
3001
|
+
}
|
|
3002
|
+
// Layer 3 - Pattern Library
|
|
3003
|
+
(0, logger_1.log)('GENERATOR', 'write', 'Writing Layer 3 - Pattern Library');
|
|
3004
|
+
for (const f of promptFiles) {
|
|
3005
|
+
const content = generatePromptFile(f.filename, stack);
|
|
3006
|
+
writeFileSync(path.join(projectDir, 'ai-docs', f.filename), content);
|
|
3007
|
+
(0, logger_1.log)('GENERATOR', 'write', f.filename, { label: f.label, tokens: Math.round(content.length / 4) });
|
|
3008
|
+
}
|
|
3009
|
+
// Skills
|
|
3010
|
+
(0, logger_1.log)('GENERATOR', 'write', 'Writing Skills');
|
|
3011
|
+
for (const f of SKILL_FILES) {
|
|
3012
|
+
const content = generateSkillFile(f.filename);
|
|
3013
|
+
writeFileSync(path.join(projectDir, 'ai-docs', f.filename), content);
|
|
3014
|
+
(0, logger_1.log)('GENERATOR', 'write', f.filename, { label: f.label });
|
|
3015
|
+
}
|
|
3016
|
+
// Extras
|
|
3017
|
+
(0, logger_1.log)('GENERATOR', 'write', 'Writing Extras');
|
|
3018
|
+
for (const f of EXTRA_FILES) {
|
|
3019
|
+
const content = generateExtraFile(f.filename, stack);
|
|
3020
|
+
writeFileSync(path.join(projectDir, 'ai-docs', f.filename), content);
|
|
3021
|
+
(0, logger_1.log)('GENERATOR', 'write', f.filename, { label: f.label });
|
|
3022
|
+
}
|
|
3023
|
+
(0, logger_1.log)('GENERATOR', 'done', 'All context files generated successfully');
|
|
3024
|
+
}
|
|
3025
|
+
//# sourceMappingURL=generator.js.map
|