create-saas-app-cli 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/dist/generator.d.ts +4 -0
- package/dist/generator.d.ts.map +1 -0
- package/dist/generator.js +175 -0
- package/dist/generator.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +127 -0
- package/dist/index.js.map +1 -0
- package/dist/prompts.d.ts +21 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +120 -0
- package/dist/prompts.js.map +1 -0
- package/dist/templates.d.ts +63 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +1996 -0
- package/dist/templates.js.map +1 -0
- package/dist/utils.d.ts +5 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +17 -0
- package/dist/utils.js.map +1 -0
- package/package.json +65 -0
|
@@ -0,0 +1,1996 @@
|
|
|
1
|
+
// ─── Root package.json ────────────────────────────────────────────────────────
|
|
2
|
+
export function rootPackageJson(a) {
|
|
3
|
+
const pkgManager = a.packageManager;
|
|
4
|
+
const pmVersions = {
|
|
5
|
+
bun: "bun@1.3.8",
|
|
6
|
+
pnpm: "pnpm@9.0.0",
|
|
7
|
+
npm: "npm@10.0.0",
|
|
8
|
+
};
|
|
9
|
+
return JSON.stringify({
|
|
10
|
+
name: a.projectName,
|
|
11
|
+
private: true,
|
|
12
|
+
scripts: {
|
|
13
|
+
build: "turbo run build",
|
|
14
|
+
dev: "turbo run dev",
|
|
15
|
+
lint: "turbo run lint",
|
|
16
|
+
"check-types": "turbo run check-types",
|
|
17
|
+
test: "turbo run test",
|
|
18
|
+
"db:generate": "turbo run db:generate --filter=@saas/database",
|
|
19
|
+
"db:migrate": "turbo run db:migrate --filter=@saas/database",
|
|
20
|
+
"docker:up": "docker compose -f docker/docker-compose.yml up --build -d",
|
|
21
|
+
"docker:down": "docker compose -f docker/docker-compose.yml down",
|
|
22
|
+
},
|
|
23
|
+
devDependencies: {
|
|
24
|
+
"@types/node": "^22.0.0",
|
|
25
|
+
"@eslint/js": "^9.0.0",
|
|
26
|
+
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
27
|
+
"@typescript-eslint/parser": "^8.0.0",
|
|
28
|
+
eslint: "^9.0.0",
|
|
29
|
+
globals: "^15.0.0",
|
|
30
|
+
prettier: "^3.4.0",
|
|
31
|
+
turbo: "^2.8.10",
|
|
32
|
+
typescript: "5.7.3",
|
|
33
|
+
},
|
|
34
|
+
engines: { node: ">=18" },
|
|
35
|
+
packageManager: pmVersions[pkgManager],
|
|
36
|
+
workspaces: ["apps/*", "packages/*"],
|
|
37
|
+
}, null, 2);
|
|
38
|
+
}
|
|
39
|
+
// ─── turbo.json ───────────────────────────────────────────────────────────────
|
|
40
|
+
export function turboJson() {
|
|
41
|
+
return JSON.stringify({
|
|
42
|
+
$schema: "https://turborepo.dev/schema.json",
|
|
43
|
+
ui: "tui",
|
|
44
|
+
tasks: {
|
|
45
|
+
build: {
|
|
46
|
+
dependsOn: ["^build"],
|
|
47
|
+
inputs: ["$TURBO_DEFAULT$", ".env*"],
|
|
48
|
+
outputs: ["dist/**"],
|
|
49
|
+
},
|
|
50
|
+
"check-types": { dependsOn: ["^check-types"] },
|
|
51
|
+
lint: { dependsOn: ["^lint"] },
|
|
52
|
+
test: {
|
|
53
|
+
dependsOn: ["^build"],
|
|
54
|
+
inputs: ["src/**", "*.test.ts", "vitest.config.*"],
|
|
55
|
+
outputs: ["coverage/**"],
|
|
56
|
+
},
|
|
57
|
+
dev: { cache: false, persistent: true },
|
|
58
|
+
"db:generate": { cache: false },
|
|
59
|
+
"db:migrate": { cache: false },
|
|
60
|
+
},
|
|
61
|
+
}, null, 2);
|
|
62
|
+
}
|
|
63
|
+
// ─── .gitignore ───────────────────────────────────────────────────────────────
|
|
64
|
+
export function gitignoreTemplate() {
|
|
65
|
+
return `node_modules/
|
|
66
|
+
dist/
|
|
67
|
+
.env
|
|
68
|
+
.env.local
|
|
69
|
+
.turbo/
|
|
70
|
+
coverage/
|
|
71
|
+
*.log
|
|
72
|
+
*.db
|
|
73
|
+
bun.lockb
|
|
74
|
+
pnpm-lock.yaml
|
|
75
|
+
package-lock.json
|
|
76
|
+
.DS_Store
|
|
77
|
+
`;
|
|
78
|
+
}
|
|
79
|
+
// ─── .npmrc ───────────────────────────────────────────────────────────────────
|
|
80
|
+
export function npmrcTemplate(a) {
|
|
81
|
+
if (a.packageManager === "pnpm") {
|
|
82
|
+
return `shamefully-hoist=true\n`;
|
|
83
|
+
}
|
|
84
|
+
return ``;
|
|
85
|
+
}
|
|
86
|
+
// ─── .env.example ─────────────────────────────────────────────────────────────
|
|
87
|
+
export function envExampleTemplate(a) {
|
|
88
|
+
const isMongo = a.database === "mongodb-mongoose";
|
|
89
|
+
const dbLine = isMongo
|
|
90
|
+
? `MONGODB_URI=mongodb://localhost:27017/${a.projectName}`
|
|
91
|
+
: `DATABASE_URL=postgres://saas:saaspassword@localhost:5432/saas`;
|
|
92
|
+
const redisLine = a.includeQueue || a.rateLimit === "redis"
|
|
93
|
+
? `\n# ─── Redis ──────────────────────────────────────────────────────────────────\nREDIS_URL=redis://localhost:6379`
|
|
94
|
+
: "";
|
|
95
|
+
const rateLimitSection = a.rateLimit !== "none"
|
|
96
|
+
? `\n# ─── Rate Limiting ───────────────────────────────────────────────────────────\nRATE_LIMIT_WINDOW_MS=60000\nRATE_LIMIT_MAX_REQUESTS=100`
|
|
97
|
+
: "";
|
|
98
|
+
const paymentsSection = a.includePayments
|
|
99
|
+
? `\n# ─── Razorpay ────────────────────────────────────────────────────────────────\nRAZORPAY_KEY_ID=rzp_test_xxxxxxxxxxxx\nRAZORPAY_KEY_SECRET=change-me\nRAZORPAY_WEBHOOK_SECRET=change-me`
|
|
100
|
+
: "";
|
|
101
|
+
const emailSection = a.emailProvider === "resend"
|
|
102
|
+
? `\n# ─── Email (Resend) ──────────────────────────────────────────────────────────\nRESEND_API_KEY=re_xxxxxxxxxxxx\nEMAIL_FROM=noreply@yoursaas.com`
|
|
103
|
+
: a.emailProvider === "nodemailer"
|
|
104
|
+
? `\n# ─── Email (SMTP) ────────────────────────────────────────────────────────────\nSMTP_HOST=smtp.example.com\nSMTP_PORT=587\nSMTP_SECURE=false\nSMTP_USER=user@example.com\nSMTP_PASS=change-me\nEMAIL_FROM=noreply@yoursaas.com`
|
|
105
|
+
: "";
|
|
106
|
+
return `# ─── Application ─────────────────────────────────────────────────────────────
|
|
107
|
+
NODE_ENV=development
|
|
108
|
+
PORT=3000
|
|
109
|
+
|
|
110
|
+
# ─── Database ─────────────────────────────────────────────────────────────────
|
|
111
|
+
${dbLine}
|
|
112
|
+
${redisLine}
|
|
113
|
+
|
|
114
|
+
# ─── Auth ─────────────────────────────────────────────────────────────────────
|
|
115
|
+
JWT_SECRET=change-me-to-a-long-random-secret-32-chars-min
|
|
116
|
+
JWT_EXPIRES_IN=7d
|
|
117
|
+
|
|
118
|
+
# ─── Admin ────────────────────────────────────────────────────────────────────
|
|
119
|
+
ADMIN_SECRET=change-me-admin-secret
|
|
120
|
+
${rateLimitSection}
|
|
121
|
+
${paymentsSection}
|
|
122
|
+
${emailSection}
|
|
123
|
+
|
|
124
|
+
# ─── Observability ────────────────────────────────────────────────────────────
|
|
125
|
+
LOG_LEVEL=info
|
|
126
|
+
`;
|
|
127
|
+
}
|
|
128
|
+
// ─── API app package.json ─────────────────────────────────────────────────────
|
|
129
|
+
export function apiPackageJson(a) {
|
|
130
|
+
const dbDeps = dbDependencies(a.database);
|
|
131
|
+
const rateDeps = rateLimitDependencies(a.rateLimit);
|
|
132
|
+
const deps = {
|
|
133
|
+
express: "^4.21.2",
|
|
134
|
+
helmet: "^8.0.0",
|
|
135
|
+
cors: "^2.8.5",
|
|
136
|
+
"@saas/config": "*",
|
|
137
|
+
"@saas/logger": "*",
|
|
138
|
+
"@saas/types": "*",
|
|
139
|
+
...dbDeps,
|
|
140
|
+
...rateDeps,
|
|
141
|
+
};
|
|
142
|
+
if (a.includeAuth)
|
|
143
|
+
deps["@saas/auth"] = "*";
|
|
144
|
+
if (a.includeQueue) {
|
|
145
|
+
deps["@saas/queue"] = "*";
|
|
146
|
+
deps["@saas/redis"] = "*";
|
|
147
|
+
deps["bullmq"] = "^5.0.0";
|
|
148
|
+
}
|
|
149
|
+
if (a.includePayments)
|
|
150
|
+
deps["@saas/payments"] = "*";
|
|
151
|
+
if (a.emailProvider !== "none")
|
|
152
|
+
deps["@saas/email"] = "*";
|
|
153
|
+
return JSON.stringify({
|
|
154
|
+
name: "@saas/api",
|
|
155
|
+
version: "0.1.0",
|
|
156
|
+
private: true,
|
|
157
|
+
type: "module",
|
|
158
|
+
scripts: {
|
|
159
|
+
dev: "tsx watch src/index.ts",
|
|
160
|
+
build: "tsc --project tsconfig.build.json",
|
|
161
|
+
start: "node dist/index.js",
|
|
162
|
+
"check-types": "tsc --noEmit",
|
|
163
|
+
lint: "eslint src/",
|
|
164
|
+
},
|
|
165
|
+
dependencies: deps,
|
|
166
|
+
devDependencies: {
|
|
167
|
+
"@saas/typescript-config": "*",
|
|
168
|
+
"@types/cors": "^2.8.18",
|
|
169
|
+
"@types/express": "^5.0.1",
|
|
170
|
+
"@types/node": "^22.0.0",
|
|
171
|
+
tsx: "^4.19.3",
|
|
172
|
+
typescript: "5.7.3",
|
|
173
|
+
vitest: "^3.0.7",
|
|
174
|
+
},
|
|
175
|
+
}, null, 2);
|
|
176
|
+
}
|
|
177
|
+
// ─── Worker app package.json ──────────────────────────────────────────────────
|
|
178
|
+
export function workerPackageJson(a) {
|
|
179
|
+
const dbDeps = dbDependencies(a.database);
|
|
180
|
+
const deps = {
|
|
181
|
+
bullmq: "^5.0.0",
|
|
182
|
+
"@saas/config": "*",
|
|
183
|
+
"@saas/logger": "*",
|
|
184
|
+
"@saas/redis": "*",
|
|
185
|
+
"@saas/types": "*",
|
|
186
|
+
...dbDeps,
|
|
187
|
+
};
|
|
188
|
+
if (a.includeQueue)
|
|
189
|
+
deps["@saas/queue"] = "*";
|
|
190
|
+
return JSON.stringify({
|
|
191
|
+
name: "@saas/worker",
|
|
192
|
+
version: "0.1.0",
|
|
193
|
+
private: true,
|
|
194
|
+
type: "module",
|
|
195
|
+
scripts: {
|
|
196
|
+
dev: "tsx watch src/index.ts",
|
|
197
|
+
build: "tsc --project tsconfig.build.json",
|
|
198
|
+
start: "node dist/index.js",
|
|
199
|
+
"check-types": "tsc --noEmit",
|
|
200
|
+
lint: "eslint src/",
|
|
201
|
+
},
|
|
202
|
+
dependencies: deps,
|
|
203
|
+
devDependencies: {
|
|
204
|
+
"@saas/typescript-config": "*",
|
|
205
|
+
"@types/node": "^22.0.0",
|
|
206
|
+
tsx: "^4.19.3",
|
|
207
|
+
typescript: "5.7.3",
|
|
208
|
+
vitest: "^3.0.7",
|
|
209
|
+
},
|
|
210
|
+
}, null, 2);
|
|
211
|
+
}
|
|
212
|
+
// ─── Shared tsconfig.json for apps ───────────────────────────────────────────
|
|
213
|
+
export function appTsconfig() {
|
|
214
|
+
return JSON.stringify({
|
|
215
|
+
extends: "@saas/typescript-config/base.json",
|
|
216
|
+
compilerOptions: {
|
|
217
|
+
outDir: "./dist",
|
|
218
|
+
rootDir: "./src",
|
|
219
|
+
},
|
|
220
|
+
include: ["src"],
|
|
221
|
+
}, null, 2);
|
|
222
|
+
}
|
|
223
|
+
export function appTsconfigBuild() {
|
|
224
|
+
return JSON.stringify({
|
|
225
|
+
extends: "./tsconfig.json",
|
|
226
|
+
exclude: ["node_modules", "dist", "**/*.test.ts"],
|
|
227
|
+
}, null, 2);
|
|
228
|
+
}
|
|
229
|
+
// ─── API source files ─────────────────────────────────────────────────────────
|
|
230
|
+
export function apiIndexTs() {
|
|
231
|
+
return `import { createApp } from "./app.js";
|
|
232
|
+
import { config } from "@saas/config";
|
|
233
|
+
import { createLogger } from "@saas/logger";
|
|
234
|
+
|
|
235
|
+
const logger = createLogger("api");
|
|
236
|
+
|
|
237
|
+
createApp().then((app) => {
|
|
238
|
+
app.listen(config.port, () => {
|
|
239
|
+
logger.info({ port: config.port }, "API server started");
|
|
240
|
+
});
|
|
241
|
+
}).catch((err) => {
|
|
242
|
+
console.error("Failed to start server:", err);
|
|
243
|
+
process.exit(1);
|
|
244
|
+
});
|
|
245
|
+
`;
|
|
246
|
+
}
|
|
247
|
+
export function apiAppTs(a) {
|
|
248
|
+
const isMongo = a.database === "mongodb-mongoose";
|
|
249
|
+
const dbImport = isMongo
|
|
250
|
+
? `import { connectDb } from "@saas/database";`
|
|
251
|
+
: `import { db } from "@saas/database";`;
|
|
252
|
+
const rateLimitImport = a.rateLimit !== "none"
|
|
253
|
+
? `import { rateLimitMiddleware } from "./middleware/rateLimit.js";`
|
|
254
|
+
: "";
|
|
255
|
+
const authImport = a.includeAuth
|
|
256
|
+
? `import { authMiddleware } from "@saas/auth";`
|
|
257
|
+
: "";
|
|
258
|
+
const queueImport = a.includeQueue
|
|
259
|
+
? `import { createQueues } from "@saas/queue";
|
|
260
|
+
import { getRedisClient } from "@saas/redis";`
|
|
261
|
+
: "";
|
|
262
|
+
const queueSetup = a.includeQueue
|
|
263
|
+
? `\n const redis = getRedisClient();
|
|
264
|
+
const _queues = createQueues(redis);`
|
|
265
|
+
: "";
|
|
266
|
+
const mongoSetup = isMongo ? `\n await connectDb();` : "";
|
|
267
|
+
const rateLimitUse = a.rateLimit !== "none" ? `\n app.use(rateLimitMiddleware());` : "";
|
|
268
|
+
const authUse = a.includeAuth
|
|
269
|
+
? `\n // Protected routes — attach auth middleware where needed\n // app.use("/api/v1", authMiddleware(), ...routes);`
|
|
270
|
+
: "";
|
|
271
|
+
return `import express, { type Express } from "express";
|
|
272
|
+
import helmet from "helmet";
|
|
273
|
+
import cors from "cors";
|
|
274
|
+
import { createLogger } from "@saas/logger";
|
|
275
|
+
${dbImport}
|
|
276
|
+
${queueImport}
|
|
277
|
+
${rateLimitImport}
|
|
278
|
+
${authImport}
|
|
279
|
+
import { healthRouter } from "./routes/health.js";
|
|
280
|
+
|
|
281
|
+
export async function createApp(): Promise<Express> {
|
|
282
|
+
const app = express();
|
|
283
|
+
const logger = createLogger("api");
|
|
284
|
+
${mongoSetup}
|
|
285
|
+
${queueSetup}
|
|
286
|
+
|
|
287
|
+
app.use(helmet());
|
|
288
|
+
app.use(cors());
|
|
289
|
+
app.use(express.json({ limit: "1mb" }));
|
|
290
|
+
${rateLimitUse}
|
|
291
|
+
${authUse}
|
|
292
|
+
|
|
293
|
+
app.use("/health", healthRouter());
|
|
294
|
+
|
|
295
|
+
// TODO: add your routes here
|
|
296
|
+
// app.use("/api/v1", tenantMiddleware(), yourRouter());
|
|
297
|
+
|
|
298
|
+
app.use(
|
|
299
|
+
(
|
|
300
|
+
err: Error,
|
|
301
|
+
_req: express.Request,
|
|
302
|
+
res: express.Response,
|
|
303
|
+
_next: express.NextFunction
|
|
304
|
+
) => {
|
|
305
|
+
logger.error({ err }, "Unhandled error");
|
|
306
|
+
res.status(500).json({ success: false, error: { message: err.message } });
|
|
307
|
+
}
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
return app;
|
|
311
|
+
}
|
|
312
|
+
`;
|
|
313
|
+
}
|
|
314
|
+
export function apiHealthRouteTs() {
|
|
315
|
+
return `import { Router } from "express";
|
|
316
|
+
|
|
317
|
+
export function healthRouter(): Router {
|
|
318
|
+
const router = Router();
|
|
319
|
+
|
|
320
|
+
router.get("/", (_req, res) => {
|
|
321
|
+
res.json({ status: "ok", timestamp: new Date().toISOString() });
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
return router;
|
|
325
|
+
}
|
|
326
|
+
`;
|
|
327
|
+
}
|
|
328
|
+
export function apiRateLimitTs(strategy) {
|
|
329
|
+
if (strategy === "redis") {
|
|
330
|
+
return `import rateLimit from "express-rate-limit";
|
|
331
|
+
import { RedisStore } from "rate-limit-redis";
|
|
332
|
+
import { getRedisClient } from "@saas/redis";
|
|
333
|
+
import type { RequestHandler } from "express";
|
|
334
|
+
|
|
335
|
+
export function rateLimitMiddleware(): RequestHandler {
|
|
336
|
+
const client = getRedisClient();
|
|
337
|
+
return rateLimit({
|
|
338
|
+
windowMs: parseInt(process.env["RATE_LIMIT_WINDOW_MS"] ?? "60000"),
|
|
339
|
+
max: parseInt(process.env["RATE_LIMIT_MAX_REQUESTS"] ?? "100"),
|
|
340
|
+
standardHeaders: true,
|
|
341
|
+
legacyHeaders: false,
|
|
342
|
+
store: new RedisStore({ sendCommand: (...args) => client.call(...args) }),
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
`;
|
|
346
|
+
}
|
|
347
|
+
return `import rateLimit from "express-rate-limit";
|
|
348
|
+
import type { RequestHandler } from "express";
|
|
349
|
+
|
|
350
|
+
export function rateLimitMiddleware(): RequestHandler {
|
|
351
|
+
return rateLimit({
|
|
352
|
+
windowMs: parseInt(process.env["RATE_LIMIT_WINDOW_MS"] ?? "60000"),
|
|
353
|
+
max: parseInt(process.env["RATE_LIMIT_MAX_REQUESTS"] ?? "100"),
|
|
354
|
+
standardHeaders: true,
|
|
355
|
+
legacyHeaders: false,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
`;
|
|
359
|
+
}
|
|
360
|
+
// ─── Worker source files ──────────────────────────────────────────────────────
|
|
361
|
+
export function workerIndexTs() {
|
|
362
|
+
return `import { Worker } from "bullmq";
|
|
363
|
+
import { getRedisClient } from "@saas/redis";
|
|
364
|
+
import { createLogger } from "@saas/logger";
|
|
365
|
+
|
|
366
|
+
const logger = createLogger("worker");
|
|
367
|
+
const redis = getRedisClient();
|
|
368
|
+
|
|
369
|
+
const worker = new Worker(
|
|
370
|
+
"default",
|
|
371
|
+
async (job) => {
|
|
372
|
+
logger.info({ jobId: job.id, name: job.name }, "Processing job");
|
|
373
|
+
// TODO: handle your jobs here
|
|
374
|
+
},
|
|
375
|
+
{ connection: redis }
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
worker.on("completed", (job) => {
|
|
379
|
+
logger.info({ jobId: job.id }, "Job completed");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
worker.on("failed", (job, err) => {
|
|
383
|
+
logger.error({ jobId: job?.id, err }, "Job failed");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
logger.info("Worker started, listening for jobs...");
|
|
387
|
+
`;
|
|
388
|
+
}
|
|
389
|
+
// ─── Database package ─────────────────────────────────────────────────────────
|
|
390
|
+
export function dbPackageJson(db) {
|
|
391
|
+
const deps = {};
|
|
392
|
+
const devDeps = {
|
|
393
|
+
"@saas/typescript-config": "*",
|
|
394
|
+
typescript: "5.7.3",
|
|
395
|
+
};
|
|
396
|
+
const scripts = {
|
|
397
|
+
"check-types": "tsc --noEmit",
|
|
398
|
+
};
|
|
399
|
+
if (db === "mongodb-mongoose") {
|
|
400
|
+
deps["mongoose"] = "^8.0.0";
|
|
401
|
+
}
|
|
402
|
+
else if (db === "postgres-drizzle" || db === "sqlite-drizzle") {
|
|
403
|
+
deps["drizzle-orm"] = "^0.40.0";
|
|
404
|
+
if (db === "postgres-drizzle")
|
|
405
|
+
deps["postgres"] = "^3.4.5";
|
|
406
|
+
if (db === "sqlite-drizzle")
|
|
407
|
+
deps["better-sqlite3"] = "^9.0.0";
|
|
408
|
+
devDeps["drizzle-kit"] = "^0.30.4";
|
|
409
|
+
scripts["db:generate"] = "drizzle-kit generate";
|
|
410
|
+
scripts["db:migrate"] = "drizzle-kit migrate";
|
|
411
|
+
scripts["db:push"] = "drizzle-kit push";
|
|
412
|
+
scripts["db:studio"] = "drizzle-kit studio";
|
|
413
|
+
}
|
|
414
|
+
else if (db === "postgres-prisma") {
|
|
415
|
+
deps["@prisma/client"] = "^5.0.0";
|
|
416
|
+
devDeps["prisma"] = "^5.0.0";
|
|
417
|
+
scripts["db:generate"] = "prisma generate";
|
|
418
|
+
scripts["db:migrate"] = "prisma migrate dev";
|
|
419
|
+
scripts["db:push"] = "prisma db push";
|
|
420
|
+
scripts["db:studio"] = "prisma studio";
|
|
421
|
+
}
|
|
422
|
+
return JSON.stringify({
|
|
423
|
+
name: "@saas/database",
|
|
424
|
+
version: "0.1.0",
|
|
425
|
+
private: true,
|
|
426
|
+
type: "module",
|
|
427
|
+
main: "./src/index.ts",
|
|
428
|
+
exports: { ".": "./src/index.ts" },
|
|
429
|
+
scripts,
|
|
430
|
+
dependencies: deps,
|
|
431
|
+
devDependencies: devDeps,
|
|
432
|
+
}, null, 2);
|
|
433
|
+
}
|
|
434
|
+
export function dbIndexTs(db) {
|
|
435
|
+
if (db === "mongodb-mongoose") {
|
|
436
|
+
return `import mongoose from "mongoose";
|
|
437
|
+
|
|
438
|
+
export async function connectDb(): Promise<void> {
|
|
439
|
+
const uri = process.env["MONGODB_URI"];
|
|
440
|
+
if (!uri) throw new Error("MONGODB_URI is not set");
|
|
441
|
+
await mongoose.connect(uri);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export { mongoose };
|
|
445
|
+
`;
|
|
446
|
+
}
|
|
447
|
+
if (db === "postgres-drizzle") {
|
|
448
|
+
return `import { drizzle } from "drizzle-orm/postgres-js";
|
|
449
|
+
import postgres from "postgres";
|
|
450
|
+
|
|
451
|
+
const connectionString = process.env["DATABASE_URL"];
|
|
452
|
+
if (!connectionString) throw new Error("DATABASE_URL is not set");
|
|
453
|
+
|
|
454
|
+
const client = postgres(connectionString);
|
|
455
|
+
export const db = drizzle(client);
|
|
456
|
+
`;
|
|
457
|
+
}
|
|
458
|
+
if (db === "postgres-prisma") {
|
|
459
|
+
return `import { PrismaClient } from "@prisma/client";
|
|
460
|
+
|
|
461
|
+
const globalForPrisma = globalThis as { prisma?: PrismaClient };
|
|
462
|
+
export const db = globalForPrisma.prisma ?? new PrismaClient();
|
|
463
|
+
if (process.env["NODE_ENV"] !== "production") globalForPrisma.prisma = db;
|
|
464
|
+
`;
|
|
465
|
+
}
|
|
466
|
+
// sqlite-drizzle
|
|
467
|
+
return `import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
468
|
+
import Database from "better-sqlite3";
|
|
469
|
+
|
|
470
|
+
const sqlite = new Database("./local.db");
|
|
471
|
+
export const db = drizzle(sqlite);
|
|
472
|
+
`;
|
|
473
|
+
}
|
|
474
|
+
// ─── Config package ───────────────────────────────────────────────────────────
|
|
475
|
+
export function configPackageJson() {
|
|
476
|
+
return JSON.stringify({
|
|
477
|
+
name: "@saas/config",
|
|
478
|
+
version: "0.1.0",
|
|
479
|
+
private: true,
|
|
480
|
+
type: "module",
|
|
481
|
+
main: "./src/index.ts",
|
|
482
|
+
exports: { ".": "./src/index.ts" },
|
|
483
|
+
scripts: { "check-types": "tsc --noEmit" },
|
|
484
|
+
devDependencies: {
|
|
485
|
+
"@saas/typescript-config": "*",
|
|
486
|
+
typescript: "5.7.3",
|
|
487
|
+
},
|
|
488
|
+
}, null, 2);
|
|
489
|
+
}
|
|
490
|
+
export function configIndexTs() {
|
|
491
|
+
return `export const config = {
|
|
492
|
+
port: parseInt(process.env["PORT"] ?? "3000"),
|
|
493
|
+
nodeEnv: process.env["NODE_ENV"] ?? "development",
|
|
494
|
+
isDev: process.env["NODE_ENV"] !== "production",
|
|
495
|
+
jwtSecret: process.env["JWT_SECRET"] ?? "change-me",
|
|
496
|
+
jwtExpiresIn: process.env["JWT_EXPIRES_IN"] ?? "7d",
|
|
497
|
+
adminSecret: process.env["ADMIN_SECRET"] ?? "change-me",
|
|
498
|
+
logLevel: process.env["LOG_LEVEL"] ?? "info",
|
|
499
|
+
} as const;
|
|
500
|
+
`;
|
|
501
|
+
}
|
|
502
|
+
// ─── Logger package ───────────────────────────────────────────────────────────
|
|
503
|
+
export function loggerPackageJson() {
|
|
504
|
+
return JSON.stringify({
|
|
505
|
+
name: "@saas/logger",
|
|
506
|
+
version: "0.1.0",
|
|
507
|
+
private: true,
|
|
508
|
+
type: "module",
|
|
509
|
+
main: "./src/index.ts",
|
|
510
|
+
exports: { ".": "./src/index.ts" },
|
|
511
|
+
scripts: { "check-types": "tsc --noEmit" },
|
|
512
|
+
dependencies: { pino: "^9.0.0" },
|
|
513
|
+
devDependencies: {
|
|
514
|
+
"@saas/typescript-config": "*",
|
|
515
|
+
"@types/node": "^22.0.0",
|
|
516
|
+
"pino-pretty": "^13.0.0",
|
|
517
|
+
typescript: "5.7.3",
|
|
518
|
+
},
|
|
519
|
+
}, null, 2);
|
|
520
|
+
}
|
|
521
|
+
export function loggerIndexTs() {
|
|
522
|
+
return `import pino from "pino";
|
|
523
|
+
|
|
524
|
+
export function createLogger(name: string) {
|
|
525
|
+
return pino({
|
|
526
|
+
name,
|
|
527
|
+
level: process.env["LOG_LEVEL"] ?? "info",
|
|
528
|
+
transport:
|
|
529
|
+
process.env["NODE_ENV"] !== "production"
|
|
530
|
+
? { target: "pino-pretty", options: { colorize: true } }
|
|
531
|
+
: undefined,
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export type Logger = ReturnType<typeof createLogger>;
|
|
536
|
+
`;
|
|
537
|
+
}
|
|
538
|
+
// ─── Auth package ─────────────────────────────────────────────────────────────
|
|
539
|
+
export function authPackageJson() {
|
|
540
|
+
return JSON.stringify({
|
|
541
|
+
name: "@saas/auth",
|
|
542
|
+
version: "0.1.0",
|
|
543
|
+
private: true,
|
|
544
|
+
type: "module",
|
|
545
|
+
main: "./src/index.ts",
|
|
546
|
+
exports: { ".": "./src/index.ts" },
|
|
547
|
+
scripts: { "check-types": "tsc --noEmit" },
|
|
548
|
+
dependencies: {
|
|
549
|
+
jsonwebtoken: "^9.0.0",
|
|
550
|
+
bcryptjs: "^2.4.3",
|
|
551
|
+
"@saas/config": "*",
|
|
552
|
+
},
|
|
553
|
+
devDependencies: {
|
|
554
|
+
"@saas/typescript-config": "*",
|
|
555
|
+
"@types/jsonwebtoken": "^9.0.0",
|
|
556
|
+
"@types/bcryptjs": "^2.4.0",
|
|
557
|
+
typescript: "5.7.3",
|
|
558
|
+
},
|
|
559
|
+
}, null, 2);
|
|
560
|
+
}
|
|
561
|
+
export function authIndexTs() {
|
|
562
|
+
return `import jwt from "jsonwebtoken";
|
|
563
|
+
import bcrypt from "bcryptjs";
|
|
564
|
+
import { config } from "@saas/config";
|
|
565
|
+
import type { RequestHandler } from "express";
|
|
566
|
+
|
|
567
|
+
export function signToken(payload: Record<string, unknown>): string {
|
|
568
|
+
return jwt.sign(payload, config.jwtSecret, {
|
|
569
|
+
expiresIn: config.jwtExpiresIn as jwt.SignOptions["expiresIn"],
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export function verifyToken(token: string): jwt.JwtPayload {
|
|
574
|
+
return jwt.verify(token, config.jwtSecret) as jwt.JwtPayload;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export async function hashPassword(password: string): Promise<string> {
|
|
578
|
+
return bcrypt.hash(password, 12);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export async function comparePassword(
|
|
582
|
+
password: string,
|
|
583
|
+
hash: string
|
|
584
|
+
): Promise<boolean> {
|
|
585
|
+
return bcrypt.compare(password, hash);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export function authMiddleware(): RequestHandler {
|
|
589
|
+
return (req, res, next) => {
|
|
590
|
+
const header = req.headers["authorization"];
|
|
591
|
+
if (!header?.startsWith("Bearer ")) {
|
|
592
|
+
res.status(401).json({ success: false, error: { message: "Unauthorized" } });
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
try {
|
|
596
|
+
const token = header.slice(7);
|
|
597
|
+
const payload = verifyToken(token);
|
|
598
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
599
|
+
(req as any).user = payload;
|
|
600
|
+
next();
|
|
601
|
+
} catch {
|
|
602
|
+
res.status(401).json({ success: false, error: { message: "Invalid token" } });
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
`;
|
|
607
|
+
}
|
|
608
|
+
// ─── Queue package ────────────────────────────────────────────────────────────
|
|
609
|
+
export function queuePackageJson() {
|
|
610
|
+
return JSON.stringify({
|
|
611
|
+
name: "@saas/queue",
|
|
612
|
+
version: "0.1.0",
|
|
613
|
+
private: true,
|
|
614
|
+
type: "module",
|
|
615
|
+
main: "./src/index.ts",
|
|
616
|
+
exports: { ".": "./src/index.ts" },
|
|
617
|
+
scripts: { "check-types": "tsc --noEmit" },
|
|
618
|
+
dependencies: {
|
|
619
|
+
bullmq: "^5.0.0",
|
|
620
|
+
"@saas/redis": "*",
|
|
621
|
+
},
|
|
622
|
+
devDependencies: {
|
|
623
|
+
"@saas/typescript-config": "*",
|
|
624
|
+
typescript: "5.7.3",
|
|
625
|
+
},
|
|
626
|
+
}, null, 2);
|
|
627
|
+
}
|
|
628
|
+
export function queueIndexTs() {
|
|
629
|
+
return `import { Queue } from "bullmq";
|
|
630
|
+
import type { Redis } from "ioredis";
|
|
631
|
+
|
|
632
|
+
export function createQueues(redis: Redis) {
|
|
633
|
+
const defaultQueue = new Queue("default", { connection: redis });
|
|
634
|
+
const emailQueue = new Queue("email", { connection: redis });
|
|
635
|
+
|
|
636
|
+
return { defaultQueue, emailQueue };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
export type Queues = ReturnType<typeof createQueues>;
|
|
640
|
+
`;
|
|
641
|
+
}
|
|
642
|
+
// ─── Redis package ────────────────────────────────────────────────────────────
|
|
643
|
+
export function redisPackageJson() {
|
|
644
|
+
return JSON.stringify({
|
|
645
|
+
name: "@saas/redis",
|
|
646
|
+
version: "0.1.0",
|
|
647
|
+
private: true,
|
|
648
|
+
type: "module",
|
|
649
|
+
main: "./src/index.ts",
|
|
650
|
+
exports: { ".": "./src/index.ts" },
|
|
651
|
+
scripts: { "check-types": "tsc --noEmit" },
|
|
652
|
+
dependencies: { ioredis: "^5.3.0" },
|
|
653
|
+
devDependencies: {
|
|
654
|
+
"@saas/typescript-config": "*",
|
|
655
|
+
typescript: "5.7.3",
|
|
656
|
+
},
|
|
657
|
+
}, null, 2);
|
|
658
|
+
}
|
|
659
|
+
export function redisIndexTs() {
|
|
660
|
+
return `import Redis from "ioredis";
|
|
661
|
+
|
|
662
|
+
let client: Redis | null = null;
|
|
663
|
+
|
|
664
|
+
export function getRedisClient(): Redis {
|
|
665
|
+
if (!client) {
|
|
666
|
+
const url = process.env["REDIS_URL"] ?? "redis://localhost:6379";
|
|
667
|
+
client = new Redis(url, { maxRetriesPerRequest: null });
|
|
668
|
+
}
|
|
669
|
+
return client;
|
|
670
|
+
}
|
|
671
|
+
`;
|
|
672
|
+
}
|
|
673
|
+
// ─── Types package ────────────────────────────────────────────────────────────
|
|
674
|
+
export function typesPackageJson() {
|
|
675
|
+
return JSON.stringify({
|
|
676
|
+
name: "@saas/types",
|
|
677
|
+
version: "0.1.0",
|
|
678
|
+
private: true,
|
|
679
|
+
type: "module",
|
|
680
|
+
main: "./src/index.ts",
|
|
681
|
+
exports: { ".": "./src/index.ts" },
|
|
682
|
+
scripts: { "check-types": "tsc --noEmit" },
|
|
683
|
+
devDependencies: {
|
|
684
|
+
"@saas/typescript-config": "*",
|
|
685
|
+
typescript: "5.7.3",
|
|
686
|
+
},
|
|
687
|
+
}, null, 2);
|
|
688
|
+
}
|
|
689
|
+
export function typesIndexTs() {
|
|
690
|
+
return `export interface Tenant {
|
|
691
|
+
id: string;
|
|
692
|
+
name: string;
|
|
693
|
+
slug: string;
|
|
694
|
+
plan: "free" | "pro" | "enterprise";
|
|
695
|
+
createdAt: Date;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
export interface ApiResponse<T = unknown> {
|
|
699
|
+
success: boolean;
|
|
700
|
+
data?: T;
|
|
701
|
+
error?: {
|
|
702
|
+
code?: string;
|
|
703
|
+
message: string;
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
export interface PaginatedResponse<T> extends ApiResponse<T[]> {
|
|
708
|
+
pagination: {
|
|
709
|
+
page: number;
|
|
710
|
+
pageSize: number;
|
|
711
|
+
total: number;
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
`;
|
|
715
|
+
}
|
|
716
|
+
// ─── TypeScript config package ────────────────────────────────────────────────
|
|
717
|
+
export function typescriptConfigPackageJson() {
|
|
718
|
+
return JSON.stringify({
|
|
719
|
+
name: "@saas/typescript-config",
|
|
720
|
+
version: "0.1.0",
|
|
721
|
+
private: true,
|
|
722
|
+
type: "module",
|
|
723
|
+
exports: { "./base.json": "./base.json" },
|
|
724
|
+
}, null, 2);
|
|
725
|
+
}
|
|
726
|
+
export function typescriptConfigBase() {
|
|
727
|
+
return JSON.stringify({
|
|
728
|
+
$schema: "https://json.schemastore.org/tsconfig",
|
|
729
|
+
display: "Base",
|
|
730
|
+
compilerOptions: {
|
|
731
|
+
target: "ES2022",
|
|
732
|
+
module: "NodeNext",
|
|
733
|
+
moduleResolution: "NodeNext",
|
|
734
|
+
strict: true,
|
|
735
|
+
esModuleInterop: true,
|
|
736
|
+
skipLibCheck: true,
|
|
737
|
+
declaration: true,
|
|
738
|
+
declarationMap: true,
|
|
739
|
+
sourceMap: true,
|
|
740
|
+
},
|
|
741
|
+
}, null, 2);
|
|
742
|
+
}
|
|
743
|
+
// ─── GitHub Actions CI ───────────────────────────────────────────────────────
|
|
744
|
+
export function githubActionsCiWorkflow(a) {
|
|
745
|
+
const isBun = a.packageManager === "bun";
|
|
746
|
+
const isPnpm = a.packageManager === "pnpm";
|
|
747
|
+
const setupRuntime = isBun
|
|
748
|
+
? ` - name: Setup Bun
|
|
749
|
+
uses: oven-sh/setup-bun@v2
|
|
750
|
+
with:
|
|
751
|
+
bun-version: 1.3.8`
|
|
752
|
+
: ` - name: Setup Node.js
|
|
753
|
+
uses: actions/setup-node@v4
|
|
754
|
+
with:
|
|
755
|
+
node-version: 20
|
|
756
|
+
cache: ${isPnpm ? "pnpm" : "npm"}`;
|
|
757
|
+
const corepackStep = isPnpm
|
|
758
|
+
? ` - name: Enable Corepack
|
|
759
|
+
run: corepack enable
|
|
760
|
+
`
|
|
761
|
+
: "";
|
|
762
|
+
const installCmd = isBun
|
|
763
|
+
? "bun install --frozen-lockfile"
|
|
764
|
+
: isPnpm
|
|
765
|
+
? "pnpm install --frozen-lockfile"
|
|
766
|
+
: "npm ci";
|
|
767
|
+
const runCmd = isBun ? "bun run" : `${a.packageManager} run`;
|
|
768
|
+
return `name: CI
|
|
769
|
+
|
|
770
|
+
on:
|
|
771
|
+
push:
|
|
772
|
+
branches: ["main"]
|
|
773
|
+
pull_request:
|
|
774
|
+
|
|
775
|
+
permissions:
|
|
776
|
+
contents: read
|
|
777
|
+
|
|
778
|
+
jobs:
|
|
779
|
+
checks:
|
|
780
|
+
runs-on: ubuntu-latest
|
|
781
|
+
steps:
|
|
782
|
+
- name: Checkout
|
|
783
|
+
uses: actions/checkout@v4
|
|
784
|
+
${setupRuntime}
|
|
785
|
+
${corepackStep} - name: Install dependencies
|
|
786
|
+
run: ${installCmd}
|
|
787
|
+
- name: Type check
|
|
788
|
+
run: ${runCmd} check-types
|
|
789
|
+
- name: Lint
|
|
790
|
+
run: ${runCmd} lint
|
|
791
|
+
- name: Test
|
|
792
|
+
run: ${runCmd} test
|
|
793
|
+
- name: Build
|
|
794
|
+
run: ${runCmd} build
|
|
795
|
+
`;
|
|
796
|
+
}
|
|
797
|
+
// ─── Docker compose ───────────────────────────────────────────────────────────
|
|
798
|
+
export function dockerComposeTemplate(a) {
|
|
799
|
+
const isMongo = a.database === "mongodb-mongoose";
|
|
800
|
+
const needsRedis = a.includeQueue || a.rateLimit === "redis" || a.includeWorker;
|
|
801
|
+
const mongoService = isMongo
|
|
802
|
+
? `
|
|
803
|
+
mongo:
|
|
804
|
+
image: mongo:7
|
|
805
|
+
restart: unless-stopped
|
|
806
|
+
ports:
|
|
807
|
+
- "27017:27017"
|
|
808
|
+
volumes:
|
|
809
|
+
- mongo_data:/data/db
|
|
810
|
+
healthcheck:
|
|
811
|
+
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
|
|
812
|
+
interval: 10s
|
|
813
|
+
timeout: 5s
|
|
814
|
+
retries: 5
|
|
815
|
+
`
|
|
816
|
+
: `
|
|
817
|
+
postgres:
|
|
818
|
+
image: postgres:16-alpine
|
|
819
|
+
restart: unless-stopped
|
|
820
|
+
environment:
|
|
821
|
+
POSTGRES_USER: saas
|
|
822
|
+
POSTGRES_PASSWORD: saaspassword
|
|
823
|
+
POSTGRES_DB: saas
|
|
824
|
+
ports:
|
|
825
|
+
- "5432:5432"
|
|
826
|
+
volumes:
|
|
827
|
+
- postgres_data:/var/lib/postgresql/data
|
|
828
|
+
healthcheck:
|
|
829
|
+
test: ["CMD-SHELL", "pg_isready -U saas -d saas"]
|
|
830
|
+
interval: 10s
|
|
831
|
+
timeout: 5s
|
|
832
|
+
retries: 5
|
|
833
|
+
`;
|
|
834
|
+
const redisService = needsRedis
|
|
835
|
+
? `
|
|
836
|
+
redis:
|
|
837
|
+
image: redis:7-alpine
|
|
838
|
+
restart: unless-stopped
|
|
839
|
+
command: redis-server --appendonly yes
|
|
840
|
+
ports:
|
|
841
|
+
- "6379:6379"
|
|
842
|
+
volumes:
|
|
843
|
+
- redis_data:/data
|
|
844
|
+
healthcheck:
|
|
845
|
+
test: ["CMD", "redis-cli", "ping"]
|
|
846
|
+
interval: 10s
|
|
847
|
+
timeout: 5s
|
|
848
|
+
retries: 5
|
|
849
|
+
`
|
|
850
|
+
: "";
|
|
851
|
+
const obsServices = a.includeObservability
|
|
852
|
+
? `
|
|
853
|
+
loki:
|
|
854
|
+
image: grafana/loki:latest
|
|
855
|
+
restart: unless-stopped
|
|
856
|
+
ports:
|
|
857
|
+
- "3100:3100"
|
|
858
|
+
volumes:
|
|
859
|
+
- loki_data:/loki
|
|
860
|
+
|
|
861
|
+
prometheus:
|
|
862
|
+
image: prom/prometheus:latest
|
|
863
|
+
restart: unless-stopped
|
|
864
|
+
ports:
|
|
865
|
+
- "9090:9090"
|
|
866
|
+
volumes:
|
|
867
|
+
- ./observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
|
868
|
+
- prometheus_data:/prometheus
|
|
869
|
+
|
|
870
|
+
grafana:
|
|
871
|
+
image: grafana/grafana:latest
|
|
872
|
+
restart: unless-stopped
|
|
873
|
+
ports:
|
|
874
|
+
- "3001:3000"
|
|
875
|
+
environment:
|
|
876
|
+
GF_SECURITY_ADMIN_USER: admin
|
|
877
|
+
GF_SECURITY_ADMIN_PASSWORD: admin
|
|
878
|
+
volumes:
|
|
879
|
+
- grafana_data:/var/lib/grafana
|
|
880
|
+
- ./observability/grafana/provisioning:/etc/grafana/provisioning
|
|
881
|
+
depends_on:
|
|
882
|
+
- prometheus
|
|
883
|
+
- loki
|
|
884
|
+
`
|
|
885
|
+
: "";
|
|
886
|
+
const dbDepends = isMongo ? `mongo` : `postgres`;
|
|
887
|
+
const redisDepends = needsRedis
|
|
888
|
+
? `\n redis:\n condition: service_healthy`
|
|
889
|
+
: "";
|
|
890
|
+
const apiService = `
|
|
891
|
+
api:
|
|
892
|
+
build:
|
|
893
|
+
context: ..
|
|
894
|
+
dockerfile: apps/api/Dockerfile
|
|
895
|
+
restart: unless-stopped
|
|
896
|
+
ports:
|
|
897
|
+
- "3000:3000"
|
|
898
|
+
environment:
|
|
899
|
+
NODE_ENV: production
|
|
900
|
+
PORT: 3000
|
|
901
|
+
${isMongo ? `MONGODB_URI: mongodb://mongo:27017/${a.projectName}` : `DATABASE_URL: postgres://saas:saaspassword@${dbDepends}:5432/saas`}
|
|
902
|
+
${needsRedis ? "REDIS_URL: redis://redis:6379" : ""}
|
|
903
|
+
depends_on:
|
|
904
|
+
${dbDepends}:
|
|
905
|
+
condition: service_healthy${redisDepends}
|
|
906
|
+
`;
|
|
907
|
+
const workerService = a.includeWorker
|
|
908
|
+
? `
|
|
909
|
+
worker:
|
|
910
|
+
build:
|
|
911
|
+
context: ..
|
|
912
|
+
dockerfile: apps/worker/Dockerfile
|
|
913
|
+
restart: unless-stopped
|
|
914
|
+
environment:
|
|
915
|
+
NODE_ENV: production
|
|
916
|
+
${isMongo ? `MONGODB_URI: mongodb://mongo:27017/${a.projectName}` : `DATABASE_URL: postgres://saas:saaspassword@${dbDepends}:5432/saas`}
|
|
917
|
+
REDIS_URL: redis://redis:6379
|
|
918
|
+
depends_on:
|
|
919
|
+
${dbDepends}:
|
|
920
|
+
condition: service_healthy
|
|
921
|
+
redis:
|
|
922
|
+
condition: service_healthy
|
|
923
|
+
`
|
|
924
|
+
: "";
|
|
925
|
+
const webService = a.includeWeb
|
|
926
|
+
? `
|
|
927
|
+
web:
|
|
928
|
+
build:
|
|
929
|
+
context: ..
|
|
930
|
+
dockerfile: apps/web/Dockerfile
|
|
931
|
+
restart: unless-stopped
|
|
932
|
+
ports:
|
|
933
|
+
- "3001:3001"
|
|
934
|
+
environment:
|
|
935
|
+
NODE_ENV: production
|
|
936
|
+
NEXT_PUBLIC_API_URL: http://api:3000
|
|
937
|
+
depends_on:
|
|
938
|
+
- api
|
|
939
|
+
`
|
|
940
|
+
: "";
|
|
941
|
+
const volumes = [
|
|
942
|
+
isMongo ? " mongo_data:" : " postgres_data:",
|
|
943
|
+
needsRedis ? " redis_data:" : "",
|
|
944
|
+
a.includeObservability
|
|
945
|
+
? " loki_data:\n prometheus_data:\n grafana_data:"
|
|
946
|
+
: "",
|
|
947
|
+
]
|
|
948
|
+
.filter(Boolean)
|
|
949
|
+
.join("\n");
|
|
950
|
+
return `services:
|
|
951
|
+
${mongoService}${redisService}${obsServices}${apiService}${workerService}${webService}
|
|
952
|
+
volumes:
|
|
953
|
+
${volumes}
|
|
954
|
+
`;
|
|
955
|
+
}
|
|
956
|
+
// ─── Dockerfile templates ─────────────────────────────────────────────────────
|
|
957
|
+
export function apiDockerfile(a) {
|
|
958
|
+
const installCmd = a.packageManager === "bun"
|
|
959
|
+
? "RUN bun install --frozen-lockfile"
|
|
960
|
+
: a.packageManager === "pnpm"
|
|
961
|
+
? "RUN pnpm install --frozen-lockfile"
|
|
962
|
+
: "RUN npm ci";
|
|
963
|
+
const buildCmd = a.packageManager === "bun"
|
|
964
|
+
? "RUN bun run build --filter=@saas/api"
|
|
965
|
+
: a.packageManager === "pnpm"
|
|
966
|
+
? "RUN pnpm run build --filter=@saas/api"
|
|
967
|
+
: "RUN npm run build --filter=@saas/api";
|
|
968
|
+
const baseImage = a.packageManager === "bun" ? "oven/bun:1" : "node:22-alpine";
|
|
969
|
+
return `FROM ${baseImage} AS base
|
|
970
|
+
WORKDIR /app
|
|
971
|
+
|
|
972
|
+
FROM base AS builder
|
|
973
|
+
COPY package.json turbo.json ./
|
|
974
|
+
COPY apps/api/package.json ./apps/api/
|
|
975
|
+
COPY packages/ ./packages/
|
|
976
|
+
${installCmd}
|
|
977
|
+
COPY . .
|
|
978
|
+
${buildCmd}
|
|
979
|
+
|
|
980
|
+
FROM node:22-alpine AS runner
|
|
981
|
+
WORKDIR /app/apps/api
|
|
982
|
+
COPY --from=builder /app/apps/api/dist ./dist
|
|
983
|
+
COPY --from=builder /app/apps/api/package.json ./
|
|
984
|
+
RUN npm install --production
|
|
985
|
+
EXPOSE 3000
|
|
986
|
+
CMD ["node", "dist/index.js"]
|
|
987
|
+
`;
|
|
988
|
+
}
|
|
989
|
+
export function workerDockerfile(a) {
|
|
990
|
+
const installCmd = a.packageManager === "bun"
|
|
991
|
+
? "RUN bun install --frozen-lockfile"
|
|
992
|
+
: a.packageManager === "pnpm"
|
|
993
|
+
? "RUN pnpm install --frozen-lockfile"
|
|
994
|
+
: "RUN npm ci";
|
|
995
|
+
const buildCmd = a.packageManager === "bun"
|
|
996
|
+
? "RUN bun run build --filter=@saas/worker"
|
|
997
|
+
: a.packageManager === "pnpm"
|
|
998
|
+
? "RUN pnpm run build --filter=@saas/worker"
|
|
999
|
+
: "RUN npm run build --filter=@saas/worker";
|
|
1000
|
+
const baseImage = a.packageManager === "bun" ? "oven/bun:1" : "node:22-alpine";
|
|
1001
|
+
return `FROM ${baseImage} AS base
|
|
1002
|
+
WORKDIR /app
|
|
1003
|
+
|
|
1004
|
+
FROM base AS builder
|
|
1005
|
+
COPY package.json turbo.json ./
|
|
1006
|
+
COPY apps/worker/package.json ./apps/worker/
|
|
1007
|
+
COPY packages/ ./packages/
|
|
1008
|
+
${installCmd}
|
|
1009
|
+
COPY . .
|
|
1010
|
+
${buildCmd}
|
|
1011
|
+
|
|
1012
|
+
FROM node:22-alpine AS runner
|
|
1013
|
+
WORKDIR /app/apps/worker
|
|
1014
|
+
COPY --from=builder /app/apps/worker/dist ./dist
|
|
1015
|
+
COPY --from=builder /app/apps/worker/package.json ./
|
|
1016
|
+
RUN npm install --production
|
|
1017
|
+
CMD ["node", "dist/index.js"]
|
|
1018
|
+
`;
|
|
1019
|
+
}
|
|
1020
|
+
// ─── README ───────────────────────────────────────────────────────────────────
|
|
1021
|
+
export function rootReadme(a) {
|
|
1022
|
+
const pm = a.packageManager;
|
|
1023
|
+
const isMongo = a.database === "mongodb-mongoose";
|
|
1024
|
+
return `# ${a.projectName}
|
|
1025
|
+
|
|
1026
|
+
> Scaffolded with [create-saas-app](https://github.com/you/create-saas-app) 🚀
|
|
1027
|
+
|
|
1028
|
+
A production-ready **Multi-Tenant SaaS** Turborepo monorepo.
|
|
1029
|
+
|
|
1030
|
+
## Stack
|
|
1031
|
+
|
|
1032
|
+
| Layer | Choice |
|
|
1033
|
+
|-------|--------|
|
|
1034
|
+
| Runtime | Node.js 22+ |
|
|
1035
|
+
| Framework | Express |
|
|
1036
|
+
| Database | ${a.database} |
|
|
1037
|
+
| Queue | ${a.includeQueue ? "BullMQ + Redis" : "—"} |
|
|
1038
|
+
| Auth | ${a.includeAuth ? "JWT (jsonwebtoken + bcryptjs)" : "—"} |
|
|
1039
|
+
| Rate Limiting | ${a.rateLimit === "none" ? "—" : a.rateLimit === "redis" ? "Redis-backed (rate-limit-redis)" : "In-memory (express-rate-limit)"} |
|
|
1040
|
+
| Monorepo | Turborepo |
|
|
1041
|
+
| Build | TypeScript |
|
|
1042
|
+
|
|
1043
|
+
## Getting Started
|
|
1044
|
+
|
|
1045
|
+
\`\`\`bash
|
|
1046
|
+
# 1. Install dependencies
|
|
1047
|
+
${pm} install
|
|
1048
|
+
|
|
1049
|
+
# 2. Start infrastructure
|
|
1050
|
+
docker compose -f docker/docker-compose.yml up -d
|
|
1051
|
+
|
|
1052
|
+
# 3. Copy & fill in env vars
|
|
1053
|
+
cp apps/api/.env.example apps/api/.env
|
|
1054
|
+
${isMongo ? "" : "\n# 4. Run migrations\n" + pm + " run db:migrate"}
|
|
1055
|
+
|
|
1056
|
+
# ${isMongo ? "4" : "5"}. Start dev servers
|
|
1057
|
+
${pm} run dev
|
|
1058
|
+
\`\`\`
|
|
1059
|
+
|
|
1060
|
+
## Project Structure
|
|
1061
|
+
|
|
1062
|
+
\`\`\`
|
|
1063
|
+
${a.projectName}/
|
|
1064
|
+
├── apps/
|
|
1065
|
+
│ ├── api/ # Express REST API
|
|
1066
|
+
${a.includeWorker ? "│ └── worker/ # BullMQ background worker\n" : ""}└── packages/
|
|
1067
|
+
├── config/ # Shared env config
|
|
1068
|
+
├── database/ # DB client & models
|
|
1069
|
+
├── logger/ # Pino logger
|
|
1070
|
+
${a.includeAuth ? " ├── auth/ # JWT auth utilities\n" : ""}${a.includeQueue ? " ├── queue/ # BullMQ queue definitions\n ├── redis/ # Redis client\n" : ""} ├── types/ # Shared TypeScript types
|
|
1071
|
+
└── typescript-config/
|
|
1072
|
+
\`\`\`
|
|
1073
|
+
|
|
1074
|
+
## Scripts
|
|
1075
|
+
|
|
1076
|
+
| Command | Description |
|
|
1077
|
+
|---------|-------------|
|
|
1078
|
+
| \`${pm} run dev\` | Start all apps in watch mode |
|
|
1079
|
+
| \`${pm} run build\` | Build all packages |
|
|
1080
|
+
| \`${pm} run lint\` | Lint all packages |
|
|
1081
|
+
| \`${pm} run check-types\` | Type-check all packages |
|
|
1082
|
+
${isMongo ? "" : "| `" + pm + " run db:generate` | Generate DB migrations |\n| `" + pm + " run db:migrate` | Run DB migrations |"}
|
|
1083
|
+
|
|
1084
|
+
---
|
|
1085
|
+
|
|
1086
|
+
_Generated by \`create-saas-app\`_
|
|
1087
|
+
`;
|
|
1088
|
+
}
|
|
1089
|
+
// ─── Drizzle config ───────────────────────────────────────────────────────────
|
|
1090
|
+
export function drizzleConfigTs(db) {
|
|
1091
|
+
if (db === "sqlite-drizzle") {
|
|
1092
|
+
return `import { defineConfig } from "drizzle-kit";
|
|
1093
|
+
|
|
1094
|
+
export default defineConfig({
|
|
1095
|
+
schema: "./src/schema.ts",
|
|
1096
|
+
out: "./drizzle",
|
|
1097
|
+
dialect: "sqlite",
|
|
1098
|
+
dbCredentials: {
|
|
1099
|
+
url: "./local.db",
|
|
1100
|
+
},
|
|
1101
|
+
});
|
|
1102
|
+
`;
|
|
1103
|
+
}
|
|
1104
|
+
// postgres-drizzle
|
|
1105
|
+
return `import { defineConfig } from "drizzle-kit";
|
|
1106
|
+
|
|
1107
|
+
export default defineConfig({
|
|
1108
|
+
schema: "./src/schema.ts",
|
|
1109
|
+
out: "./drizzle",
|
|
1110
|
+
dialect: "postgresql",
|
|
1111
|
+
dbCredentials: {
|
|
1112
|
+
url: process.env["DATABASE_URL"]!,
|
|
1113
|
+
},
|
|
1114
|
+
});
|
|
1115
|
+
`;
|
|
1116
|
+
}
|
|
1117
|
+
export function drizzleSchemaTs(db) {
|
|
1118
|
+
if (db === "sqlite-drizzle") {
|
|
1119
|
+
return `import { text, integer, sqliteTable } from "drizzle-orm/sqlite-core";
|
|
1120
|
+
|
|
1121
|
+
export const tenants = sqliteTable("tenants", {
|
|
1122
|
+
id: text("id").primaryKey(),
|
|
1123
|
+
name: text("name").notNull(),
|
|
1124
|
+
slug: text("slug").notNull().unique(),
|
|
1125
|
+
plan: text("plan", { enum: ["free", "pro", "enterprise"] }).notNull().default("free"),
|
|
1126
|
+
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
export type Tenant = typeof tenants.$inferSelect;
|
|
1130
|
+
export type NewTenant = typeof tenants.$inferInsert;
|
|
1131
|
+
`;
|
|
1132
|
+
}
|
|
1133
|
+
// postgres-drizzle
|
|
1134
|
+
return `import { pgTable, text, timestamp, pgEnum } from "drizzle-orm/pg-core";
|
|
1135
|
+
|
|
1136
|
+
export const planEnum = pgEnum("plan", ["free", "pro", "enterprise"]);
|
|
1137
|
+
|
|
1138
|
+
export const tenants = pgTable("tenants", {
|
|
1139
|
+
id: text("id").primaryKey(),
|
|
1140
|
+
name: text("name").notNull(),
|
|
1141
|
+
slug: text("slug").notNull().unique(),
|
|
1142
|
+
plan: planEnum("plan").notNull().default("free"),
|
|
1143
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
export type Tenant = typeof tenants.$inferSelect;
|
|
1147
|
+
export type NewTenant = typeof tenants.$inferInsert;
|
|
1148
|
+
`;
|
|
1149
|
+
}
|
|
1150
|
+
// ─── Prisma schema ────────────────────────────────────────────────────────────
|
|
1151
|
+
export function prismaSchemaTemplate(projectName) {
|
|
1152
|
+
return `// This is your Prisma schema file.
|
|
1153
|
+
// Learn more about it in the docs: https://pris.ly/d/prisma-schema
|
|
1154
|
+
|
|
1155
|
+
generator client {
|
|
1156
|
+
provider = "prisma-client-js"
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
datasource db {
|
|
1160
|
+
provider = "postgresql"
|
|
1161
|
+
url = env("DATABASE_URL")
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
enum Plan {
|
|
1165
|
+
free
|
|
1166
|
+
pro
|
|
1167
|
+
enterprise
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
model Tenant {
|
|
1171
|
+
id String @id @default(cuid())
|
|
1172
|
+
name String
|
|
1173
|
+
slug String @unique
|
|
1174
|
+
plan Plan @default(free)
|
|
1175
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
1176
|
+
|
|
1177
|
+
@@map("tenants")
|
|
1178
|
+
}
|
|
1179
|
+
`;
|
|
1180
|
+
}
|
|
1181
|
+
// ─── Vitest config ────────────────────────────────────────────────────────────
|
|
1182
|
+
export function vitestConfigTs() {
|
|
1183
|
+
return `import { defineConfig } from "vitest/config";
|
|
1184
|
+
|
|
1185
|
+
export default defineConfig({
|
|
1186
|
+
test: {
|
|
1187
|
+
globals: true,
|
|
1188
|
+
environment: "node",
|
|
1189
|
+
coverage: {
|
|
1190
|
+
reporter: ["text", "lcov"],
|
|
1191
|
+
exclude: ["node_modules/", "dist/"],
|
|
1192
|
+
},
|
|
1193
|
+
},
|
|
1194
|
+
});
|
|
1195
|
+
`;
|
|
1196
|
+
}
|
|
1197
|
+
// ─── ESLint flat config ───────────────────────────────────────────────────────
|
|
1198
|
+
export function eslintConfigTs() {
|
|
1199
|
+
return `import js from "@eslint/js";
|
|
1200
|
+
import tsPlugin from "@typescript-eslint/eslint-plugin";
|
|
1201
|
+
import tsParser from "@typescript-eslint/parser";
|
|
1202
|
+
import globals from "globals";
|
|
1203
|
+
|
|
1204
|
+
/** @type {import("eslint").Linter.FlatConfig[]} */
|
|
1205
|
+
export default [
|
|
1206
|
+
js.configs.recommended,
|
|
1207
|
+
{
|
|
1208
|
+
files: ["**/*.ts"],
|
|
1209
|
+
languageOptions: {
|
|
1210
|
+
parser: tsParser,
|
|
1211
|
+
parserOptions: { project: true },
|
|
1212
|
+
globals: { ...globals.node },
|
|
1213
|
+
},
|
|
1214
|
+
plugins: { "@typescript-eslint": tsPlugin },
|
|
1215
|
+
rules: {
|
|
1216
|
+
...tsPlugin.configs.recommended.rules,
|
|
1217
|
+
"@typescript-eslint/no-explicit-any": "warn",
|
|
1218
|
+
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
|
|
1219
|
+
},
|
|
1220
|
+
},
|
|
1221
|
+
{
|
|
1222
|
+
ignores: ["dist/", "node_modules/", "coverage/"],
|
|
1223
|
+
},
|
|
1224
|
+
];
|
|
1225
|
+
`;
|
|
1226
|
+
}
|
|
1227
|
+
export function eslintDevDeps() {
|
|
1228
|
+
return {
|
|
1229
|
+
"@eslint/js": "^9.0.0",
|
|
1230
|
+
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
1231
|
+
"@typescript-eslint/parser": "^8.0.0",
|
|
1232
|
+
eslint: "^9.0.0",
|
|
1233
|
+
globals: "^15.0.0",
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
// ─── Prettier config ──────────────────────────────────────────────────────────
|
|
1237
|
+
export function prettierRc() {
|
|
1238
|
+
return JSON.stringify({
|
|
1239
|
+
semi: true,
|
|
1240
|
+
singleQuote: false,
|
|
1241
|
+
tabWidth: 2,
|
|
1242
|
+
trailingComma: "all",
|
|
1243
|
+
printWidth: 100,
|
|
1244
|
+
}, null, 2);
|
|
1245
|
+
}
|
|
1246
|
+
// ─── Tenant middleware ────────────────────────────────────────────────────────
|
|
1247
|
+
export function tenantMiddlewareTs() {
|
|
1248
|
+
return `import type { RequestHandler } from "express";
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* Resolves the tenant from the request.
|
|
1252
|
+
* Extend this to look up the tenant from a DB using slug / subdomain / header.
|
|
1253
|
+
*/
|
|
1254
|
+
export function tenantMiddleware(): RequestHandler {
|
|
1255
|
+
return async (req, res, next) => {
|
|
1256
|
+
// Option A: resolve from subdomain (e.g. acme.yoursaas.com → "acme")
|
|
1257
|
+
// const host = req.hostname;
|
|
1258
|
+
// const slug = host.split(".")[0];
|
|
1259
|
+
|
|
1260
|
+
// Option B: resolve from a custom header X-Tenant-Slug: acme
|
|
1261
|
+
const slug = req.headers["x-tenant-slug"] as string | undefined;
|
|
1262
|
+
|
|
1263
|
+
if (!slug) {
|
|
1264
|
+
res.status(400).json({ success: false, error: { message: "Missing tenant identifier" } });
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// TODO: look up the tenant in your DB and attach it to req
|
|
1269
|
+
// const tenant = await db.query.tenants.findFirst({ where: eq(tenants.slug, slug) });
|
|
1270
|
+
// if (!tenant) { res.status(404).json({ ... }); return; }
|
|
1271
|
+
// (req as any).tenant = tenant;
|
|
1272
|
+
|
|
1273
|
+
// For now, just pass the slug along so you can start building
|
|
1274
|
+
(req as any).tenantSlug = slug;
|
|
1275
|
+
next();
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
`;
|
|
1279
|
+
}
|
|
1280
|
+
// ─── Worker .env.example ─────────────────────────────────────────────────────
|
|
1281
|
+
export function workerEnvExampleTemplate(a) {
|
|
1282
|
+
const isMongo = a.database === "mongodb-mongoose";
|
|
1283
|
+
const dbLine = isMongo
|
|
1284
|
+
? `MONGODB_URI=mongodb://localhost:27017/${a.projectName}`
|
|
1285
|
+
: `DATABASE_URL=postgres://saas:saaspassword@localhost:5432/saas`;
|
|
1286
|
+
return `# ─── Application ─────────────────────────────────────────────────────────────
|
|
1287
|
+
NODE_ENV=development
|
|
1288
|
+
|
|
1289
|
+
# ─── Database ─────────────────────────────────────────────────────────────────
|
|
1290
|
+
${dbLine}
|
|
1291
|
+
|
|
1292
|
+
# ─── Redis ────────────────────────────────────────────────────────────────────
|
|
1293
|
+
REDIS_URL=redis://localhost:6379
|
|
1294
|
+
|
|
1295
|
+
# ─── Observability ────────────────────────────────────────────────────────────
|
|
1296
|
+
LOG_LEVEL=info
|
|
1297
|
+
`;
|
|
1298
|
+
}
|
|
1299
|
+
// ─── Grafana provisioning ─────────────────────────────────────────────────────
|
|
1300
|
+
export function grafanaDatasourceYaml() {
|
|
1301
|
+
return `apiVersion: 1
|
|
1302
|
+
|
|
1303
|
+
datasources:
|
|
1304
|
+
- name: Prometheus
|
|
1305
|
+
type: prometheus
|
|
1306
|
+
access: proxy
|
|
1307
|
+
url: http://prometheus:9090
|
|
1308
|
+
isDefault: true
|
|
1309
|
+
editable: true
|
|
1310
|
+
|
|
1311
|
+
- name: Loki
|
|
1312
|
+
type: loki
|
|
1313
|
+
access: proxy
|
|
1314
|
+
url: http://loki:3100
|
|
1315
|
+
editable: true
|
|
1316
|
+
`;
|
|
1317
|
+
}
|
|
1318
|
+
// ─── Next.js 15 web app ───────────────────────────────────────────────────────
|
|
1319
|
+
export function webPackageJson(a) {
|
|
1320
|
+
const deps = {
|
|
1321
|
+
next: "15.2.0",
|
|
1322
|
+
react: "^19.0.0",
|
|
1323
|
+
"react-dom": "^19.0.0",
|
|
1324
|
+
"@saas/config": "*",
|
|
1325
|
+
"@saas/types": "*",
|
|
1326
|
+
};
|
|
1327
|
+
if (a.includeAuth)
|
|
1328
|
+
deps["@saas/auth"] = "*";
|
|
1329
|
+
if (a.emailProvider !== "none")
|
|
1330
|
+
deps["@saas/email"] = "*";
|
|
1331
|
+
return JSON.stringify({
|
|
1332
|
+
name: "@saas/web",
|
|
1333
|
+
version: "0.1.0",
|
|
1334
|
+
private: true,
|
|
1335
|
+
scripts: {
|
|
1336
|
+
dev: "next dev --turbopack",
|
|
1337
|
+
build: "next build",
|
|
1338
|
+
start: "next start",
|
|
1339
|
+
lint: "next lint",
|
|
1340
|
+
"check-types": "tsc --noEmit",
|
|
1341
|
+
},
|
|
1342
|
+
dependencies: deps,
|
|
1343
|
+
devDependencies: {
|
|
1344
|
+
"@saas/typescript-config": "*",
|
|
1345
|
+
"@types/node": "^22.0.0",
|
|
1346
|
+
"@types/react": "^19.0.0",
|
|
1347
|
+
"@types/react-dom": "^19.0.0",
|
|
1348
|
+
typescript: "5.7.3",
|
|
1349
|
+
},
|
|
1350
|
+
}, null, 2);
|
|
1351
|
+
}
|
|
1352
|
+
export function webNextConfig() {
|
|
1353
|
+
return `import type { NextConfig } from "next";
|
|
1354
|
+
|
|
1355
|
+
const nextConfig: NextConfig = {
|
|
1356
|
+
output: "standalone",
|
|
1357
|
+
transpilePackages: [
|
|
1358
|
+
"@saas/config",
|
|
1359
|
+
"@saas/types",
|
|
1360
|
+
"@saas/auth",
|
|
1361
|
+
"@saas/email",
|
|
1362
|
+
],
|
|
1363
|
+
};
|
|
1364
|
+
|
|
1365
|
+
export default nextConfig;
|
|
1366
|
+
`;
|
|
1367
|
+
}
|
|
1368
|
+
export function webTsconfig() {
|
|
1369
|
+
return JSON.stringify({
|
|
1370
|
+
extends: "@saas/typescript-config/base.json",
|
|
1371
|
+
compilerOptions: {
|
|
1372
|
+
target: "ES2017",
|
|
1373
|
+
lib: ["dom", "dom.iterable", "esnext"],
|
|
1374
|
+
allowJs: true,
|
|
1375
|
+
skipLibCheck: true,
|
|
1376
|
+
strict: true,
|
|
1377
|
+
noEmit: true,
|
|
1378
|
+
esModuleInterop: true,
|
|
1379
|
+
module: "esnext",
|
|
1380
|
+
moduleResolution: "bundler",
|
|
1381
|
+
resolveJsonModule: true,
|
|
1382
|
+
isolatedModules: true,
|
|
1383
|
+
jsx: "preserve",
|
|
1384
|
+
incremental: true,
|
|
1385
|
+
plugins: [{ name: "next" }],
|
|
1386
|
+
paths: { "@/*": ["./src/*"] },
|
|
1387
|
+
},
|
|
1388
|
+
include: ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
1389
|
+
exclude: ["node_modules"],
|
|
1390
|
+
}, null, 2);
|
|
1391
|
+
}
|
|
1392
|
+
export function webRootLayout(a) {
|
|
1393
|
+
return `import type { Metadata } from "next";
|
|
1394
|
+
import { Inter } from "next/font/google";
|
|
1395
|
+
import "./globals.css";
|
|
1396
|
+
|
|
1397
|
+
const inter = Inter({ subsets: ["latin"] });
|
|
1398
|
+
|
|
1399
|
+
export const metadata: Metadata = {
|
|
1400
|
+
title: "${a.projectName}",
|
|
1401
|
+
description: "Multi-Tenant SaaS Application",
|
|
1402
|
+
};
|
|
1403
|
+
|
|
1404
|
+
export default function RootLayout({
|
|
1405
|
+
children,
|
|
1406
|
+
}: {
|
|
1407
|
+
children: React.ReactNode;
|
|
1408
|
+
}) {
|
|
1409
|
+
return (
|
|
1410
|
+
<html lang="en">
|
|
1411
|
+
<body className={inter.className}>{children}</body>
|
|
1412
|
+
</html>
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
`;
|
|
1416
|
+
}
|
|
1417
|
+
export function webGlobalsCss() {
|
|
1418
|
+
return `@import "tailwindcss";
|
|
1419
|
+
`;
|
|
1420
|
+
}
|
|
1421
|
+
export function webHomePage(a) {
|
|
1422
|
+
return `export default function HomePage() {
|
|
1423
|
+
return (
|
|
1424
|
+
<main style={{ fontFamily: "system-ui, sans-serif", maxWidth: 680, margin: "80px auto", padding: "0 1rem" }}>
|
|
1425
|
+
<h1 style={{ fontSize: "2.5rem", fontWeight: 700, marginBottom: "0.5rem" }}>
|
|
1426
|
+
Welcome to <span style={{ color: "#0070f3" }}>${a.projectName}</span>
|
|
1427
|
+
</h1>
|
|
1428
|
+
<p style={{ color: "#666", fontSize: "1.125rem", marginBottom: "2rem" }}>
|
|
1429
|
+
Your production-ready Multi-Tenant SaaS platform.
|
|
1430
|
+
</p>
|
|
1431
|
+
|
|
1432
|
+
<div style={{ display: "flex", gap: "1rem", flexWrap: "wrap" }}>
|
|
1433
|
+
<a
|
|
1434
|
+
href="/dashboard"
|
|
1435
|
+
style={{
|
|
1436
|
+
padding: "0.75rem 1.5rem",
|
|
1437
|
+
background: "#0070f3",
|
|
1438
|
+
color: "#fff",
|
|
1439
|
+
borderRadius: 8,
|
|
1440
|
+
textDecoration: "none",
|
|
1441
|
+
fontWeight: 600,
|
|
1442
|
+
}}
|
|
1443
|
+
>
|
|
1444
|
+
Dashboard →
|
|
1445
|
+
</a>
|
|
1446
|
+
<a
|
|
1447
|
+
href="/login"
|
|
1448
|
+
style={{
|
|
1449
|
+
padding: "0.75rem 1.5rem",
|
|
1450
|
+
background: "#f5f5f5",
|
|
1451
|
+
color: "#333",
|
|
1452
|
+
borderRadius: 8,
|
|
1453
|
+
textDecoration: "none",
|
|
1454
|
+
fontWeight: 600,
|
|
1455
|
+
}}
|
|
1456
|
+
>
|
|
1457
|
+
Sign In
|
|
1458
|
+
</a>
|
|
1459
|
+
</div>
|
|
1460
|
+
</main>
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1463
|
+
`;
|
|
1464
|
+
}
|
|
1465
|
+
export function webDashboardPage() {
|
|
1466
|
+
return `// Protected — add your auth check here (e.g. read a cookie, call the API)
|
|
1467
|
+
export default function DashboardPage() {
|
|
1468
|
+
return (
|
|
1469
|
+
<main style={{ fontFamily: "system-ui, sans-serif", maxWidth: 900, margin: "40px auto", padding: "0 1rem" }}>
|
|
1470
|
+
<h1 style={{ fontSize: "1.75rem", fontWeight: 700 }}>Dashboard</h1>
|
|
1471
|
+
<p style={{ color: "#666" }}>You are signed in. Build your SaaS here.</p>
|
|
1472
|
+
|
|
1473
|
+
<div
|
|
1474
|
+
style={{
|
|
1475
|
+
background: "#f9f9f9",
|
|
1476
|
+
border: "1px solid #eee",
|
|
1477
|
+
borderRadius: 12,
|
|
1478
|
+
padding: "2rem",
|
|
1479
|
+
marginTop: "2rem",
|
|
1480
|
+
}}
|
|
1481
|
+
>
|
|
1482
|
+
<p style={{ margin: 0, color: "#888" }}>
|
|
1483
|
+
📦 Workspace ready — start adding your features.
|
|
1484
|
+
</p>
|
|
1485
|
+
</div>
|
|
1486
|
+
</main>
|
|
1487
|
+
);
|
|
1488
|
+
}
|
|
1489
|
+
`;
|
|
1490
|
+
}
|
|
1491
|
+
export function webLoginPage() {
|
|
1492
|
+
return `"use client";
|
|
1493
|
+
import { useState } from "react";
|
|
1494
|
+
|
|
1495
|
+
export default function LoginPage() {
|
|
1496
|
+
const [email, setEmail] = useState("");
|
|
1497
|
+
const [password, setPassword] = useState("");
|
|
1498
|
+
const [loading, setLoading] = useState(false);
|
|
1499
|
+
const [error, setError] = useState<string | null>(null);
|
|
1500
|
+
|
|
1501
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
1502
|
+
e.preventDefault();
|
|
1503
|
+
setLoading(true);
|
|
1504
|
+
setError(null);
|
|
1505
|
+
try {
|
|
1506
|
+
const res = await fetch(
|
|
1507
|
+
\`\${process.env["NEXT_PUBLIC_API_URL"]}/api/v1/auth/login\`,
|
|
1508
|
+
{
|
|
1509
|
+
method: "POST",
|
|
1510
|
+
headers: { "Content-Type": "application/json" },
|
|
1511
|
+
body: JSON.stringify({ email, password }),
|
|
1512
|
+
}
|
|
1513
|
+
);
|
|
1514
|
+
const data = await res.json();
|
|
1515
|
+
if (!res.ok) throw new Error(data.error?.message ?? "Login failed");
|
|
1516
|
+
// TODO: store token (cookie / localStorage) and redirect
|
|
1517
|
+
window.location.href = "/dashboard";
|
|
1518
|
+
} catch (err: unknown) {
|
|
1519
|
+
setError(err instanceof Error ? err.message : "Login failed");
|
|
1520
|
+
} finally {
|
|
1521
|
+
setLoading(false);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
return (
|
|
1526
|
+
<main
|
|
1527
|
+
style={{
|
|
1528
|
+
fontFamily: "system-ui, sans-serif",
|
|
1529
|
+
maxWidth: 400,
|
|
1530
|
+
margin: "100px auto",
|
|
1531
|
+
padding: "0 1rem",
|
|
1532
|
+
}}
|
|
1533
|
+
>
|
|
1534
|
+
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, marginBottom: "1.5rem" }}>
|
|
1535
|
+
Sign in
|
|
1536
|
+
</h1>
|
|
1537
|
+
{error && (
|
|
1538
|
+
<p style={{ color: "#e00", marginBottom: "1rem", fontSize: "0.9rem" }}>
|
|
1539
|
+
{error}
|
|
1540
|
+
</p>
|
|
1541
|
+
)}
|
|
1542
|
+
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
|
1543
|
+
<input
|
|
1544
|
+
type="email"
|
|
1545
|
+
placeholder="Email"
|
|
1546
|
+
value={email}
|
|
1547
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
1548
|
+
required
|
|
1549
|
+
style={{ padding: "0.75rem", borderRadius: 8, border: "1px solid #ddd", fontSize: "1rem" }}
|
|
1550
|
+
/>
|
|
1551
|
+
<input
|
|
1552
|
+
type="password"
|
|
1553
|
+
placeholder="Password"
|
|
1554
|
+
value={password}
|
|
1555
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
1556
|
+
required
|
|
1557
|
+
style={{ padding: "0.75rem", borderRadius: 8, border: "1px solid #ddd", fontSize: "1rem" }}
|
|
1558
|
+
/>
|
|
1559
|
+
<button
|
|
1560
|
+
type="submit"
|
|
1561
|
+
disabled={loading}
|
|
1562
|
+
style={{
|
|
1563
|
+
padding: "0.75rem",
|
|
1564
|
+
background: "#0070f3",
|
|
1565
|
+
color: "#fff",
|
|
1566
|
+
border: "none",
|
|
1567
|
+
borderRadius: 8,
|
|
1568
|
+
fontWeight: 600,
|
|
1569
|
+
fontSize: "1rem",
|
|
1570
|
+
cursor: "pointer",
|
|
1571
|
+
opacity: loading ? 0.7 : 1,
|
|
1572
|
+
}}
|
|
1573
|
+
>
|
|
1574
|
+
{loading ? "Signing in…" : "Sign in"}
|
|
1575
|
+
</button>
|
|
1576
|
+
</form>
|
|
1577
|
+
</main>
|
|
1578
|
+
);
|
|
1579
|
+
}
|
|
1580
|
+
`;
|
|
1581
|
+
}
|
|
1582
|
+
export function webMiddleware() {
|
|
1583
|
+
return `import { NextResponse } from "next/server";
|
|
1584
|
+
import type { NextRequest } from "next/server";
|
|
1585
|
+
|
|
1586
|
+
const PUBLIC_PATHS = ["/", "/login", "/api/auth"];
|
|
1587
|
+
|
|
1588
|
+
export function middleware(request: NextRequest) {
|
|
1589
|
+
const { pathname } = request.nextUrl;
|
|
1590
|
+
|
|
1591
|
+
const isPublic = PUBLIC_PATHS.some(
|
|
1592
|
+
(p) => pathname === p || pathname.startsWith("/api/auth")
|
|
1593
|
+
);
|
|
1594
|
+
|
|
1595
|
+
if (isPublic) return NextResponse.next();
|
|
1596
|
+
|
|
1597
|
+
// Read the JWT stored in an httpOnly cookie after login
|
|
1598
|
+
const token = request.cookies.get("auth-token")?.value;
|
|
1599
|
+
|
|
1600
|
+
if (!token) {
|
|
1601
|
+
const loginUrl = request.nextUrl.clone();
|
|
1602
|
+
loginUrl.pathname = "/login";
|
|
1603
|
+
return NextResponse.redirect(loginUrl);
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// NOTE: full JWT verification should happen in your API, not here.
|
|
1607
|
+
// Middleware only does a lightweight "token present" check for UX.
|
|
1608
|
+
return NextResponse.next();
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
export const config = {
|
|
1612
|
+
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
|
1613
|
+
};
|
|
1614
|
+
`;
|
|
1615
|
+
}
|
|
1616
|
+
export function webEnvExample(a) {
|
|
1617
|
+
return `# ─── API connection ───────────────────────────────────────────────────────────
|
|
1618
|
+
NEXT_PUBLIC_API_URL=http://localhost:3000
|
|
1619
|
+
|
|
1620
|
+
# ─── Auth ─────────────────────────────────────────────────────────────────────
|
|
1621
|
+
NEXT_PUBLIC_APP_URL=http://localhost:3001
|
|
1622
|
+
${a.includePayments ? `\n# ─── Razorpay ────────────────────────────────────────────────────────────────\nNEXT_PUBLIC_RAZORPAY_KEY_ID=rzp_test_xxxxxxxxxxxx` : ""}
|
|
1623
|
+
`;
|
|
1624
|
+
}
|
|
1625
|
+
export function webDockerfile(a) {
|
|
1626
|
+
const installCmd = a.packageManager === "bun"
|
|
1627
|
+
? "RUN bun install --frozen-lockfile"
|
|
1628
|
+
: a.packageManager === "pnpm"
|
|
1629
|
+
? "RUN pnpm install --frozen-lockfile"
|
|
1630
|
+
: "RUN npm ci";
|
|
1631
|
+
const baseImage = a.packageManager === "bun" ? "oven/bun:1" : "node:22-alpine";
|
|
1632
|
+
return `FROM ${baseImage} AS base
|
|
1633
|
+
WORKDIR /app
|
|
1634
|
+
|
|
1635
|
+
FROM base AS builder
|
|
1636
|
+
ENV NEXT_TELEMETRY_DISABLED=1
|
|
1637
|
+
COPY package.json turbo.json ./
|
|
1638
|
+
COPY apps/web/package.json ./apps/web/
|
|
1639
|
+
COPY packages/ ./packages/
|
|
1640
|
+
${installCmd}
|
|
1641
|
+
COPY . .
|
|
1642
|
+
RUN npm run build --filter=@saas/web
|
|
1643
|
+
|
|
1644
|
+
FROM node:22-alpine AS runner
|
|
1645
|
+
WORKDIR /app/apps/web
|
|
1646
|
+
ENV NODE_ENV=production
|
|
1647
|
+
ENV NEXT_TELEMETRY_DISABLED=1
|
|
1648
|
+
COPY --from=builder /app/apps/web/.next/standalone ./
|
|
1649
|
+
COPY --from=builder /app/apps/web/.next/static ./.next/static
|
|
1650
|
+
COPY --from=builder /app/apps/web/public ./public
|
|
1651
|
+
EXPOSE 3001
|
|
1652
|
+
CMD ["node", "server.js"]
|
|
1653
|
+
`;
|
|
1654
|
+
}
|
|
1655
|
+
// ─── Razorpay payments package ────────────────────────────────────────────────
|
|
1656
|
+
export function paymentsPackageJson() {
|
|
1657
|
+
return JSON.stringify({
|
|
1658
|
+
name: "@saas/payments",
|
|
1659
|
+
version: "0.1.0",
|
|
1660
|
+
private: true,
|
|
1661
|
+
type: "module",
|
|
1662
|
+
main: "./src/index.ts",
|
|
1663
|
+
exports: { ".": "./src/index.ts" },
|
|
1664
|
+
scripts: { "check-types": "tsc --noEmit" },
|
|
1665
|
+
dependencies: {
|
|
1666
|
+
razorpay: "^2.9.4",
|
|
1667
|
+
"@saas/config": "*",
|
|
1668
|
+
"@saas/logger": "*",
|
|
1669
|
+
},
|
|
1670
|
+
devDependencies: {
|
|
1671
|
+
"@saas/typescript-config": "*",
|
|
1672
|
+
"@types/node": "^22.0.0",
|
|
1673
|
+
typescript: "5.7.3",
|
|
1674
|
+
},
|
|
1675
|
+
}, null, 2);
|
|
1676
|
+
}
|
|
1677
|
+
export function paymentsIndexTs() {
|
|
1678
|
+
return `import Razorpay from "razorpay";
|
|
1679
|
+
import crypto from "node:crypto";
|
|
1680
|
+
import { createLogger } from "@saas/logger";
|
|
1681
|
+
|
|
1682
|
+
const logger = createLogger("payments");
|
|
1683
|
+
|
|
1684
|
+
let instance: Razorpay | null = null;
|
|
1685
|
+
|
|
1686
|
+
export function getRazorpayClient(): Razorpay {
|
|
1687
|
+
if (!instance) {
|
|
1688
|
+
const keyId = process.env["RAZORPAY_KEY_ID"];
|
|
1689
|
+
const keySecret = process.env["RAZORPAY_KEY_SECRET"];
|
|
1690
|
+
if (!keyId || !keySecret) {
|
|
1691
|
+
throw new Error("RAZORPAY_KEY_ID and RAZORPAY_KEY_SECRET must be set");
|
|
1692
|
+
}
|
|
1693
|
+
instance = new Razorpay({ key_id: keyId, key_secret: keySecret });
|
|
1694
|
+
}
|
|
1695
|
+
return instance;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
export interface CreateOrderOptions {
|
|
1699
|
+
amountInPaise: number; // e.g. 49900 = ₹499.00
|
|
1700
|
+
currency?: string;
|
|
1701
|
+
receipt?: string;
|
|
1702
|
+
notes?: Record<string, string>;
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
export async function createOrder(opts: CreateOrderOptions) {
|
|
1706
|
+
const rz = getRazorpayClient();
|
|
1707
|
+
const order = await rz.orders.create({
|
|
1708
|
+
amount: opts.amountInPaise,
|
|
1709
|
+
currency: opts.currency ?? "INR",
|
|
1710
|
+
receipt: opts.receipt,
|
|
1711
|
+
notes: opts.notes,
|
|
1712
|
+
});
|
|
1713
|
+
logger.info({ orderId: order.id, amount: opts.amountInPaise }, "Razorpay order created");
|
|
1714
|
+
return order;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
/**
|
|
1718
|
+
* Verify Razorpay webhook signature.
|
|
1719
|
+
* @param rawBody - Raw request body string (before JSON.parse)
|
|
1720
|
+
* @param signature - Value of the X-Razorpay-Signature header
|
|
1721
|
+
*/
|
|
1722
|
+
export function verifyWebhookSignature(rawBody: string, signature: string): boolean {
|
|
1723
|
+
const secret = process.env["RAZORPAY_WEBHOOK_SECRET"];
|
|
1724
|
+
if (!secret) throw new Error("RAZORPAY_WEBHOOK_SECRET is not set");
|
|
1725
|
+
const expected = crypto
|
|
1726
|
+
.createHmac("sha256", secret)
|
|
1727
|
+
.update(rawBody)
|
|
1728
|
+
.digest("hex");
|
|
1729
|
+
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
/**
|
|
1733
|
+
* Verify Razorpay payment signature (client-side callback verification).
|
|
1734
|
+
*/
|
|
1735
|
+
export function verifyPaymentSignature(
|
|
1736
|
+
orderId: string,
|
|
1737
|
+
paymentId: string,
|
|
1738
|
+
signature: string,
|
|
1739
|
+
): boolean {
|
|
1740
|
+
const secret = process.env["RAZORPAY_KEY_SECRET"];
|
|
1741
|
+
if (!secret) throw new Error("RAZORPAY_KEY_SECRET is not set");
|
|
1742
|
+
const body = \`\${orderId}|\${paymentId}\`;
|
|
1743
|
+
const expected = crypto
|
|
1744
|
+
.createHmac("sha256", secret)
|
|
1745
|
+
.update(body)
|
|
1746
|
+
.digest("hex");
|
|
1747
|
+
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
|
|
1748
|
+
}
|
|
1749
|
+
`;
|
|
1750
|
+
}
|
|
1751
|
+
export function paymentsRouteTs() {
|
|
1752
|
+
return `import { Router } from "express";
|
|
1753
|
+
import { createOrder, verifyWebhookSignature, verifyPaymentSignature } from "@saas/payments";
|
|
1754
|
+
import { createLogger } from "@saas/logger";
|
|
1755
|
+
import type { RequestHandler } from "express";
|
|
1756
|
+
|
|
1757
|
+
const logger = createLogger("payments-route");
|
|
1758
|
+
|
|
1759
|
+
export function paymentsRouter(): Router {
|
|
1760
|
+
const router = Router();
|
|
1761
|
+
|
|
1762
|
+
// POST /payments/order — create a Razorpay order
|
|
1763
|
+
router.post("/order", (async (req, res) => {
|
|
1764
|
+
const { amountInPaise, currency, receipt, notes } = req.body as {
|
|
1765
|
+
amountInPaise: number;
|
|
1766
|
+
currency?: string;
|
|
1767
|
+
receipt?: string;
|
|
1768
|
+
notes?: Record<string, string>;
|
|
1769
|
+
};
|
|
1770
|
+
|
|
1771
|
+
if (!amountInPaise || amountInPaise < 100) {
|
|
1772
|
+
res.status(400).json({ success: false, error: { message: "Invalid amount" } });
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
try {
|
|
1777
|
+
const order = await createOrder({ amountInPaise, currency, receipt, notes });
|
|
1778
|
+
res.json({ success: true, data: order });
|
|
1779
|
+
} catch (err) {
|
|
1780
|
+
logger.error({ err }, "Failed to create Razorpay order");
|
|
1781
|
+
res.status(500).json({ success: false, error: { message: "Failed to create order" } });
|
|
1782
|
+
}
|
|
1783
|
+
}) as RequestHandler);
|
|
1784
|
+
|
|
1785
|
+
// POST /payments/verify — verify payment after client callback
|
|
1786
|
+
router.post("/verify", ((req, res) => {
|
|
1787
|
+
const { razorpay_order_id, razorpay_payment_id, razorpay_signature } =
|
|
1788
|
+
req.body as {
|
|
1789
|
+
razorpay_order_id: string;
|
|
1790
|
+
razorpay_payment_id: string;
|
|
1791
|
+
razorpay_signature: string;
|
|
1792
|
+
};
|
|
1793
|
+
|
|
1794
|
+
const valid = verifyPaymentSignature(
|
|
1795
|
+
razorpay_order_id,
|
|
1796
|
+
razorpay_payment_id,
|
|
1797
|
+
razorpay_signature,
|
|
1798
|
+
);
|
|
1799
|
+
|
|
1800
|
+
if (!valid) {
|
|
1801
|
+
res.status(400).json({ success: false, error: { message: "Invalid payment signature" } });
|
|
1802
|
+
return;
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
// TODO: update subscription/plan in your DB here
|
|
1806
|
+
logger.info({ orderId: razorpay_order_id, paymentId: razorpay_payment_id }, "Payment verified");
|
|
1807
|
+
res.json({ success: true, data: { paymentId: razorpay_payment_id } });
|
|
1808
|
+
}) as RequestHandler);
|
|
1809
|
+
|
|
1810
|
+
// POST /payments/webhook — Razorpay server-to-server events
|
|
1811
|
+
router.post("/webhook", ((req, res) => {
|
|
1812
|
+
const signature = req.headers["x-razorpay-signature"] as string | undefined;
|
|
1813
|
+
if (!signature) {
|
|
1814
|
+
res.status(400).json({ success: false, error: { message: "Missing signature" } });
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
// express.raw() middleware must be applied to this route for rawBody access
|
|
1819
|
+
const rawBody =
|
|
1820
|
+
typeof req.body === "string" ? req.body : JSON.stringify(req.body);
|
|
1821
|
+
|
|
1822
|
+
try {
|
|
1823
|
+
const valid = verifyWebhookSignature(rawBody, signature);
|
|
1824
|
+
if (!valid) {
|
|
1825
|
+
res.status(400).json({ success: false, error: { message: "Invalid webhook signature" } });
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
} catch (err) {
|
|
1829
|
+
logger.error({ err }, "Webhook verification error");
|
|
1830
|
+
res.status(500).json({ success: false, error: { message: "Webhook error" } });
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
const event = req.body as { event: string; payload: unknown };
|
|
1835
|
+
logger.info({ event: event.event }, "Razorpay webhook received");
|
|
1836
|
+
|
|
1837
|
+
// Handle events
|
|
1838
|
+
switch (event.event) {
|
|
1839
|
+
case "payment.captured":
|
|
1840
|
+
// TODO: activate subscription
|
|
1841
|
+
break;
|
|
1842
|
+
case "payment.failed":
|
|
1843
|
+
// TODO: notify user
|
|
1844
|
+
break;
|
|
1845
|
+
case "subscription.charged":
|
|
1846
|
+
// TODO: update subscription period
|
|
1847
|
+
break;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
res.json({ success: true });
|
|
1851
|
+
}) as RequestHandler);
|
|
1852
|
+
|
|
1853
|
+
return router;
|
|
1854
|
+
}
|
|
1855
|
+
`;
|
|
1856
|
+
}
|
|
1857
|
+
// ─── Email package ────────────────────────────────────────────────────────────
|
|
1858
|
+
export function emailPackageJson(provider) {
|
|
1859
|
+
const deps = { "@saas/config": "*" };
|
|
1860
|
+
const devDeps = {
|
|
1861
|
+
"@saas/typescript-config": "*",
|
|
1862
|
+
"@types/node": "^22.0.0",
|
|
1863
|
+
typescript: "5.7.3",
|
|
1864
|
+
};
|
|
1865
|
+
if (provider === "resend") {
|
|
1866
|
+
deps["resend"] = "^4.0.0";
|
|
1867
|
+
}
|
|
1868
|
+
else {
|
|
1869
|
+
deps["nodemailer"] = "^6.9.0";
|
|
1870
|
+
devDeps["@types/nodemailer"] = "^6.4.0";
|
|
1871
|
+
}
|
|
1872
|
+
return JSON.stringify({
|
|
1873
|
+
name: "@saas/email",
|
|
1874
|
+
version: "0.1.0",
|
|
1875
|
+
private: true,
|
|
1876
|
+
type: "module",
|
|
1877
|
+
main: "./src/index.ts",
|
|
1878
|
+
exports: { ".": "./src/index.ts" },
|
|
1879
|
+
scripts: { "check-types": "tsc --noEmit" },
|
|
1880
|
+
dependencies: deps,
|
|
1881
|
+
devDependencies: devDeps,
|
|
1882
|
+
}, null, 2);
|
|
1883
|
+
}
|
|
1884
|
+
export function emailIndexTs(provider) {
|
|
1885
|
+
if (provider === "resend") {
|
|
1886
|
+
return `import { Resend } from "resend";
|
|
1887
|
+
|
|
1888
|
+
let client: Resend | null = null;
|
|
1889
|
+
|
|
1890
|
+
function getClient(): Resend {
|
|
1891
|
+
if (!client) {
|
|
1892
|
+
const apiKey = process.env["RESEND_API_KEY"];
|
|
1893
|
+
if (!apiKey) throw new Error("RESEND_API_KEY is not set");
|
|
1894
|
+
client = new Resend(apiKey);
|
|
1895
|
+
}
|
|
1896
|
+
return client;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
export interface SendEmailOptions {
|
|
1900
|
+
to: string | string[];
|
|
1901
|
+
subject: string;
|
|
1902
|
+
html: string;
|
|
1903
|
+
from?: string;
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
export async function sendEmail(opts: SendEmailOptions): Promise<void> {
|
|
1907
|
+
const from = opts.from ?? process.env["EMAIL_FROM"] ?? "noreply@yoursaas.com";
|
|
1908
|
+
const { error } = await getClient().emails.send({
|
|
1909
|
+
from,
|
|
1910
|
+
to: opts.to,
|
|
1911
|
+
subject: opts.subject,
|
|
1912
|
+
html: opts.html,
|
|
1913
|
+
});
|
|
1914
|
+
if (error) throw new Error(\`Failed to send email: \${error.message}\`);
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
// ─── Template helpers ──────────────────────────────────────────────────────────
|
|
1918
|
+
|
|
1919
|
+
export function welcomeEmail(name: string): string {
|
|
1920
|
+
return \`<h1>Welcome, \${name}!</h1><p>Thanks for signing up. Let's get started.</p>\`;
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
export function passwordResetEmail(resetUrl: string): string {
|
|
1924
|
+
return \`<h1>Reset your password</h1><p><a href="\${resetUrl}">Click here</a> to reset your password. This link expires in 1 hour.</p>\`;
|
|
1925
|
+
}
|
|
1926
|
+
`;
|
|
1927
|
+
}
|
|
1928
|
+
// nodemailer
|
|
1929
|
+
return `import nodemailer from "nodemailer";
|
|
1930
|
+
|
|
1931
|
+
let transporter: nodemailer.Transporter | null = null;
|
|
1932
|
+
|
|
1933
|
+
function getTransporter(): nodemailer.Transporter {
|
|
1934
|
+
if (!transporter) {
|
|
1935
|
+
transporter = nodemailer.createTransport({
|
|
1936
|
+
host: process.env["SMTP_HOST"] ?? "localhost",
|
|
1937
|
+
port: parseInt(process.env["SMTP_PORT"] ?? "587"),
|
|
1938
|
+
secure: process.env["SMTP_SECURE"] === "true",
|
|
1939
|
+
auth:
|
|
1940
|
+
process.env["SMTP_USER"] && process.env["SMTP_PASS"]
|
|
1941
|
+
? { user: process.env["SMTP_USER"], pass: process.env["SMTP_PASS"] }
|
|
1942
|
+
: undefined,
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1945
|
+
return transporter;
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
export interface SendEmailOptions {
|
|
1949
|
+
to: string | string[];
|
|
1950
|
+
subject: string;
|
|
1951
|
+
html: string;
|
|
1952
|
+
from?: string;
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
export async function sendEmail(opts: SendEmailOptions): Promise<void> {
|
|
1956
|
+
const from = opts.from ?? process.env["EMAIL_FROM"] ?? "noreply@yoursaas.com";
|
|
1957
|
+
await getTransporter().sendMail({ from, to: opts.to, subject: opts.subject, html: opts.html });
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
// ─── Template helpers ──────────────────────────────────────────────────────────
|
|
1961
|
+
|
|
1962
|
+
export function welcomeEmail(name: string): string {
|
|
1963
|
+
return \`<h1>Welcome, \${name}!</h1><p>Thanks for signing up. Let's get started.</p>\`;
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
export function passwordResetEmail(resetUrl: string): string {
|
|
1967
|
+
return \`<h1>Reset your password</h1><p><a href="\${resetUrl}">Click here</a> to reset your password. This link expires in 1 hour.</p>\`;
|
|
1968
|
+
}
|
|
1969
|
+
`;
|
|
1970
|
+
}
|
|
1971
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
1972
|
+
function dbDependencies(db) {
|
|
1973
|
+
if (db === "mongodb-mongoose")
|
|
1974
|
+
return { "@saas/database": "*", mongoose: "^8.0.0" };
|
|
1975
|
+
if (db === "postgres-drizzle")
|
|
1976
|
+
return {
|
|
1977
|
+
"@saas/database": "*",
|
|
1978
|
+
"drizzle-orm": "^0.40.0",
|
|
1979
|
+
postgres: "^3.4.5",
|
|
1980
|
+
};
|
|
1981
|
+
if (db === "postgres-prisma")
|
|
1982
|
+
return { "@saas/database": "*", "@prisma/client": "^5.0.0" };
|
|
1983
|
+
return {
|
|
1984
|
+
"@saas/database": "*",
|
|
1985
|
+
"drizzle-orm": "^0.40.0",
|
|
1986
|
+
"better-sqlite3": "^9.0.0",
|
|
1987
|
+
};
|
|
1988
|
+
}
|
|
1989
|
+
function rateLimitDependencies(r) {
|
|
1990
|
+
if (r === "none")
|
|
1991
|
+
return {};
|
|
1992
|
+
if (r === "redis")
|
|
1993
|
+
return { "express-rate-limit": "^7.0.0", "rate-limit-redis": "^4.0.0" };
|
|
1994
|
+
return { "express-rate-limit": "^7.0.0" };
|
|
1995
|
+
}
|
|
1996
|
+
//# sourceMappingURL=templates.js.map
|