@techstream/quark-create-app 1.4.0 → 1.5.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/package.json +1 -1
- package/src/index.js +21 -0
- package/templates/base-project/apps/web/src/app/api/files/[id]/route.js +89 -0
- package/templates/base-project/apps/web/src/app/api/files/route.js +132 -0
- package/templates/base-project/apps/worker/src/handlers/email.js +100 -0
- package/templates/base-project/apps/worker/src/handlers/files.js +59 -0
- package/templates/base-project/apps/worker/src/handlers/index.js +18 -0
- package/templates/base-project/apps/worker/src/index.js +50 -123
- package/templates/base-project/turbo.json +11 -1
- package/templates/jobs/src/definitions.js +2 -1
- package/templates/jobs/src/index.js +0 -1
- package/templates/jobs/src/handlers.js +0 -20
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -475,6 +475,24 @@ PORT=3000
|
|
|
475
475
|
|
|
476
476
|
# --- Worker Configuration ---
|
|
477
477
|
WORKER_CONCURRENCY=5
|
|
478
|
+
|
|
479
|
+
# --- File Storage Configuration ---
|
|
480
|
+
# Storage provider: "local" (default) or "s3" (S3-compatible, e.g. Cloudflare R2)
|
|
481
|
+
STORAGE_PROVIDER=local
|
|
482
|
+
# Local storage directory (only used when STORAGE_PROVIDER=local)
|
|
483
|
+
# STORAGE_LOCAL_DIR=./uploads
|
|
484
|
+
|
|
485
|
+
# S3 / Cloudflare R2 Configuration (only used when STORAGE_PROVIDER=s3)
|
|
486
|
+
# S3_BUCKET=your-bucket-name
|
|
487
|
+
# S3_REGION=auto
|
|
488
|
+
# S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
|
|
489
|
+
# S3_ACCESS_KEY_ID=your-access-key
|
|
490
|
+
# S3_SECRET_ACCESS_KEY=your-secret-key
|
|
491
|
+
# S3_PUBLIC_URL=https://your-public-bucket-domain.com
|
|
492
|
+
|
|
493
|
+
# --- Upload Limits ---
|
|
494
|
+
# UPLOAD_MAX_SIZE=10485760
|
|
495
|
+
# UPLOAD_ALLOWED_TYPES=image/jpeg,image/png,image/gif,image/webp,image/avif,image/svg+xml,application/pdf
|
|
478
496
|
`;
|
|
479
497
|
await fs.writeFile(
|
|
480
498
|
path.join(targetDir, ".env.example"),
|
|
@@ -540,6 +558,9 @@ PORT=${webPort}
|
|
|
540
558
|
|
|
541
559
|
# --- Worker Configuration ---
|
|
542
560
|
WORKER_CONCURRENCY=5
|
|
561
|
+
|
|
562
|
+
# --- File Storage ---
|
|
563
|
+
STORAGE_PROVIDER=local
|
|
543
564
|
`;
|
|
544
565
|
await fs.writeFile(path.join(targetDir, ".env"), envContent);
|
|
545
566
|
console.log(
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single File API
|
|
3
|
+
* GET /api/files/[id] - Download / serve a file
|
|
4
|
+
* DELETE /api/files/[id] - Delete a file (owner or admin only)
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
createStorage,
|
|
9
|
+
ForbiddenError,
|
|
10
|
+
NotFoundError,
|
|
11
|
+
withCsrfProtection,
|
|
12
|
+
} from "@techstream/quark-core";
|
|
13
|
+
import { file } from "@techstream/quark-db";
|
|
14
|
+
import { NextResponse } from "next/server";
|
|
15
|
+
import { requireAuth } from "@/lib/auth-middleware";
|
|
16
|
+
import { handleError } from "../../error-handler";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* GET /api/files/[id]
|
|
20
|
+
* Serve / download a file by its database ID.
|
|
21
|
+
* Public endpoint (no auth required) — access control is by knowledge of ID.
|
|
22
|
+
*/
|
|
23
|
+
export async function GET(_request, { params }) {
|
|
24
|
+
try {
|
|
25
|
+
const { id } = await params;
|
|
26
|
+
|
|
27
|
+
const record = await file.findById(id);
|
|
28
|
+
if (!record) {
|
|
29
|
+
throw new NotFoundError("File not found");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const storage = createStorage();
|
|
33
|
+
const { body, contentType } = await storage.get(record.storageKey);
|
|
34
|
+
|
|
35
|
+
const headers = new Headers();
|
|
36
|
+
headers.set("Content-Type", contentType || record.mimeType);
|
|
37
|
+
headers.set("Content-Length", String(body.length));
|
|
38
|
+
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
|
39
|
+
|
|
40
|
+
// Inline display for images; attachment download for everything else
|
|
41
|
+
const isImage = record.mimeType.startsWith("image/");
|
|
42
|
+
const safeName = record.originalName.replace(/[\\"\r\n]/g, "_");
|
|
43
|
+
const encodedName = encodeURIComponent(record.originalName);
|
|
44
|
+
headers.set(
|
|
45
|
+
"Content-Disposition",
|
|
46
|
+
isImage
|
|
47
|
+
? `inline; filename="${safeName}"; filename*=UTF-8''${encodedName}`
|
|
48
|
+
: `attachment; filename="${safeName}"; filename*=UTF-8''${encodedName}`,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
return new Response(body, { status: 200, headers });
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return handleError(error);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* DELETE /api/files/[id]
|
|
59
|
+
* Delete a file. Only the uploader or an admin can delete.
|
|
60
|
+
*/
|
|
61
|
+
export const DELETE = withCsrfProtection(async (_request, { params }) => {
|
|
62
|
+
try {
|
|
63
|
+
const session = await requireAuth();
|
|
64
|
+
const { id } = await params;
|
|
65
|
+
|
|
66
|
+
const record = await file.findById(id);
|
|
67
|
+
if (!record) {
|
|
68
|
+
throw new NotFoundError("File not found");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Authorization: uploader or admin
|
|
72
|
+
const isOwner = record.uploadedById === session.user.id;
|
|
73
|
+
const isAdmin = session.user.role === "admin";
|
|
74
|
+
if (!isOwner && !isAdmin) {
|
|
75
|
+
throw new ForbiddenError("You can only delete your own files");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Remove from storage
|
|
79
|
+
const storage = createStorage();
|
|
80
|
+
await storage.delete(record.storageKey);
|
|
81
|
+
|
|
82
|
+
// Remove database record
|
|
83
|
+
await file.delete(record.id);
|
|
84
|
+
|
|
85
|
+
return NextResponse.json({ message: "File deleted" });
|
|
86
|
+
} catch (error) {
|
|
87
|
+
return handleError(error);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Upload API
|
|
3
|
+
* POST /api/files - Upload a file (multipart/form-data)
|
|
4
|
+
* GET /api/files - List current user's files
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
createStorage,
|
|
9
|
+
generateStorageKey,
|
|
10
|
+
parseMultipart,
|
|
11
|
+
validateFile,
|
|
12
|
+
} from "@techstream/quark-core";
|
|
13
|
+
import { file } from "@techstream/quark-db";
|
|
14
|
+
import { NextResponse } from "next/server";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import { requireAuth } from "@/lib/auth-middleware";
|
|
17
|
+
import { handleError } from "../error-handler";
|
|
18
|
+
|
|
19
|
+
const paginationSchema = z.object({
|
|
20
|
+
page: z.coerce.number().int().min(1).default(1),
|
|
21
|
+
limit: z.coerce.number().int().min(1).max(100).default(50),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* POST /api/files
|
|
26
|
+
* Upload one or more files via multipart/form-data.
|
|
27
|
+
* Requires authentication.
|
|
28
|
+
*/
|
|
29
|
+
export async function POST(request) {
|
|
30
|
+
try {
|
|
31
|
+
const session = await requireAuth();
|
|
32
|
+
|
|
33
|
+
// Parse multipart body
|
|
34
|
+
const { files: parsedFiles } = await parseMultipart(request);
|
|
35
|
+
|
|
36
|
+
if (parsedFiles.length === 0) {
|
|
37
|
+
return NextResponse.json(
|
|
38
|
+
{ message: "No files provided" },
|
|
39
|
+
{ status: 400 },
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const storage = createStorage();
|
|
44
|
+
const results = [];
|
|
45
|
+
|
|
46
|
+
for (const parsed of parsedFiles) {
|
|
47
|
+
// Validate each file
|
|
48
|
+
const validation = validateFile({
|
|
49
|
+
filename: parsed.filename,
|
|
50
|
+
mimeType: parsed.mimeType,
|
|
51
|
+
size: parsed.size,
|
|
52
|
+
buffer: parsed.buffer,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (!validation.valid) {
|
|
56
|
+
return NextResponse.json(
|
|
57
|
+
{ message: validation.error, filename: parsed.filename },
|
|
58
|
+
{ status: 422 },
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Generate storage key and upload
|
|
63
|
+
const storageKey = generateStorageKey(parsed.filename);
|
|
64
|
+
|
|
65
|
+
await storage.put(storageKey, parsed.buffer, {
|
|
66
|
+
contentType: parsed.mimeType,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Save metadata to database
|
|
70
|
+
const record = await file.create({
|
|
71
|
+
filename: storageKey.split("/").pop(),
|
|
72
|
+
originalName: parsed.filename,
|
|
73
|
+
mimeType: parsed.mimeType,
|
|
74
|
+
size: parsed.size,
|
|
75
|
+
storageKey,
|
|
76
|
+
storageProvider: storage.provider,
|
|
77
|
+
uploadedById: session.user.id,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
results.push({
|
|
81
|
+
id: record.id,
|
|
82
|
+
originalName: record.originalName,
|
|
83
|
+
mimeType: record.mimeType,
|
|
84
|
+
size: record.size,
|
|
85
|
+
url: `/api/files/${record.id}`,
|
|
86
|
+
createdAt: record.createdAt,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const status = results.length === 1 ? 201 : 200;
|
|
91
|
+
const body = results.length === 1 ? results[0] : { files: results };
|
|
92
|
+
|
|
93
|
+
return NextResponse.json(body, { status });
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return handleError(error);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* GET /api/files
|
|
101
|
+
* List files uploaded by the current user.
|
|
102
|
+
*/
|
|
103
|
+
export async function GET(request) {
|
|
104
|
+
try {
|
|
105
|
+
const session = await requireAuth();
|
|
106
|
+
|
|
107
|
+
const { searchParams } = new URL(request.url);
|
|
108
|
+
const { page, limit } = paginationSchema.parse({
|
|
109
|
+
page: searchParams.get("page") ?? undefined,
|
|
110
|
+
limit: searchParams.get("limit") ?? undefined,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const skip = (page - 1) * limit;
|
|
114
|
+
const files = await file.findByUploader(session.user.id, {
|
|
115
|
+
skip,
|
|
116
|
+
take: limit,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const mapped = files.map((f) => ({
|
|
120
|
+
id: f.id,
|
|
121
|
+
originalName: f.originalName,
|
|
122
|
+
mimeType: f.mimeType,
|
|
123
|
+
size: f.size,
|
|
124
|
+
url: `/api/files/${f.id}`,
|
|
125
|
+
createdAt: f.createdAt,
|
|
126
|
+
}));
|
|
127
|
+
|
|
128
|
+
return NextResponse.json(mapped);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
return handleError(error);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email Job Handlers
|
|
3
|
+
* Processes email-related background jobs
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
createEmailService,
|
|
8
|
+
passwordResetEmail,
|
|
9
|
+
welcomeEmail,
|
|
10
|
+
} from "@techstream/quark-core";
|
|
11
|
+
import { prisma } from "@techstream/quark-db";
|
|
12
|
+
import { JOB_NAMES } from "@techstream/quark-jobs";
|
|
13
|
+
|
|
14
|
+
const emailService = createEmailService();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Job handler for SEND_WELCOME_EMAIL
|
|
18
|
+
* @param {import("bullmq").Job} bullJob
|
|
19
|
+
* @param {import("@techstream/quark-core").Logger} logger
|
|
20
|
+
*/
|
|
21
|
+
export async function handleSendWelcomeEmail(bullJob, logger) {
|
|
22
|
+
const { userId } = bullJob.data;
|
|
23
|
+
|
|
24
|
+
if (!userId) {
|
|
25
|
+
throw new Error("userId is required for SEND_WELCOME_EMAIL job");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
logger.info(`Sending welcome email for user ${userId}`, {
|
|
29
|
+
job: JOB_NAMES.SEND_WELCOME_EMAIL,
|
|
30
|
+
userId,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const userRecord = await prisma.user.findUnique({
|
|
34
|
+
where: { id: userId },
|
|
35
|
+
select: { email: true, name: true },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (!userRecord?.email) {
|
|
39
|
+
throw new Error(`User ${userId} not found or has no email`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const template = welcomeEmail({
|
|
43
|
+
name: userRecord.name,
|
|
44
|
+
loginUrl: process.env.APP_URL
|
|
45
|
+
? `${process.env.APP_URL}/api/auth/signin`
|
|
46
|
+
: undefined,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
await emailService.sendEmail(
|
|
50
|
+
userRecord.email,
|
|
51
|
+
template.subject,
|
|
52
|
+
template.html,
|
|
53
|
+
template.text,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
return { success: true, userId, email: userRecord.email };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Job handler for SEND_RESET_PASSWORD_EMAIL
|
|
61
|
+
* @param {import("bullmq").Job} bullJob
|
|
62
|
+
* @param {import("@techstream/quark-core").Logger} logger
|
|
63
|
+
*/
|
|
64
|
+
export async function handleSendResetPasswordEmail(bullJob, logger) {
|
|
65
|
+
const { userId, resetUrl } = bullJob.data;
|
|
66
|
+
|
|
67
|
+
if (!userId || !resetUrl) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
"userId and resetUrl are required for SEND_RESET_PASSWORD_EMAIL job",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
logger.info(`Sending password reset email for user ${userId}`, {
|
|
74
|
+
job: JOB_NAMES.SEND_RESET_PASSWORD_EMAIL,
|
|
75
|
+
userId,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const userRecord = await prisma.user.findUnique({
|
|
79
|
+
where: { id: userId },
|
|
80
|
+
select: { email: true, name: true },
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (!userRecord?.email) {
|
|
84
|
+
throw new Error(`User ${userId} not found or has no email`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const template = passwordResetEmail({
|
|
88
|
+
name: userRecord.name,
|
|
89
|
+
resetUrl,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await emailService.sendEmail(
|
|
93
|
+
userRecord.email,
|
|
94
|
+
template.subject,
|
|
95
|
+
template.html,
|
|
96
|
+
template.text,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
return { success: true, userId, email: userRecord.email };
|
|
100
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Job Handlers
|
|
3
|
+
* Processes file-related background jobs (cleanup, etc.)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createStorage } from "@techstream/quark-core";
|
|
7
|
+
import { file } from "@techstream/quark-db";
|
|
8
|
+
import { JOB_NAMES } from "@techstream/quark-jobs";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Job handler for CLEANUP_ORPHANED_FILES
|
|
12
|
+
* Deletes files with no owner that are older than a retention period.
|
|
13
|
+
*
|
|
14
|
+
* @param {import("bullmq").Job} bullJob
|
|
15
|
+
* @param {import("@techstream/quark-core").Logger} logger
|
|
16
|
+
*/
|
|
17
|
+
export async function handleCleanupOrphanedFiles(bullJob, logger) {
|
|
18
|
+
const retentionHours = bullJob.data?.retentionHours || 24;
|
|
19
|
+
const cutoff = new Date(Date.now() - retentionHours * 60 * 60 * 1000);
|
|
20
|
+
|
|
21
|
+
logger.info("Starting orphaned file cleanup", {
|
|
22
|
+
job: JOB_NAMES.CLEANUP_ORPHANED_FILES,
|
|
23
|
+
retentionHours,
|
|
24
|
+
cutoff: cutoff.toISOString(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const orphaned = await file.findOlderThan(cutoff);
|
|
28
|
+
|
|
29
|
+
if (orphaned.length === 0) {
|
|
30
|
+
logger.info("No orphaned files to clean up");
|
|
31
|
+
return { success: true, deleted: 0 };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const storage = createStorage();
|
|
35
|
+
let deleted = 0;
|
|
36
|
+
const errors = [];
|
|
37
|
+
|
|
38
|
+
for (const record of orphaned) {
|
|
39
|
+
try {
|
|
40
|
+
await storage.delete(record.storageKey);
|
|
41
|
+
await file.delete(record.id);
|
|
42
|
+
deleted++;
|
|
43
|
+
} catch (err) {
|
|
44
|
+
errors.push({ id: record.id, error: err.message });
|
|
45
|
+
logger.warn(`Failed to delete file ${record.id}: ${err.message}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
logger.info(
|
|
50
|
+
`Orphaned file cleanup complete: ${deleted}/${orphaned.length} deleted`,
|
|
51
|
+
{
|
|
52
|
+
deleted,
|
|
53
|
+
total: orphaned.length,
|
|
54
|
+
errors: errors.length,
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
return { success: true, deleted, total: orphaned.length, errors };
|
|
59
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job Handler Registry
|
|
3
|
+
* Maps job names to their handler functions.
|
|
4
|
+
* Each handler receives (bullJob, logger) and returns a result object.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { JOB_NAMES } from "@techstream/quark-jobs";
|
|
8
|
+
import {
|
|
9
|
+
handleSendResetPasswordEmail,
|
|
10
|
+
handleSendWelcomeEmail,
|
|
11
|
+
} from "./email.js";
|
|
12
|
+
import { handleCleanupOrphanedFiles } from "./files.js";
|
|
13
|
+
|
|
14
|
+
export const jobHandlers = {
|
|
15
|
+
[JOB_NAMES.SEND_WELCOME_EMAIL]: handleSendWelcomeEmail,
|
|
16
|
+
[JOB_NAMES.SEND_RESET_PASSWORD_EMAIL]: handleSendResetPasswordEmail,
|
|
17
|
+
[JOB_NAMES.CLEANUP_ORPHANED_FILES]: handleCleanupOrphanedFiles,
|
|
18
|
+
};
|
|
@@ -5,163 +5,87 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
|
-
createEmailService,
|
|
9
8
|
createLogger,
|
|
9
|
+
createQueue,
|
|
10
10
|
createWorker,
|
|
11
|
-
passwordResetEmail,
|
|
12
|
-
welcomeEmail,
|
|
13
11
|
} from "@techstream/quark-core";
|
|
14
12
|
import { prisma } from "@techstream/quark-db";
|
|
15
13
|
import { JOB_NAMES, JOB_QUEUES } from "@techstream/quark-jobs";
|
|
14
|
+
import { jobHandlers } from "./handlers/index.js";
|
|
16
15
|
|
|
17
16
|
const logger = createLogger("worker");
|
|
18
17
|
|
|
19
18
|
// Store workers for graceful shutdown
|
|
20
19
|
const workers = [];
|
|
21
20
|
|
|
22
|
-
// Initialize email service
|
|
23
|
-
const emailService = createEmailService();
|
|
24
|
-
|
|
25
21
|
/**
|
|
26
|
-
*
|
|
27
|
-
* @param {
|
|
22
|
+
* Generic queue processor — dispatches jobs to registered handlers
|
|
23
|
+
* @param {string} queueName
|
|
28
24
|
*/
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
if (!userRecord?.email) {
|
|
47
|
-
throw new Error(`User ${userId} not found or has no email`);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const template = welcomeEmail({
|
|
51
|
-
name: userRecord.name,
|
|
52
|
-
loginUrl: process.env.APP_URL
|
|
53
|
-
? `${process.env.APP_URL}/api/auth/signin`
|
|
54
|
-
: undefined,
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
await emailService.sendEmail(
|
|
58
|
-
userRecord.email,
|
|
59
|
-
template.subject,
|
|
60
|
-
template.html,
|
|
61
|
-
template.text,
|
|
25
|
+
function createQueueWorker(queueName) {
|
|
26
|
+
const queueWorker = createWorker(
|
|
27
|
+
queueName,
|
|
28
|
+
async (bullJob) => {
|
|
29
|
+
const handler = jobHandlers[bullJob.name];
|
|
30
|
+
|
|
31
|
+
if (!handler) {
|
|
32
|
+
throw new Error(`No handler registered for job: ${bullJob.name}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return handler(bullJob, logger);
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
concurrency: parseInt(process.env.WORKER_CONCURRENCY || "5", 10),
|
|
39
|
+
},
|
|
62
40
|
);
|
|
63
41
|
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Job handler for SEND_RESET_PASSWORD_EMAIL
|
|
69
|
-
* @param {Job} bullJob - BullMQ job object
|
|
70
|
-
*/
|
|
71
|
-
async function handleSendResetPasswordEmail(bullJob) {
|
|
72
|
-
const { userId, resetUrl } = bullJob.data;
|
|
42
|
+
workers.push(queueWorker);
|
|
73
43
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
"userId and resetUrl are required for SEND_RESET_PASSWORD_EMAIL job",
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
logger.info(`Sending password reset email for user ${userId}`, {
|
|
81
|
-
job: JOB_NAMES.SEND_RESET_PASSWORD_EMAIL,
|
|
82
|
-
userId,
|
|
44
|
+
queueWorker.on("completed", (job, result) => {
|
|
45
|
+
logger.info(`Job ${job.id} (${job.name}) completed`, { result });
|
|
83
46
|
});
|
|
84
47
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const template = passwordResetEmail({
|
|
95
|
-
name: userRecord.name,
|
|
96
|
-
resetUrl,
|
|
48
|
+
queueWorker.on("failed", (job, error) => {
|
|
49
|
+
logger.error(
|
|
50
|
+
`Job ${job.id} (${job.name}) failed after ${job.attemptsMade} attempts`,
|
|
51
|
+
{
|
|
52
|
+
error: error.message,
|
|
53
|
+
jobName: job.name,
|
|
54
|
+
attemptsMade: job.attemptsMade,
|
|
55
|
+
},
|
|
56
|
+
);
|
|
97
57
|
});
|
|
98
58
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
template.subject,
|
|
102
|
-
template.html,
|
|
103
|
-
template.text,
|
|
59
|
+
logger.info(
|
|
60
|
+
`Queue "${queueName}" worker started (concurrency: ${queueWorker.opts.concurrency})`,
|
|
104
61
|
);
|
|
105
62
|
|
|
106
|
-
return
|
|
63
|
+
return queueWorker;
|
|
107
64
|
}
|
|
108
65
|
|
|
109
|
-
/**
|
|
110
|
-
* Initialize job handlers
|
|
111
|
-
* Maps job names to their handler functions
|
|
112
|
-
*/
|
|
113
|
-
const jobHandlers = {
|
|
114
|
-
[JOB_NAMES.SEND_WELCOME_EMAIL]: handleSendWelcomeEmail,
|
|
115
|
-
[JOB_NAMES.SEND_RESET_PASSWORD_EMAIL]: handleSendResetPasswordEmail,
|
|
116
|
-
};
|
|
117
|
-
|
|
118
66
|
/**
|
|
119
67
|
* Start the worker service
|
|
120
|
-
* Creates workers for all queues and registers handlers
|
|
121
68
|
*/
|
|
122
69
|
async function startWorker() {
|
|
123
70
|
logger.info("Starting Quark Worker Service");
|
|
124
71
|
|
|
125
72
|
try {
|
|
126
|
-
//
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const handler = jobHandlers[bullJob.name];
|
|
131
|
-
|
|
132
|
-
if (!handler) {
|
|
133
|
-
throw new Error(`No handler registered for job: ${bullJob.name}`);
|
|
134
|
-
}
|
|
73
|
+
// Register a worker for each queue
|
|
74
|
+
for (const queueName of Object.values(JOB_QUEUES)) {
|
|
75
|
+
createQueueWorker(queueName);
|
|
76
|
+
}
|
|
135
77
|
|
|
136
|
-
|
|
137
|
-
|
|
78
|
+
// Schedule repeating cleanup job (runs every 24 hours)
|
|
79
|
+
const filesQueue = createQueue(JOB_QUEUES.FILES);
|
|
80
|
+
await filesQueue.add(
|
|
81
|
+
JOB_NAMES.CLEANUP_ORPHANED_FILES,
|
|
82
|
+
{ retentionHours: 24 },
|
|
138
83
|
{
|
|
139
|
-
|
|
84
|
+
repeat: { every: 24 * 60 * 60 * 1000 }, // 24h
|
|
85
|
+
jobId: "cleanup-orphaned-files-repeat",
|
|
140
86
|
},
|
|
141
87
|
);
|
|
142
88
|
|
|
143
|
-
workers.push(emailQueueWorker);
|
|
144
|
-
|
|
145
|
-
emailQueueWorker.on("completed", (job, result) => {
|
|
146
|
-
logger.info(`Job ${job.id} (${job.name}) completed`, { result });
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
emailQueueWorker.on("failed", (job, error) => {
|
|
150
|
-
logger.error(
|
|
151
|
-
`Job ${job.id} (${job.name}) failed after ${job.attemptsMade} attempts`,
|
|
152
|
-
{
|
|
153
|
-
error: error.message,
|
|
154
|
-
jobName: job.name,
|
|
155
|
-
attemptsMade: job.attemptsMade,
|
|
156
|
-
},
|
|
157
|
-
);
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
logger.info(
|
|
161
|
-
`Email queue worker started (concurrency: ${emailQueueWorker.opts.concurrency})`,
|
|
162
|
-
);
|
|
163
|
-
|
|
164
|
-
// Ready to process jobs
|
|
165
89
|
logger.info("Worker service ready");
|
|
166
90
|
} catch (error) {
|
|
167
91
|
logger.error("Failed to start worker service", {
|
|
@@ -179,12 +103,10 @@ async function shutdown() {
|
|
|
179
103
|
logger.info("Shutting down worker service");
|
|
180
104
|
|
|
181
105
|
try {
|
|
182
|
-
// Close all workers
|
|
183
106
|
for (const worker of workers) {
|
|
184
107
|
await worker.close();
|
|
185
108
|
}
|
|
186
109
|
|
|
187
|
-
// Disconnect Prisma client
|
|
188
110
|
await prisma.$disconnect();
|
|
189
111
|
|
|
190
112
|
logger.info("All workers closed");
|
|
@@ -198,6 +120,11 @@ async function shutdown() {
|
|
|
198
120
|
}
|
|
199
121
|
}
|
|
200
122
|
|
|
123
|
+
process.on("SIGTERM", shutdown);
|
|
124
|
+
process.on("SIGINT", shutdown);
|
|
125
|
+
|
|
126
|
+
startWorker();
|
|
127
|
+
|
|
201
128
|
// Register shutdown handlers
|
|
202
129
|
process.on("SIGTERM", shutdown);
|
|
203
130
|
process.on("SIGINT", shutdown);
|
|
@@ -34,7 +34,17 @@
|
|
|
34
34
|
"MAILHOG_UI_PORT",
|
|
35
35
|
"NEXTAUTH_SECRET",
|
|
36
36
|
"APP_URL",
|
|
37
|
-
"WORKER_CONCURRENCY"
|
|
37
|
+
"WORKER_CONCURRENCY",
|
|
38
|
+
"STORAGE_PROVIDER",
|
|
39
|
+
"STORAGE_LOCAL_DIR",
|
|
40
|
+
"S3_BUCKET",
|
|
41
|
+
"S3_REGION",
|
|
42
|
+
"S3_ENDPOINT",
|
|
43
|
+
"S3_ACCESS_KEY_ID",
|
|
44
|
+
"S3_SECRET_ACCESS_KEY",
|
|
45
|
+
"S3_PUBLIC_URL",
|
|
46
|
+
"UPLOAD_MAX_SIZE",
|
|
47
|
+
"UPLOAD_ALLOWED_TYPES"
|
|
38
48
|
]
|
|
39
49
|
}
|
|
40
50
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
export const JOB_QUEUES = {
|
|
2
2
|
EMAIL: "email-queue",
|
|
3
|
-
|
|
3
|
+
FILES: "files-queue",
|
|
4
4
|
};
|
|
5
5
|
|
|
6
6
|
export const JOB_NAMES = {
|
|
7
7
|
SEND_WELCOME_EMAIL: "send-welcome-email",
|
|
8
8
|
SEND_RESET_PASSWORD_EMAIL: "send-reset-password-email",
|
|
9
|
+
CLEANUP_ORPHANED_FILES: "cleanup-orphaned-files",
|
|
9
10
|
};
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import { JOB_NAMES } from "./definitions.js";
|
|
2
|
-
|
|
3
|
-
export async function sendWelcomeEmail(job) {
|
|
4
|
-
const { email } = job.data;
|
|
5
|
-
console.log(`Sending welcome email to ${email}`);
|
|
6
|
-
// Add your email sending logic here
|
|
7
|
-
return { success: true };
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export async function sendResetPasswordEmail(job) {
|
|
11
|
-
const { email } = job.data;
|
|
12
|
-
console.log(`Sending reset password email to ${email}`);
|
|
13
|
-
// Add your email sending logic here
|
|
14
|
-
return { success: true };
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export const jobHandlers = {
|
|
18
|
-
[JOB_NAMES.SEND_WELCOME_EMAIL]: sendWelcomeEmail,
|
|
19
|
-
[JOB_NAMES.SEND_RESET_PASSWORD_EMAIL]: sendResetPasswordEmail,
|
|
20
|
-
};
|