@techstream/quark-create-app 1.3.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.
Files changed (33) hide show
  1. package/package.json +34 -34
  2. package/src/index.js +44 -16
  3. package/templates/base-project/apps/web/src/app/api/auth/register/route.js +17 -1
  4. package/templates/base-project/apps/web/src/app/api/files/[id]/route.js +89 -0
  5. package/templates/base-project/apps/web/src/app/api/files/route.js +132 -0
  6. package/templates/base-project/apps/web/src/app/api/health/route.js +4 -1
  7. package/templates/base-project/apps/web/src/app/api/posts/[id]/route.js +5 -1
  8. package/templates/base-project/apps/worker/src/handlers/email.js +100 -0
  9. package/templates/base-project/apps/worker/src/handlers/files.js +59 -0
  10. package/templates/base-project/apps/worker/src/handlers/index.js +18 -0
  11. package/templates/base-project/apps/worker/src/index.js +60 -90
  12. package/templates/base-project/package.json +7 -1
  13. package/templates/base-project/packages/db/scripts/seed.js +1 -1
  14. package/templates/base-project/packages/db/src/client.js +40 -24
  15. package/templates/base-project/turbo.json +26 -1
  16. package/templates/jobs/src/definitions.js +2 -1
  17. package/templates/jobs/src/index.js +0 -1
  18. package/templates/base-project/packages/db/src/generated/prisma/browser.ts +0 -53
  19. package/templates/base-project/packages/db/src/generated/prisma/client.ts +0 -82
  20. package/templates/base-project/packages/db/src/generated/prisma/commonInputTypes.ts +0 -649
  21. package/templates/base-project/packages/db/src/generated/prisma/enums.ts +0 -19
  22. package/templates/base-project/packages/db/src/generated/prisma/internal/class.ts +0 -305
  23. package/templates/base-project/packages/db/src/generated/prisma/internal/prismaNamespace.ts +0 -1428
  24. package/templates/base-project/packages/db/src/generated/prisma/internal/prismaNamespaceBrowser.ts +0 -217
  25. package/templates/base-project/packages/db/src/generated/prisma/models/Account.ts +0 -2098
  26. package/templates/base-project/packages/db/src/generated/prisma/models/AuditLog.ts +0 -1805
  27. package/templates/base-project/packages/db/src/generated/prisma/models/Job.ts +0 -1737
  28. package/templates/base-project/packages/db/src/generated/prisma/models/Post.ts +0 -1762
  29. package/templates/base-project/packages/db/src/generated/prisma/models/Session.ts +0 -1738
  30. package/templates/base-project/packages/db/src/generated/prisma/models/User.ts +0 -2298
  31. package/templates/base-project/packages/db/src/generated/prisma/models/VerificationToken.ts +0 -1450
  32. package/templates/base-project/packages/db/src/generated/prisma/models.ts +0 -18
  33. package/templates/jobs/src/handlers.js +0 -20
package/package.json CHANGED
@@ -1,35 +1,35 @@
1
1
  {
2
- "name": "@techstream/quark-create-app",
3
- "version": "1.3.0",
4
- "type": "module",
5
- "bin": {
6
- "quark-create-app": "src/index.js",
7
- "create-quark-app": "src/index.js"
8
- },
9
- "files": [
10
- "src",
11
- "templates",
12
- "README.md"
13
- ],
14
- "scripts": {
15
- "test": "node test-cli.js",
16
- "test:e2e": "node test-e2e.js",
17
- "test:integration": "node test-integration.js",
18
- "test:all": "node test-all.js"
19
- },
20
- "dependencies": {
21
- "chalk": "^5.6.2",
22
- "commander": "^12.1.0",
23
- "execa": "^9.6.1",
24
- "fs-extra": "^11.3.3",
25
- "prompts": "^2.4.2"
26
- },
27
- "publishConfig": {
28
- "registry": "https://registry.npmjs.org",
29
- "access": "public"
30
- },
31
- "engines": {
32
- "node": ">=22"
33
- },
34
- "license": "ISC"
35
- }
2
+ "name": "@techstream/quark-create-app",
3
+ "version": "1.5.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "quark-create-app": "src/index.js",
7
+ "create-quark-app": "src/index.js"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "templates",
12
+ "README.md"
13
+ ],
14
+ "dependencies": {
15
+ "chalk": "^5.6.2",
16
+ "commander": "^12.1.0",
17
+ "execa": "^9.6.1",
18
+ "fs-extra": "^11.3.3",
19
+ "prompts": "^2.4.2"
20
+ },
21
+ "publishConfig": {
22
+ "registry": "https://registry.npmjs.org",
23
+ "access": "public"
24
+ },
25
+ "engines": {
26
+ "node": ">=22"
27
+ },
28
+ "license": "ISC",
29
+ "scripts": {
30
+ "test": "node test-cli.js",
31
+ "test:e2e": "node test-e2e.js",
32
+ "test:integration": "node test-integration.js",
33
+ "test:all": "node test-all.js"
34
+ }
35
+ }
package/src/index.js CHANGED
@@ -110,7 +110,10 @@ async function copyTemplate(templateName, targetDir, variables = {}) {
110
110
  let content = await fs.readFile(packageJsonPath, "utf-8");
111
111
 
112
112
  for (const [key, value] of Object.entries(variables)) {
113
- const pattern = new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g");
113
+ const pattern = new RegExp(
114
+ key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
115
+ "g",
116
+ );
114
117
  content = content.replace(pattern, value);
115
118
  }
116
119
 
@@ -175,7 +178,11 @@ function replaceDepsScope(deps, scope, selectedPackages) {
175
178
  const packageName = key.replace("@techstream/quark-", "");
176
179
  delete deps[key];
177
180
  // Only keep the dep if the package was selected (or is always required)
178
- if (packageName === "db" || packageName === "config" || selectedPackages.includes(packageName)) {
181
+ if (
182
+ packageName === "db" ||
183
+ packageName === "config" ||
184
+ selectedPackages.includes(packageName)
185
+ ) {
179
186
  deps[`@${scope}/${packageName}`] = value;
180
187
  }
181
188
  }
@@ -194,7 +201,11 @@ async function replaceImportsInSourceFiles(dir, scope) {
194
201
  for (const entry of entries) {
195
202
  const fullPath = path.join(dir, entry.name);
196
203
 
197
- if (entry.isDirectory() && entry.name !== "node_modules" && entry.name !== ".next") {
204
+ if (
205
+ entry.isDirectory() &&
206
+ entry.name !== "node_modules" &&
207
+ entry.name !== ".next"
208
+ ) {
198
209
  await replaceImportsInSourceFiles(fullPath, scope);
199
210
  } else if (entry.isFile() && /\.(js|ts|jsx|tsx|mjs)$/.test(entry.name)) {
200
211
  let content = await fs.readFile(fullPath, "utf-8");
@@ -245,7 +256,9 @@ program
245
256
  .argument("<project-name>", "Name of the project to create")
246
257
  .action(async (projectName) => {
247
258
  console.log(
248
- chalk.blue.bold(`\n\uD83D\uDE80 Creating your new Quark project: ${projectName}\n`),
259
+ chalk.blue.bold(
260
+ `\n\uD83D\uDE80 Creating your new Quark project: ${projectName}\n`,
261
+ ),
249
262
  );
250
263
 
251
264
  const targetDir = validateProjectName(projectName);
@@ -383,16 +396,13 @@ program
383
396
  if (await fs.pathExists(pkgPath)) {
384
397
  const pkg = await fs.readJSON(pkgPath);
385
398
  // Rename package name if it uses @quark/ prefix
386
- if (pkg.name && pkg.name.startsWith("@quark/")) {
399
+ if (pkg.name?.startsWith("@quark/")) {
387
400
  const shortName = pkg.name.replace("@quark/", "");
388
401
  pkg.name = `@${scope}/${shortName}`;
389
402
  }
390
403
  replaceDepsScope(pkg.dependencies, scope, features);
391
404
  replaceDepsScope(pkg.devDependencies, scope, features);
392
- await fs.writeFile(
393
- pkgPath,
394
- `${JSON.stringify(pkg, null, 2)}\n`,
395
- );
405
+ await fs.writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
396
406
  }
397
407
  }
398
408
 
@@ -465,6 +475,24 @@ PORT=3000
465
475
 
466
476
  # --- Worker Configuration ---
467
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
468
496
  `;
469
497
  await fs.writeFile(
470
498
  path.join(targetDir, ".env.example"),
@@ -481,7 +509,8 @@ WORKER_CONCURRENCY=5
481
509
  const webPort = await findAvailablePort(3000);
482
510
 
483
511
  const portChanges = [];
484
- if (postgresPort !== 5432) portChanges.push(`PostgreSQL: ${postgresPort}`);
512
+ if (postgresPort !== 5432)
513
+ portChanges.push(`PostgreSQL: ${postgresPort}`);
485
514
  if (redisPort !== 6379) portChanges.push(`Redis: ${redisPort}`);
486
515
  if (mailSmtpPort !== 1025) portChanges.push(`Mail SMTP: ${mailSmtpPort}`);
487
516
  if (mailUiPort !== 8025) portChanges.push(`Mail UI: ${mailUiPort}`);
@@ -529,6 +558,9 @@ PORT=${webPort}
529
558
 
530
559
  # --- Worker Configuration ---
531
560
  WORKER_CONCURRENCY=5
561
+
562
+ # --- File Storage ---
563
+ STORAGE_PROVIDER=local
532
564
  `;
533
565
  await fs.writeFile(path.join(targetDir, ".env"), envContent);
534
566
  console.log(
@@ -566,9 +598,7 @@ WORKER_CONCURRENCY=5
566
598
  console.log(chalk.green(`\n ✓ Dependencies installed`));
567
599
  } catch (installError) {
568
600
  console.warn(
569
- chalk.yellow(
570
- `\n ⚠️ pnpm install failed: ${installError.message}`,
571
- ),
601
+ chalk.yellow(`\n ⚠️ pnpm install failed: ${installError.message}`),
572
602
  );
573
603
  console.warn(
574
604
  chalk.yellow(
@@ -592,9 +622,7 @@ WORKER_CONCURRENCY=5
592
622
  ),
593
623
  );
594
624
  console.warn(
595
- chalk.yellow(
596
- ` Run 'pnpm --filter db db:generate' manually.`,
597
- ),
625
+ chalk.yellow(` Run 'pnpm --filter db db:generate' manually.`),
598
626
  );
599
627
  }
600
628
 
@@ -1,5 +1,10 @@
1
- import { hashPassword, validateBody } from "@techstream/quark-core";
1
+ import {
2
+ createQueue,
3
+ hashPassword,
4
+ validateBody,
5
+ } from "@techstream/quark-core";
2
6
  import { user, userRegisterSchema } from "@techstream/quark-db";
7
+ import { JOB_NAMES, JOB_QUEUES } from "@techstream/quark-jobs";
3
8
  import { NextResponse } from "next/server";
4
9
  import { handleError } from "../../error-handler";
5
10
 
@@ -29,6 +34,17 @@ export async function POST(request) {
29
34
  password: hashedPassword,
30
35
  });
31
36
 
37
+ // Enqueue welcome email (fire-and-forget)
38
+ try {
39
+ const emailQueue = createQueue(JOB_QUEUES.EMAIL);
40
+ await emailQueue.add(JOB_NAMES.SEND_WELCOME_EMAIL, {
41
+ userId: newUser.id,
42
+ });
43
+ } catch (emailError) {
44
+ // Don't fail registration if email enqueue fails
45
+ console.error("Failed to enqueue welcome email:", emailError);
46
+ }
47
+
32
48
  // Don't return the password
33
49
  const { password: _, ...safeUser } = newUser;
34
50
 
@@ -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
+ }
@@ -29,7 +29,10 @@ export async function GET() {
29
29
  status: result.status === "ok" ? 200 : 503,
30
30
  });
31
31
  } catch (error) {
32
- logger.error("Health check failed", { error: error.message, stack: error.stack });
32
+ logger.error("Health check failed", {
33
+ error: error.message,
34
+ stack: error.stack,
35
+ });
33
36
  return NextResponse.json(
34
37
  {
35
38
  status: "error",
@@ -1,4 +1,8 @@
1
- import { UnauthorizedError, validateBody, withCsrfProtection } from "@techstream/quark-core";
1
+ import {
2
+ UnauthorizedError,
3
+ validateBody,
4
+ withCsrfProtection,
5
+ } from "@techstream/quark-core";
2
6
  import { post, postUpdateSchema } from "@techstream/quark-db";
3
7
  import { NextResponse } from "next/server";
4
8
  import { requireAuth } from "@/lib/auth-middleware";
@@ -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
+ };