@techstream/quark-create-app 1.5.2 → 1.6.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 (53) hide show
  1. package/README.md +38 -0
  2. package/package.json +5 -2
  3. package/src/index.js +142 -12
  4. package/templates/base-project/.github/copilot-instructions.md +7 -0
  5. package/templates/base-project/.github/dependabot.yml +12 -0
  6. package/templates/base-project/.github/skills/project-context/SKILL.md +106 -0
  7. package/templates/base-project/.github/workflows/ci.yml +97 -0
  8. package/templates/base-project/.github/workflows/dependabot-auto-merge.yml +22 -0
  9. package/templates/base-project/.github/workflows/release.yml +38 -0
  10. package/templates/base-project/apps/web/biome.json +7 -0
  11. package/templates/base-project/apps/web/jsconfig.json +10 -0
  12. package/templates/base-project/apps/web/next.config.js +86 -1
  13. package/templates/base-project/apps/web/package.json +4 -4
  14. package/templates/base-project/apps/web/src/app/api/auth/register/route.js +9 -9
  15. package/templates/base-project/apps/web/src/app/api/files/route.js +3 -2
  16. package/templates/base-project/apps/web/src/app/layout.js +3 -4
  17. package/templates/base-project/apps/web/src/app/manifest.js +12 -0
  18. package/templates/base-project/apps/web/src/app/robots.js +21 -0
  19. package/templates/base-project/apps/web/src/app/sitemap.js +20 -0
  20. package/templates/base-project/apps/web/src/lib/seo/indexing.js +23 -0
  21. package/templates/base-project/apps/web/src/lib/seo/site-metadata.js +33 -0
  22. package/templates/base-project/apps/web/src/proxy.js +1 -2
  23. package/templates/base-project/apps/worker/package.json +4 -4
  24. package/templates/base-project/apps/worker/src/index.js +40 -12
  25. package/templates/base-project/apps/worker/src/index.test.js +296 -15
  26. package/templates/base-project/biome.json +44 -0
  27. package/templates/base-project/docker-compose.yml +7 -4
  28. package/templates/base-project/package.json +1 -1
  29. package/templates/base-project/packages/db/package.json +1 -1
  30. package/templates/base-project/packages/db/prisma/migrations/20260202061128_initial/migration.sql +42 -0
  31. package/templates/base-project/packages/db/prisma/schema.prisma +20 -16
  32. package/templates/base-project/packages/db/prisma.config.ts +3 -2
  33. package/templates/base-project/packages/db/scripts/seed.js +117 -30
  34. package/templates/base-project/packages/db/src/queries.js +58 -68
  35. package/templates/base-project/packages/db/src/queries.test.js +0 -29
  36. package/templates/base-project/packages/db/src/schemas.js +4 -10
  37. package/templates/base-project/pnpm-workspace.yaml +4 -0
  38. package/templates/base-project/turbo.json +5 -3
  39. package/templates/config/package.json +2 -0
  40. package/templates/config/src/environment.js +270 -0
  41. package/templates/config/src/index.js +10 -18
  42. package/templates/config/src/load-config.js +135 -0
  43. package/templates/config/src/validate-env.js +60 -2
  44. package/templates/jobs/package.json +2 -2
  45. package/templates/jobs/src/definitions.test.js +34 -0
  46. package/templates/jobs/src/index.js +1 -1
  47. package/templates/ui/package.json +4 -4
  48. package/templates/ui/src/button.test.js +23 -0
  49. package/templates/ui/src/index.js +1 -3
  50. package/templates/base-project/apps/web/src/app/api/posts/[id]/route.js +0 -65
  51. package/templates/base-project/apps/web/src/app/api/posts/route.js +0 -42
  52. package/templates/ui/src/card.js +0 -14
  53. package/templates/ui/src/input.js +0 -11
@@ -1,19 +1,300 @@
1
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");
2
+ import { describe, mock, test } from "node:test";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Helpers: lightweight fakes for Prisma, emailService, and storage
6
+ // ---------------------------------------------------------------------------
7
+
8
+ function createMockLogger() {
9
+ return {
10
+ info: mock.fn(),
11
+ warn: mock.fn(),
12
+ error: mock.fn(),
13
+ };
14
+ }
15
+
16
+ function makeBullJob(name, data, overrides = {}) {
17
+ return { id: "job-1", name, data, attemptsMade: 0, ...overrides };
18
+ }
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // handleSendWelcomeEmail
22
+ // ---------------------------------------------------------------------------
23
+
24
+ describe("handleSendWelcomeEmail", () => {
25
+ test("throws when userId is missing", async () => {
26
+ // Inline handler that mirrors the real one's validation
27
+ const handler = async (bullJob) => {
28
+ const { userId } = bullJob.data;
29
+ if (!userId) {
30
+ throw new Error("userId is required for SEND_WELCOME_EMAIL job");
31
+ }
32
+ };
33
+
34
+ await assert.rejects(() => handler(makeBullJob("send-welcome-email", {})), {
35
+ message: "userId is required for SEND_WELCOME_EMAIL job",
36
+ });
37
+ });
38
+
39
+ test("throws when user is not found", async () => {
40
+ const handler = async (bullJob) => {
41
+ const { userId } = bullJob.data;
42
+ if (!userId) throw new Error("userId is required");
43
+ // Simulate user not found
44
+ const userRecord = null;
45
+ if (!userRecord?.email) {
46
+ throw new Error(`User ${userId} not found or has no email`);
47
+ }
48
+ };
49
+
50
+ await assert.rejects(
51
+ () => handler(makeBullJob("send-welcome-email", { userId: "user-123" })),
52
+ { message: "User user-123 not found or has no email" },
53
+ );
54
+ });
55
+
56
+ test("returns success when user exists and email sends", async () => {
57
+ const sendEmail = mock.fn(async () => {});
58
+
59
+ const handler = async (bullJob, _logger) => {
60
+ const { userId } = bullJob.data;
61
+ if (!userId) throw new Error("userId is required");
62
+
63
+ const userRecord = { email: "a@b.com", name: "Alice" };
64
+ if (!userRecord?.email) {
65
+ throw new Error(`User ${userId} not found or has no email`);
66
+ }
67
+
68
+ await sendEmail(userRecord.email, "Welcome", "<p>Hi</p>", "Hi");
69
+ return { success: true, userId, email: userRecord.email };
70
+ };
71
+
72
+ const logger = createMockLogger();
73
+ const result = await handler(
74
+ makeBullJob("send-welcome-email", { userId: "user-123" }),
75
+ logger,
76
+ );
77
+
78
+ assert.deepStrictEqual(result, {
79
+ success: true,
80
+ userId: "user-123",
81
+ email: "a@b.com",
82
+ });
83
+ assert.strictEqual(sendEmail.mock.callCount(), 1);
84
+ });
85
+ });
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // handleSendResetPasswordEmail
89
+ // ---------------------------------------------------------------------------
90
+
91
+ describe("handleSendResetPasswordEmail", () => {
92
+ test("throws when userId or resetUrl is missing", async () => {
93
+ const handler = async (bullJob) => {
94
+ const { userId, resetUrl } = bullJob.data;
95
+ if (!userId || !resetUrl) {
96
+ throw new Error(
97
+ "userId and resetUrl are required for SEND_RESET_PASSWORD_EMAIL job",
98
+ );
99
+ }
100
+ };
101
+
102
+ await assert.rejects(
103
+ () =>
104
+ handler(
105
+ makeBullJob("send-reset-password-email", {
106
+ userId: "user-1",
107
+ }),
108
+ ),
109
+ {
110
+ message:
111
+ "userId and resetUrl are required for SEND_RESET_PASSWORD_EMAIL job",
112
+ },
113
+ );
114
+
115
+ await assert.rejects(
116
+ () =>
117
+ handler(
118
+ makeBullJob("send-reset-password-email", {
119
+ resetUrl: "https://example.com/reset",
120
+ }),
121
+ ),
122
+ {
123
+ message:
124
+ "userId and resetUrl are required for SEND_RESET_PASSWORD_EMAIL job",
125
+ },
126
+ );
127
+ });
128
+ });
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // handleCleanupOrphanedFiles
132
+ // ---------------------------------------------------------------------------
133
+
134
+ describe("handleCleanupOrphanedFiles", () => {
135
+ test("returns deleted: 0 when no orphaned files exist", async () => {
136
+ const handler = async (bullJob, logger) => {
137
+ const retentionHours = bullJob.data?.retentionHours || 24;
138
+ const _cutoff = new Date(Date.now() - retentionHours * 60 * 60 * 1000);
139
+
140
+ logger.info("Starting orphaned file cleanup", { retentionHours });
141
+
142
+ // Simulate no orphaned files
143
+ const orphaned = [];
144
+ if (orphaned.length === 0) {
145
+ logger.info("No orphaned files to clean up");
146
+ return { success: true, deleted: 0 };
147
+ }
148
+ };
149
+
150
+ const logger = createMockLogger();
151
+ const result = await handler(
152
+ makeBullJob("cleanup-orphaned-files", { retentionHours: 24 }),
153
+ logger,
154
+ );
155
+
156
+ assert.deepStrictEqual(result, { success: true, deleted: 0 });
157
+ });
158
+
159
+ test("deletes orphaned files and reports counts", async () => {
160
+ const deletedKeys = [];
161
+ const deletedIds = [];
162
+
163
+ const handler = async (bullJob, logger) => {
164
+ const retentionHours = bullJob.data?.retentionHours || 24;
165
+ logger.info("Starting cleanup", { retentionHours });
166
+
167
+ const orphaned = [
168
+ { id: "f1", storageKey: "uploads/f1.jpg" },
169
+ { id: "f2", storageKey: "uploads/f2.png" },
170
+ ];
171
+
172
+ let deleted = 0;
173
+ const errors = [];
174
+
175
+ for (const record of orphaned) {
176
+ try {
177
+ deletedKeys.push(record.storageKey);
178
+ deletedIds.push(record.id);
179
+ deleted++;
180
+ } catch (err) {
181
+ errors.push({ id: record.id, error: err.message });
182
+ }
183
+ }
184
+
185
+ return { success: true, deleted, total: orphaned.length, errors };
186
+ };
187
+
188
+ const logger = createMockLogger();
189
+ const result = await handler(
190
+ makeBullJob("cleanup-orphaned-files", { retentionHours: 12 }),
191
+ logger,
192
+ );
193
+
194
+ assert.strictEqual(result.deleted, 2);
195
+ assert.strictEqual(result.total, 2);
196
+ assert.deepStrictEqual(result.errors, []);
197
+ assert.deepStrictEqual(deletedKeys, ["uploads/f1.jpg", "uploads/f2.png"]);
198
+ });
199
+
200
+ test("continues on individual file delete failure", async () => {
201
+ const handler = async (_bullJob, logger) => {
202
+ const orphaned = [
203
+ { id: "f1", storageKey: "uploads/f1.jpg" },
204
+ { id: "f2", storageKey: "uploads/f2.png" },
205
+ ];
206
+
207
+ let deleted = 0;
208
+ const errors = [];
209
+
210
+ for (const record of orphaned) {
211
+ try {
212
+ if (record.id === "f1") {
213
+ throw new Error("S3 timeout");
214
+ }
215
+ deleted++;
216
+ } catch (err) {
217
+ errors.push({ id: record.id, error: err.message });
218
+ logger.warn(`Failed to delete file ${record.id}: ${err.message}`);
219
+ }
220
+ }
221
+
222
+ return { success: true, deleted, total: orphaned.length, errors };
223
+ };
224
+
225
+ const logger = createMockLogger();
226
+ const result = await handler(
227
+ makeBullJob("cleanup-orphaned-files", {}),
228
+ logger,
229
+ );
230
+
231
+ assert.strictEqual(result.deleted, 1);
232
+ assert.strictEqual(result.errors.length, 1);
233
+ assert.strictEqual(result.errors[0].id, "f1");
234
+ assert.strictEqual(result.errors[0].error, "S3 timeout");
235
+ assert.strictEqual(logger.warn.mock.callCount(), 1);
236
+ });
237
+
238
+ test("uses default 24h retention when not specified", async () => {
239
+ let capturedRetention;
240
+
241
+ const handler = async (bullJob) => {
242
+ capturedRetention = bullJob.data?.retentionHours || 24;
243
+ return { success: true, deleted: 0 };
244
+ };
245
+
246
+ await handler(makeBullJob("cleanup-orphaned-files", {}));
247
+ assert.strictEqual(capturedRetention, 24);
248
+ });
14
249
  });
15
250
 
16
- test("Worker - Worker can be instantiated", async () => {
17
- // Basic smoke test - bullmq Worker is available
18
- assert.ok(true, "Worker should be instantiable");
251
+ // ---------------------------------------------------------------------------
252
+ // Handler registry
253
+ // ---------------------------------------------------------------------------
254
+
255
+ describe("jobHandlers registry", () => {
256
+ test("maps all expected job names to functions", () => {
257
+ const JOB_NAMES = {
258
+ SEND_WELCOME_EMAIL: "send-welcome-email",
259
+ SEND_RESET_PASSWORD_EMAIL: "send-reset-password-email",
260
+ CLEANUP_ORPHANED_FILES: "cleanup-orphaned-files",
261
+ };
262
+
263
+ // Simulate the registry
264
+ const jobHandlers = {
265
+ [JOB_NAMES.SEND_WELCOME_EMAIL]: () => {},
266
+ [JOB_NAMES.SEND_RESET_PASSWORD_EMAIL]: () => {},
267
+ [JOB_NAMES.CLEANUP_ORPHANED_FILES]: () => {},
268
+ };
269
+
270
+ assert.strictEqual(
271
+ Object.keys(jobHandlers).length,
272
+ 3,
273
+ "Should have exactly 3 handlers",
274
+ );
275
+
276
+ for (const name of Object.values(JOB_NAMES)) {
277
+ assert.strictEqual(
278
+ typeof jobHandlers[name],
279
+ "function",
280
+ `Handler for "${name}" should be a function`,
281
+ );
282
+ }
283
+ });
284
+
285
+ test("throws for unregistered job name", async () => {
286
+ const jobHandlers = {};
287
+
288
+ const dispatch = async (bullJob) => {
289
+ const handler = jobHandlers[bullJob.name];
290
+ if (!handler) {
291
+ throw new Error(`No handler registered for job: ${bullJob.name}`);
292
+ }
293
+ return handler(bullJob);
294
+ };
295
+
296
+ await assert.rejects(() => dispatch(makeBullJob("unknown-job", {})), {
297
+ message: "No handler registered for job: unknown-job",
298
+ });
299
+ });
19
300
  });
@@ -0,0 +1,44 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/2.4.0/schema.json",
3
+ "vcs": {
4
+ "enabled": true,
5
+ "clientKind": "git",
6
+ "useIgnoreFile": true
7
+ },
8
+ "files": {
9
+ "ignoreUnknown": false,
10
+ "includes": ["**", "!**/.next", "!**/dist", "!**/.turbo", "!**/coverage"]
11
+ },
12
+ "formatter": {
13
+ "enabled": true,
14
+ "indentStyle": "tab"
15
+ },
16
+ "linter": {
17
+ "enabled": true,
18
+ "rules": {
19
+ "recommended": true
20
+ }
21
+ },
22
+ "javascript": {
23
+ "formatter": {
24
+ "quoteStyle": "double"
25
+ }
26
+ },
27
+ "css": {
28
+ "parser": {
29
+ "cssModules": true,
30
+ "tailwindDirectives": true
31
+ },
32
+ "linter": {
33
+ "enabled": true
34
+ }
35
+ },
36
+ "assist": {
37
+ "enabled": true,
38
+ "actions": {
39
+ "source": {
40
+ "organizeImports": "on"
41
+ }
42
+ }
43
+ }
44
+ }
@@ -2,13 +2,14 @@ services:
2
2
  # --- 1. PostgreSQL Database ---
3
3
  postgres:
4
4
  image: postgres:16-alpine
5
+ container_name: postgres
5
6
  restart: always
6
7
  ports:
7
8
  - "${POSTGRES_PORT:-5432}:5432"
8
9
  environment:
9
- POSTGRES_USER: ${POSTGRES_USER}
10
- POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
11
- POSTGRES_DB: ${POSTGRES_DB}
10
+ POSTGRES_USER: ${POSTGRES_USER:-quark_user}
11
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-CHANGE_ME_IN_ENV_FILE}
12
+ POSTGRES_DB: ${POSTGRES_DB:-quark_dev}
12
13
  volumes:
13
14
  - postgres_data:/var/lib/postgresql/data
14
15
  healthcheck:
@@ -20,6 +21,7 @@ services:
20
21
  # --- 2. Redis Cache & Job Queue ---
21
22
  redis:
22
23
  image: redis:7-alpine
24
+ container_name: redis
23
25
  restart: always
24
26
  ports:
25
27
  - "${REDIS_PORT:-6379}:6379"
@@ -35,6 +37,7 @@ services:
35
37
  # --- 3. Mailpit (Local SMTP Server) ---
36
38
  mailpit:
37
39
  image: ghcr.io/axllent/mailpit
40
+ container_name: mailpit
38
41
  restart: always
39
42
  ports:
40
43
  # SMTP port (used by application to send mail)
@@ -49,4 +52,4 @@ services:
49
52
 
50
53
  volumes:
51
54
  postgres_data:
52
- redis_data:
55
+ redis_data:
@@ -36,7 +36,7 @@
36
36
  }
37
37
  },
38
38
  "devDependencies": {
39
- "@biomejs/biome": "^2.3.13",
39
+ "@biomejs/biome": "^2.4.0",
40
40
  "@types/node": "^24.10.9",
41
41
  "dotenv-cli": "^11.0.0",
42
42
  "tsx": "^4.21.0",
@@ -21,6 +21,7 @@
21
21
  "license": "ISC",
22
22
  "packageManager": "pnpm@10.12.1",
23
23
  "devDependencies": {
24
+ "@faker-js/faker": "^9.0.0",
24
25
  "@techstream/quark-config": "workspace:*",
25
26
  "bcryptjs": "^3.0.3",
26
27
  "prisma": "^7.4.0"
@@ -28,7 +29,6 @@
28
29
  "dependencies": {
29
30
  "@prisma/adapter-pg": "^7.4.0",
30
31
  "@prisma/client": "^7.4.0",
31
- "dotenv": "^17.2.4",
32
32
  "pg": "^8.18.0",
33
33
  "zod": "^4.3.6"
34
34
  }
@@ -43,6 +43,8 @@ CREATE TABLE "Account" (
43
43
  "scope" TEXT,
44
44
  "id_token" TEXT,
45
45
  "session_state" TEXT,
46
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
47
+ "updatedAt" TIMESTAMP(3) NOT NULL,
46
48
 
47
49
  CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
48
50
  );
@@ -86,6 +88,22 @@ CREATE TABLE "Job" (
86
88
  CONSTRAINT "Job_pkey" PRIMARY KEY ("id")
87
89
  );
88
90
 
91
+ -- CreateTable
92
+ CREATE TABLE "File" (
93
+ "id" TEXT NOT NULL,
94
+ "filename" TEXT NOT NULL,
95
+ "originalName" TEXT NOT NULL,
96
+ "mimeType" TEXT NOT NULL,
97
+ "size" INTEGER NOT NULL,
98
+ "storageKey" TEXT NOT NULL,
99
+ "storageProvider" TEXT NOT NULL DEFAULT 'local',
100
+ "uploadedById" TEXT,
101
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
102
+ "updatedAt" TIMESTAMP(3) NOT NULL,
103
+
104
+ CONSTRAINT "File_pkey" PRIMARY KEY ("id")
105
+ );
106
+
89
107
  -- CreateTable
90
108
  CREATE TABLE "AuditLog" (
91
109
  "id" TEXT NOT NULL,
@@ -130,6 +148,9 @@ CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
130
148
  -- CreateIndex
131
149
  CREATE INDEX "Session_userId_idx" ON "Session"("userId");
132
150
 
151
+ -- CreateIndex
152
+ CREATE INDEX "Session_expires_idx" ON "Session"("expires");
153
+
133
154
  -- CreateIndex
134
155
  CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
135
156
 
@@ -139,6 +160,9 @@ CREATE INDEX "VerificationToken_token_idx" ON "VerificationToken"("token");
139
160
  -- CreateIndex
140
161
  CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
141
162
 
163
+ -- CreateIndex
164
+ CREATE INDEX "VerificationToken_expires_idx" ON "VerificationToken"("expires");
165
+
142
166
  -- CreateIndex
143
167
  CREATE INDEX "Job_queue_idx" ON "Job"("queue");
144
168
 
@@ -148,9 +172,24 @@ CREATE INDEX "Job_status_idx" ON "Job"("status");
148
172
  -- CreateIndex
149
173
  CREATE INDEX "Job_runAt_idx" ON "Job"("runAt");
150
174
 
175
+ -- CreateIndex
176
+ CREATE INDEX "Job_status_runAt_idx" ON "Job"("status", "runAt");
177
+
151
178
  -- CreateIndex
152
179
  CREATE INDEX "Job_createdAt_idx" ON "Job"("createdAt");
153
180
 
181
+ -- CreateIndex
182
+ CREATE UNIQUE INDEX "File_storageKey_key" ON "File"("storageKey");
183
+
184
+ -- CreateIndex
185
+ CREATE INDEX "File_uploadedById_idx" ON "File"("uploadedById");
186
+
187
+ -- CreateIndex
188
+ CREATE INDEX "File_mimeType_idx" ON "File"("mimeType");
189
+
190
+ -- CreateIndex
191
+ CREATE INDEX "File_createdAt_idx" ON "File"("createdAt");
192
+
154
193
  -- CreateIndex
155
194
  CREATE INDEX "AuditLog_userId_idx" ON "AuditLog"("userId");
156
195
 
@@ -172,5 +211,8 @@ ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId"
172
211
  -- AddForeignKey
173
212
  ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
174
213
 
214
+ -- AddForeignKey
215
+ ALTER TABLE "File" ADD CONSTRAINT "File_uploadedById_fkey" FOREIGN KEY ("uploadedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
216
+
175
217
  -- AddForeignKey
176
218
  ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -24,30 +24,15 @@ model User {
24
24
  createdAt DateTime @default(now())
25
25
  updatedAt DateTime @updatedAt
26
26
 
27
- posts Post[]
28
27
  accounts Account[]
29
28
  sessions Session[]
30
29
  auditLogs AuditLog[]
30
+ files File[]
31
31
 
32
32
  @@index([email])
33
33
  @@index([createdAt])
34
34
  }
35
35
 
36
- model Post {
37
- id String @id @default(cuid())
38
- title String
39
- content String? @db.Text
40
- published Boolean @default(false)
41
- authorId String
42
- author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
43
- createdAt DateTime @default(now())
44
- updatedAt DateTime @updatedAt
45
-
46
- @@index([authorId])
47
- @@index([published])
48
- @@index([createdAt])
49
- }
50
-
51
36
  // NextAuth Models
52
37
  model Account {
53
38
  id String @id @default(cuid())
@@ -127,6 +112,25 @@ enum JobStatus {
127
112
  CANCELLED
128
113
  }
129
114
 
115
+ // File Upload Model
116
+ model File {
117
+ id String @id @default(cuid())
118
+ filename String
119
+ originalName String
120
+ mimeType String
121
+ size Int
122
+ storageKey String @unique
123
+ storageProvider String @default("local")
124
+ uploadedById String?
125
+ uploadedBy User? @relation(fields: [uploadedById], references: [id], onDelete: SetNull)
126
+ createdAt DateTime @default(now())
127
+ updatedAt DateTime @updatedAt
128
+
129
+ @@index([uploadedById])
130
+ @@index([mimeType])
131
+ @@index([createdAt])
132
+ }
133
+
130
134
  // Audit Log Model
131
135
  model AuditLog {
132
136
  id String @id @default(cuid())
@@ -1,9 +1,10 @@
1
1
  import { resolve } from "node:path";
2
- import { config } from "dotenv";
3
2
  import { defineConfig } from "prisma/config";
4
3
 
5
4
  // Load .env from monorepo root (needed for standalone commands like db:push, db:seed)
6
- config({ path: resolve(__dirname, "../../.env"), quiet: true });
5
+ try {
6
+ process.loadEnvFile(resolve(__dirname, "../../.env"));
7
+ } catch {}
7
8
 
8
9
  // Construct DATABASE_URL from individual env vars - single source of truth
9
10
  const user = process.env.POSTGRES_USER || "quark_user";