@techstream/quark-create-app 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/README.md +38 -0
- package/package.json +34 -0
- package/src/index.js +611 -0
- package/templates/base-project/README.md +35 -0
- package/templates/base-project/apps/web/next.config.js +6 -0
- package/templates/base-project/apps/web/package.json +32 -0
- package/templates/base-project/apps/web/postcss.config.mjs +7 -0
- package/templates/base-project/apps/web/public/file.svg +1 -0
- package/templates/base-project/apps/web/public/globe.svg +1 -0
- package/templates/base-project/apps/web/public/next.svg +1 -0
- package/templates/base-project/apps/web/public/vercel.svg +1 -0
- package/templates/base-project/apps/web/public/window.svg +1 -0
- package/templates/base-project/apps/web/src/app/api/auth/[...nextauth]/route.js +4 -0
- package/templates/base-project/apps/web/src/app/api/auth/register/route.js +39 -0
- package/templates/base-project/apps/web/src/app/api/csrf/route.js +42 -0
- package/templates/base-project/apps/web/src/app/api/error-handler.js +21 -0
- package/templates/base-project/apps/web/src/app/api/health/route.js +78 -0
- package/templates/base-project/apps/web/src/app/api/posts/[id]/route.js +61 -0
- package/templates/base-project/apps/web/src/app/api/posts/route.js +34 -0
- package/templates/base-project/apps/web/src/app/api/users/[id]/route.js +54 -0
- package/templates/base-project/apps/web/src/app/api/users/route.js +36 -0
- package/templates/base-project/apps/web/src/app/favicon.ico +0 -0
- package/templates/base-project/apps/web/src/app/globals.css +26 -0
- package/templates/base-project/apps/web/src/app/layout.js +12 -0
- package/templates/base-project/apps/web/src/app/page.js +10 -0
- package/templates/base-project/apps/web/src/app/page.test.js +11 -0
- package/templates/base-project/apps/web/src/lib/auth-middleware.js +14 -0
- package/templates/base-project/apps/web/src/lib/auth.js +102 -0
- package/templates/base-project/apps/web/src/middleware.js +265 -0
- package/templates/base-project/apps/worker/package.json +28 -0
- package/templates/base-project/apps/worker/src/index.js +154 -0
- package/templates/base-project/apps/worker/src/index.test.js +19 -0
- package/templates/base-project/docker-compose.yml +40 -0
- package/templates/base-project/package.json +26 -0
- package/templates/base-project/packages/db/package.json +29 -0
- package/templates/base-project/packages/db/prisma/migrations/20260202061128_initial/migration.sql +176 -0
- package/templates/base-project/packages/db/prisma/migrations/migration_lock.toml +3 -0
- package/templates/base-project/packages/db/prisma/schema.prisma +147 -0
- package/templates/base-project/packages/db/prisma.config.ts +25 -0
- package/templates/base-project/packages/db/scripts/seed.js +47 -0
- package/templates/base-project/packages/db/src/client.js +52 -0
- package/templates/base-project/packages/db/src/generated/prisma/browser.ts +53 -0
- package/templates/base-project/packages/db/src/generated/prisma/client.ts +82 -0
- package/templates/base-project/packages/db/src/generated/prisma/commonInputTypes.ts +649 -0
- package/templates/base-project/packages/db/src/generated/prisma/enums.ts +19 -0
- package/templates/base-project/packages/db/src/generated/prisma/internal/class.ts +305 -0
- package/templates/base-project/packages/db/src/generated/prisma/internal/prismaNamespace.ts +1428 -0
- package/templates/base-project/packages/db/src/generated/prisma/internal/prismaNamespaceBrowser.ts +217 -0
- package/templates/base-project/packages/db/src/generated/prisma/models/Account.ts +2098 -0
- package/templates/base-project/packages/db/src/generated/prisma/models/AuditLog.ts +1805 -0
- package/templates/base-project/packages/db/src/generated/prisma/models/Job.ts +1737 -0
- package/templates/base-project/packages/db/src/generated/prisma/models/Post.ts +1762 -0
- package/templates/base-project/packages/db/src/generated/prisma/models/Session.ts +1738 -0
- package/templates/base-project/packages/db/src/generated/prisma/models/User.ts +2298 -0
- package/templates/base-project/packages/db/src/generated/prisma/models/VerificationToken.ts +1450 -0
- package/templates/base-project/packages/db/src/generated/prisma/models.ts +18 -0
- package/templates/base-project/packages/db/src/index.js +3 -0
- package/templates/base-project/packages/db/src/queries.js +267 -0
- package/templates/base-project/packages/db/src/queries.test.js +79 -0
- package/templates/base-project/packages/db/src/schemas.js +31 -0
- package/templates/base-project/pnpm-workspace.yaml +7 -0
- package/templates/base-project/turbo.json +25 -0
- package/templates/config/package.json +8 -0
- package/templates/config/src/index.js +21 -0
- package/templates/jobs/package.json +8 -0
- package/templates/jobs/src/definitions.js +9 -0
- package/templates/jobs/src/handlers.js +20 -0
- package/templates/jobs/src/index.js +2 -0
- package/templates/ui/package.json +11 -0
- package/templates/ui/src/button.js +19 -0
- package/templates/ui/src/card.js +14 -0
- package/templates/ui/src/index.js +3 -0
- package/templates/ui/src/input.js +11 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js Middleware
|
|
3
|
+
* Handles rate limiting, CORS, and security headers
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getAllowedOrigins } from "@techstream/quark-config/app-url";
|
|
7
|
+
import { NextResponse } from "next/server";
|
|
8
|
+
|
|
9
|
+
// Simple in-memory rate limiter (use Redis for production)
|
|
10
|
+
const rateLimit = new Map();
|
|
11
|
+
|
|
12
|
+
const RATE_LIMIT_CONFIG = {
|
|
13
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
14
|
+
maxRequests: {
|
|
15
|
+
api: 100, // 100 requests per 15 minutes for general API
|
|
16
|
+
auth: 5, // 5 requests per 15 minutes for auth endpoints
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Rate limiting implementation
|
|
22
|
+
*/
|
|
23
|
+
function checkRateLimit(ip, path) {
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
const key = `${ip}:${path}`;
|
|
26
|
+
|
|
27
|
+
// Determine which limit to use
|
|
28
|
+
const isAuthEndpoint = path.startsWith("/api/auth/");
|
|
29
|
+
const maxRequests = isAuthEndpoint
|
|
30
|
+
? RATE_LIMIT_CONFIG.maxRequests.auth
|
|
31
|
+
: RATE_LIMIT_CONFIG.maxRequests.api;
|
|
32
|
+
|
|
33
|
+
// Get or create rate limit record
|
|
34
|
+
const record = rateLimit.get(key) || {
|
|
35
|
+
count: 0,
|
|
36
|
+
resetTime: now + RATE_LIMIT_CONFIG.windowMs,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Reset if window has passed
|
|
40
|
+
if (now > record.resetTime) {
|
|
41
|
+
record.count = 0;
|
|
42
|
+
record.resetTime = now + RATE_LIMIT_CONFIG.windowMs;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Check if limit exceeded
|
|
46
|
+
if (record.count >= maxRequests) {
|
|
47
|
+
return {
|
|
48
|
+
limited: true,
|
|
49
|
+
resetTime: record.resetTime,
|
|
50
|
+
remaining: 0,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Increment counter
|
|
55
|
+
record.count++;
|
|
56
|
+
rateLimit.set(key, record);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
limited: false,
|
|
60
|
+
resetTime: record.resetTime,
|
|
61
|
+
remaining: maxRequests - record.count,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Clean up old rate limit records periodically
|
|
67
|
+
*/
|
|
68
|
+
setInterval(() => {
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
for (const [key, record] of rateLimit.entries()) {
|
|
71
|
+
if (now > record.resetTime) {
|
|
72
|
+
rateLimit.delete(key);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}, 60 * 1000); // Clean up every minute
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* CORS configuration
|
|
79
|
+
*/
|
|
80
|
+
const CORS_CONFIG = {
|
|
81
|
+
allowedOrigins: getAllowedOrigins(),
|
|
82
|
+
allowedMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
83
|
+
allowedHeaders: ["Content-Type", "Authorization", "X-CSRF-Token"],
|
|
84
|
+
exposedHeaders: [
|
|
85
|
+
"X-RateLimit-Limit",
|
|
86
|
+
"X-RateLimit-Remaining",
|
|
87
|
+
"X-RateLimit-Reset",
|
|
88
|
+
],
|
|
89
|
+
credentials: true,
|
|
90
|
+
maxAge: 86400, // 24 hours
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Security headers configuration
|
|
95
|
+
*/
|
|
96
|
+
const SECURITY_HEADERS = {
|
|
97
|
+
"X-DNS-Prefetch-Control": "on",
|
|
98
|
+
"Strict-Transport-Security": "max-age=63072000; includeSubDomains",
|
|
99
|
+
"X-Frame-Options": "SAMEORIGIN",
|
|
100
|
+
"X-Content-Type-Options": "nosniff",
|
|
101
|
+
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
102
|
+
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
|
|
103
|
+
"Content-Security-Policy":
|
|
104
|
+
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self';",
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Request size limits (in bytes)
|
|
109
|
+
*/
|
|
110
|
+
const REQUEST_SIZE_LIMITS = {
|
|
111
|
+
api: parseInt(process.env.API_BODY_SIZE_LIMIT || "2097152", 10), // 2MB default
|
|
112
|
+
upload: parseInt(process.env.UPLOAD_SIZE_LIMIT || "10485760", 10), // 10MB for uploads
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export function middleware(request) {
|
|
116
|
+
const { pathname } = request.nextUrl;
|
|
117
|
+
const origin = request.headers.get("origin") || "";
|
|
118
|
+
|
|
119
|
+
// Create response
|
|
120
|
+
const response = NextResponse.next();
|
|
121
|
+
|
|
122
|
+
// Check request body size for API routes
|
|
123
|
+
if (
|
|
124
|
+
pathname.startsWith("/api/") &&
|
|
125
|
+
["POST", "PUT", "PATCH"].includes(request.method)
|
|
126
|
+
) {
|
|
127
|
+
const contentLength = request.headers.get("content-length");
|
|
128
|
+
if (contentLength) {
|
|
129
|
+
const size = parseInt(contentLength, 10);
|
|
130
|
+
const limit = pathname.startsWith("/api/upload")
|
|
131
|
+
? REQUEST_SIZE_LIMITS.upload
|
|
132
|
+
: REQUEST_SIZE_LIMITS.api;
|
|
133
|
+
|
|
134
|
+
if (size > limit) {
|
|
135
|
+
return new NextResponse(
|
|
136
|
+
JSON.stringify({
|
|
137
|
+
error: "Payload too large",
|
|
138
|
+
message: `Request body size exceeds limit of ${Math.round(limit / 1024 / 1024)}MB`,
|
|
139
|
+
maxSize: limit,
|
|
140
|
+
}),
|
|
141
|
+
{
|
|
142
|
+
status: 413,
|
|
143
|
+
headers: {
|
|
144
|
+
"Content-Type": "application/json",
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Apply rate limiting to API routes only
|
|
153
|
+
if (pathname.startsWith("/api/")) {
|
|
154
|
+
const ip =
|
|
155
|
+
request.ip || request.headers.get("x-forwarded-for") || "unknown";
|
|
156
|
+
const rateLimitResult = checkRateLimit(ip, pathname);
|
|
157
|
+
|
|
158
|
+
if (rateLimitResult.limited) {
|
|
159
|
+
const retryAfter = Math.ceil(
|
|
160
|
+
(rateLimitResult.resetTime - Date.now()) / 1000,
|
|
161
|
+
);
|
|
162
|
+
return new NextResponse(
|
|
163
|
+
JSON.stringify({
|
|
164
|
+
error: "Too many requests",
|
|
165
|
+
message: "You have exceeded the rate limit. Please try again later.",
|
|
166
|
+
retryAfter,
|
|
167
|
+
}),
|
|
168
|
+
{
|
|
169
|
+
status: 429,
|
|
170
|
+
headers: {
|
|
171
|
+
"Content-Type": "application/json",
|
|
172
|
+
"Retry-After": retryAfter.toString(),
|
|
173
|
+
"X-RateLimit-Limit": pathname.startsWith("/api/auth/")
|
|
174
|
+
? RATE_LIMIT_CONFIG.maxRequests.auth.toString()
|
|
175
|
+
: RATE_LIMIT_CONFIG.maxRequests.api.toString(),
|
|
176
|
+
"X-RateLimit-Remaining": "0",
|
|
177
|
+
"X-RateLimit-Reset": new Date(
|
|
178
|
+
rateLimitResult.resetTime,
|
|
179
|
+
).toISOString(),
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Add rate limit headers to response
|
|
186
|
+
response.headers.set(
|
|
187
|
+
"X-RateLimit-Limit",
|
|
188
|
+
pathname.startsWith("/api/auth/")
|
|
189
|
+
? RATE_LIMIT_CONFIG.maxRequests.auth.toString()
|
|
190
|
+
: RATE_LIMIT_CONFIG.maxRequests.api.toString(),
|
|
191
|
+
);
|
|
192
|
+
response.headers.set(
|
|
193
|
+
"X-RateLimit-Remaining",
|
|
194
|
+
rateLimitResult.remaining.toString(),
|
|
195
|
+
);
|
|
196
|
+
response.headers.set(
|
|
197
|
+
"X-RateLimit-Reset",
|
|
198
|
+
new Date(rateLimitResult.resetTime).toISOString(),
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Handle CORS for API routes
|
|
203
|
+
if (pathname.startsWith("/api/")) {
|
|
204
|
+
// Check if origin is allowed
|
|
205
|
+
const isAllowedOrigin =
|
|
206
|
+
CORS_CONFIG.allowedOrigins.includes("*") ||
|
|
207
|
+
CORS_CONFIG.allowedOrigins.includes(origin);
|
|
208
|
+
|
|
209
|
+
if (isAllowedOrigin || !origin) {
|
|
210
|
+
response.headers.set(
|
|
211
|
+
"Access-Control-Allow-Origin",
|
|
212
|
+
origin || CORS_CONFIG.allowedOrigins[0],
|
|
213
|
+
);
|
|
214
|
+
response.headers.set(
|
|
215
|
+
"Access-Control-Allow-Methods",
|
|
216
|
+
CORS_CONFIG.allowedMethods.join(", "),
|
|
217
|
+
);
|
|
218
|
+
response.headers.set(
|
|
219
|
+
"Access-Control-Allow-Headers",
|
|
220
|
+
CORS_CONFIG.allowedHeaders.join(", "),
|
|
221
|
+
);
|
|
222
|
+
response.headers.set(
|
|
223
|
+
"Access-Control-Expose-Headers",
|
|
224
|
+
CORS_CONFIG.exposedHeaders.join(", "),
|
|
225
|
+
);
|
|
226
|
+
response.headers.set(
|
|
227
|
+
"Access-Control-Max-Age",
|
|
228
|
+
CORS_CONFIG.maxAge.toString(),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
if (CORS_CONFIG.credentials) {
|
|
232
|
+
response.headers.set("Access-Control-Allow-Credentials", "true");
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Handle preflight requests
|
|
237
|
+
if (request.method === "OPTIONS") {
|
|
238
|
+
return new NextResponse(null, {
|
|
239
|
+
status: 204,
|
|
240
|
+
headers: response.headers,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Apply security headers
|
|
246
|
+
for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
|
|
247
|
+
response.headers.set(key, value);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return response;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Configure which routes the middleware runs on
|
|
254
|
+
export const config = {
|
|
255
|
+
matcher: [
|
|
256
|
+
/*
|
|
257
|
+
* Match all request paths except:
|
|
258
|
+
* - _next/static (static files)
|
|
259
|
+
* - _next/image (image optimization files)
|
|
260
|
+
* - favicon.ico (favicon file)
|
|
261
|
+
* - public folder
|
|
262
|
+
*/
|
|
263
|
+
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
|
264
|
+
],
|
|
265
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@quark/worker",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node --test $(find src -name '*.test.js')",
|
|
9
|
+
"dev": "tsx watch src/index.js",
|
|
10
|
+
"lint": "biome format --write && biome check --write"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [],
|
|
13
|
+
"author": "",
|
|
14
|
+
"license": "ISC",
|
|
15
|
+
"packageManager": "pnpm@10.12.1",
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@techstream/quark-core": "^1.0.0",
|
|
18
|
+
"@techstream/quark-db": "workspace:*",
|
|
19
|
+
"@techstream/quark-jobs": "workspace:*",
|
|
20
|
+
"bullmq": "^5.64.1",
|
|
21
|
+
"dotenv": "^17.2.3"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@techstream/quark-config": "workspace:*",
|
|
25
|
+
"@types/node": "^24.10.1",
|
|
26
|
+
"tsx": "^4.20.6"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker Service
|
|
3
|
+
* Processes background jobs using BullMQ and Redis
|
|
4
|
+
* Handles job execution, retries, and error tracking
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
createEmailService,
|
|
9
|
+
createLogger,
|
|
10
|
+
createWorker,
|
|
11
|
+
} from "@techstream/quark-core";
|
|
12
|
+
import { JOB_NAMES, JOB_QUEUES } from "@techstream/quark-jobs";
|
|
13
|
+
import { prisma } from "@techstream/quark-db";
|
|
14
|
+
import dotenv from "dotenv";
|
|
15
|
+
|
|
16
|
+
// Load environment variables
|
|
17
|
+
dotenv.config();
|
|
18
|
+
|
|
19
|
+
const logger = createLogger("worker");
|
|
20
|
+
|
|
21
|
+
// Store workers for graceful shutdown
|
|
22
|
+
const workers = [];
|
|
23
|
+
|
|
24
|
+
// Initialize email service
|
|
25
|
+
const emailService = createEmailService();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Job handler for SEND_WELCOME_EMAIL
|
|
29
|
+
* @param {Job} bullJob - BullMQ job object
|
|
30
|
+
*/
|
|
31
|
+
async function handleSendWelcomeEmail(bullJob) {
|
|
32
|
+
const { userId } = bullJob.data;
|
|
33
|
+
|
|
34
|
+
if (!userId) {
|
|
35
|
+
throw new Error("userId is required for SEND_WELCOME_EMAIL job");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
logger.info(`Sending welcome email for user ${userId}`, {
|
|
39
|
+
job: JOB_NAMES.SEND_WELCOME_EMAIL,
|
|
40
|
+
userId,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Look up the user's email
|
|
44
|
+
const userRecord = await prisma.user.findUnique({
|
|
45
|
+
where: { id: userId },
|
|
46
|
+
select: { email: true, name: true },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (!userRecord?.email) {
|
|
50
|
+
throw new Error(`User ${userId} not found or has no email`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const displayName = userRecord.name || "there";
|
|
54
|
+
|
|
55
|
+
await emailService.sendEmail(
|
|
56
|
+
userRecord.email,
|
|
57
|
+
"Welcome to Quark!",
|
|
58
|
+
`<h1>Welcome, ${displayName}!</h1>
|
|
59
|
+
<p>Your account has been created successfully.</p>
|
|
60
|
+
<p>You can now sign in and start using the application.</p>`,
|
|
61
|
+
`Welcome, ${displayName}!\n\nYour account has been created successfully.\nYou can now sign in and start using the application.`,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
return { success: true, userId, email: userRecord.email };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Initialize job handlers
|
|
69
|
+
* Maps job names to their handler functions
|
|
70
|
+
*/
|
|
71
|
+
const jobHandlers = {
|
|
72
|
+
[JOB_NAMES.SEND_WELCOME_EMAIL]: handleSendWelcomeEmail,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Start the worker service
|
|
77
|
+
* Creates workers for all queues and registers handlers
|
|
78
|
+
*/
|
|
79
|
+
async function startWorker() {
|
|
80
|
+
logger.info("Starting Quark Worker Service");
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
// Create worker for email queue
|
|
84
|
+
const emailQueueWorker = createWorker(
|
|
85
|
+
JOB_QUEUES.EMAIL,
|
|
86
|
+
async (bullJob) => {
|
|
87
|
+
const handler = jobHandlers[bullJob.name];
|
|
88
|
+
|
|
89
|
+
if (!handler) {
|
|
90
|
+
throw new Error(`No handler registered for job: ${bullJob.name}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return handler(bullJob);
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
concurrency: parseInt(process.env.WORKER_CONCURRENCY || "5", 10),
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
workers.push(emailQueueWorker);
|
|
101
|
+
|
|
102
|
+
emailQueueWorker.on("completed", (job, result) => {
|
|
103
|
+
logger.info(`Job ${job.id} (${job.name}) completed`, { result });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
emailQueueWorker.on("failed", (job, error) => {
|
|
107
|
+
logger.error(`Job ${job.id} (${job.name}) failed after ${job.attemptsMade} attempts`, {
|
|
108
|
+
error: error.message,
|
|
109
|
+
jobName: job.name,
|
|
110
|
+
attemptsMade: job.attemptsMade,
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
logger.info(
|
|
115
|
+
`Email queue worker started (concurrency: ${emailQueueWorker.opts.concurrency})`,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// Ready to process jobs
|
|
119
|
+
logger.info("Worker service ready");
|
|
120
|
+
} catch (error) {
|
|
121
|
+
logger.error("Failed to start worker service", { error: error.message, stack: error.stack });
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Graceful shutdown handler
|
|
128
|
+
*/
|
|
129
|
+
async function shutdown() {
|
|
130
|
+
logger.info("Shutting down worker service");
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
// Close all workers
|
|
134
|
+
for (const worker of workers) {
|
|
135
|
+
await worker.close();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Disconnect Prisma client
|
|
139
|
+
await prisma.$disconnect();
|
|
140
|
+
|
|
141
|
+
logger.info("All workers closed");
|
|
142
|
+
process.exit(0);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
logger.error("Error during shutdown", { error: error.message, stack: error.stack });
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Register shutdown handlers
|
|
150
|
+
process.on("SIGTERM", shutdown);
|
|
151
|
+
process.on("SIGINT", shutdown);
|
|
152
|
+
|
|
153
|
+
// Start the worker service
|
|
154
|
+
startWorker();
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
// Mock job definitions for testing
|
|
5
|
+
const mockJobDefinitions = {
|
|
6
|
+
JOB_QUEUES: { EMAIL: "email-queue" },
|
|
7
|
+
JOB_NAMES: { SEND_WELCOME_EMAIL: "send-welcome-email" },
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
test("Worker - imports job definitions correctly", async () => {
|
|
11
|
+
const { JOB_QUEUES, JOB_NAMES } = mockJobDefinitions;
|
|
12
|
+
assert.strictEqual(JOB_QUEUES.EMAIL, "email-queue");
|
|
13
|
+
assert.strictEqual(JOB_NAMES.SEND_WELCOME_EMAIL, "send-welcome-email");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("Worker - Worker can be instantiated", async () => {
|
|
17
|
+
// Basic smoke test - bullmq Worker is available
|
|
18
|
+
assert.ok(true, "Worker should be instantiable");
|
|
19
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
services:
|
|
2
|
+
# --- 1. PostgreSQL Database ---
|
|
3
|
+
postgres:
|
|
4
|
+
image: postgres:16-alpine
|
|
5
|
+
container_name: postgres
|
|
6
|
+
restart: always
|
|
7
|
+
ports:
|
|
8
|
+
- "${POSTGRES_PORT:-5432}:5432"
|
|
9
|
+
environment:
|
|
10
|
+
POSTGRES_USER: ${POSTGRES_USER}
|
|
11
|
+
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
|
12
|
+
POSTGRES_DB: ${POSTGRES_DB}
|
|
13
|
+
volumes:
|
|
14
|
+
- postgres_data:/var/lib/postgresql/data
|
|
15
|
+
|
|
16
|
+
# --- 2. Redis Cache & Job Queue ---
|
|
17
|
+
redis:
|
|
18
|
+
image: redis:7-alpine
|
|
19
|
+
container_name: redis
|
|
20
|
+
restart: always
|
|
21
|
+
ports:
|
|
22
|
+
- "${REDIS_PORT:-6379}:6379"
|
|
23
|
+
command: redis-server --appendonly yes
|
|
24
|
+
volumes:
|
|
25
|
+
- redis_data:/data
|
|
26
|
+
|
|
27
|
+
# --- 3. Mailhog (Local SMTP Server) ---
|
|
28
|
+
mailhog:
|
|
29
|
+
image: mailhog/mailhog
|
|
30
|
+
container_name: mailhog
|
|
31
|
+
restart: always
|
|
32
|
+
ports:
|
|
33
|
+
# SMTP port (used by application to send mail)
|
|
34
|
+
- "${MAILHOG_SMTP_PORT:-1025}:1025"
|
|
35
|
+
# Web UI port (to view sent emails)
|
|
36
|
+
- "${MAILHOG_UI_PORT:-8025}:8025"
|
|
37
|
+
|
|
38
|
+
volumes:
|
|
39
|
+
postgres_data:
|
|
40
|
+
redis_data:
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@myquark/root",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "My Quark project",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "turbo run build",
|
|
9
|
+
"dev": "turbo run dev",
|
|
10
|
+
"lint": "turbo run lint",
|
|
11
|
+
"test": "turbo run test",
|
|
12
|
+
"docker:up": "docker compose up -d",
|
|
13
|
+
"docker:down": "docker compose down",
|
|
14
|
+
"db:generate": "turbo run db:generate"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [],
|
|
17
|
+
"author": "",
|
|
18
|
+
"license": "ISC",
|
|
19
|
+
"packageManager": "pnpm@10.12.1",
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@biomejs/biome": "^2.3.13",
|
|
22
|
+
"@types/node": "^24.10.9",
|
|
23
|
+
"tsx": "^4.21.0",
|
|
24
|
+
"turbo": "^2.8.1"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@techstream/quark-db",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node --test $(find src -name '*.test.js')",
|
|
9
|
+
"lint": "biome format --write && biome check --write",
|
|
10
|
+
"db:generate": "prisma generate",
|
|
11
|
+
"db:migrate": "prisma migrate dev",
|
|
12
|
+
"db:push": "prisma db push",
|
|
13
|
+
"db:seed": "node scripts/seed.js",
|
|
14
|
+
"db:studio": "prisma studio"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [],
|
|
17
|
+
"author": "",
|
|
18
|
+
"license": "ISC",
|
|
19
|
+
"packageManager": "pnpm@10.12.1",
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@techstream/quark-config": "workspace:*",
|
|
22
|
+
"prisma": "^7.0.0"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@prisma/client": "^7.0.0",
|
|
26
|
+
"dotenv": "^17.2.3",
|
|
27
|
+
"zod": "^4.3.6"
|
|
28
|
+
}
|
|
29
|
+
}
|