@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.
- package/package.json +34 -34
- package/src/index.js +44 -16
- package/templates/base-project/apps/web/src/app/api/auth/register/route.js +17 -1
- 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/web/src/app/api/health/route.js +4 -1
- package/templates/base-project/apps/web/src/app/api/posts/[id]/route.js +5 -1
- 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 +60 -90
- package/templates/base-project/package.json +7 -1
- package/templates/base-project/packages/db/scripts/seed.js +1 -1
- package/templates/base-project/packages/db/src/client.js +40 -24
- package/templates/base-project/turbo.json +26 -1
- package/templates/jobs/src/definitions.js +2 -1
- package/templates/jobs/src/index.js +0 -1
- package/templates/base-project/packages/db/src/generated/prisma/browser.ts +0 -53
- package/templates/base-project/packages/db/src/generated/prisma/client.ts +0 -82
- package/templates/base-project/packages/db/src/generated/prisma/commonInputTypes.ts +0 -649
- package/templates/base-project/packages/db/src/generated/prisma/enums.ts +0 -19
- package/templates/base-project/packages/db/src/generated/prisma/internal/class.ts +0 -305
- package/templates/base-project/packages/db/src/generated/prisma/internal/prismaNamespace.ts +0 -1428
- package/templates/base-project/packages/db/src/generated/prisma/internal/prismaNamespaceBrowser.ts +0 -217
- package/templates/base-project/packages/db/src/generated/prisma/models/Account.ts +0 -2098
- package/templates/base-project/packages/db/src/generated/prisma/models/AuditLog.ts +0 -1805
- package/templates/base-project/packages/db/src/generated/prisma/models/Job.ts +0 -1737
- package/templates/base-project/packages/db/src/generated/prisma/models/Post.ts +0 -1762
- package/templates/base-project/packages/db/src/generated/prisma/models/Session.ts +0 -1738
- package/templates/base-project/packages/db/src/generated/prisma/models/User.ts +0 -2298
- package/templates/base-project/packages/db/src/generated/prisma/models/VerificationToken.ts +0 -1450
- package/templates/base-project/packages/db/src/generated/prisma/models.ts +0 -18
- package/templates/jobs/src/handlers.js +0 -20
package/package.json
CHANGED
|
@@ -1,35 +1,35 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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(
|
|
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 (
|
|
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 (
|
|
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(
|
|
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
|
|
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)
|
|
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 {
|
|
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", {
|
|
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 {
|
|
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
|
+
};
|