say-under-me 1.3.1 → 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/dist/index.d.mts CHANGED
@@ -37,6 +37,8 @@ type CrudConfig<T = any> = {
37
37
  ownerField?: string;
38
38
  adminRole?: string;
39
39
  hooks?: LifecycleHooks<T>;
40
+ imageFields?: string[];
41
+ publicDir?: string;
40
42
  };
41
43
  type PaginatedResponse<T> = {
42
44
  data: T[];
@@ -47,6 +49,17 @@ type PaginatedResponse<T> = {
47
49
  totalPages: number;
48
50
  };
49
51
  };
52
+ type UploadConfig = {
53
+ uploadDir?: string;
54
+ maxSize?: number;
55
+ allowedTypes?: string[];
56
+ };
57
+ type UploadResult = {
58
+ url: string;
59
+ name: string;
60
+ size: number;
61
+ type: string;
62
+ };
50
63
  declare class ForbiddenError extends Error {
51
64
  constructor(message?: string);
52
65
  }
@@ -56,6 +69,7 @@ declare function sayHello({ firstName, lastName, age, }: sayHelloProps): void;
56
69
  type PrismaQuery = {
57
70
  where?: Record<string, any>;
58
71
  select?: Record<string, any>;
72
+ include?: Record<string, any>;
59
73
  orderBy?: Record<string, any> | Record<string, any>[];
60
74
  take?: number;
61
75
  skip?: number;
@@ -107,6 +121,10 @@ declare class PrismaCrud<T> {
107
121
  * Deletes a record by ID with optional lifecycle hooks.
108
122
  */
109
123
  delete(id: string | number, context?: UserContext): Promise<any>;
124
+ /**
125
+ * Deletes a file from the filesystem if it belongs to the uploads directory.
126
+ */
127
+ private deleteLocalFile;
110
128
  /**
111
129
  * Logic to detect if an ID is a number (Int) or string (UUID/CUID).
112
130
  * This allows the API to handle both `id=1` and `id=uuid-string` automatically.
@@ -132,4 +150,23 @@ declare function createApiHandler<T>(crud: PrismaCrud<T>, options?: {
132
150
  DELETE: (req: NextRequest, context: any) => Promise<NextResponse<unknown>>;
133
151
  };
134
152
 
135
- export { type CrudConfig, ForbiddenError, type LifecycleHooks, type PaginatedResponse, type ParseOptions, type PermissionConfig, PrismaCrud, type PrismaQuery, type UserContext, createApiHandler, parsePrismaQuery, sayHello, type sayHelloProps };
153
+ /**
154
+ * Creates a Next.js App Router handler for file uploads and management.
155
+ * POST: Upload new files
156
+ * GET: List existing files
157
+ */
158
+ declare function createUploadHandler(config?: UploadConfig): {
159
+ GET: () => Promise<NextResponse<any[]> | NextResponse<{
160
+ error: string;
161
+ }>>;
162
+ POST: (req: NextRequest) => Promise<NextResponse<{
163
+ error: string;
164
+ }> | NextResponse<UploadResult | UploadResult[]>>;
165
+ DELETE: (req: NextRequest) => Promise<NextResponse<{
166
+ error: string;
167
+ }> | NextResponse<{
168
+ success: boolean;
169
+ }>>;
170
+ };
171
+
172
+ export { type CrudConfig, ForbiddenError, type LifecycleHooks, type PaginatedResponse, type ParseOptions, type PermissionConfig, PrismaCrud, type PrismaQuery, type UploadConfig, type UploadResult, type UserContext, createApiHandler, createUploadHandler, parsePrismaQuery, sayHello, type sayHelloProps };
package/dist/index.d.ts CHANGED
@@ -37,6 +37,8 @@ type CrudConfig<T = any> = {
37
37
  ownerField?: string;
38
38
  adminRole?: string;
39
39
  hooks?: LifecycleHooks<T>;
40
+ imageFields?: string[];
41
+ publicDir?: string;
40
42
  };
41
43
  type PaginatedResponse<T> = {
42
44
  data: T[];
@@ -47,6 +49,17 @@ type PaginatedResponse<T> = {
47
49
  totalPages: number;
48
50
  };
49
51
  };
52
+ type UploadConfig = {
53
+ uploadDir?: string;
54
+ maxSize?: number;
55
+ allowedTypes?: string[];
56
+ };
57
+ type UploadResult = {
58
+ url: string;
59
+ name: string;
60
+ size: number;
61
+ type: string;
62
+ };
50
63
  declare class ForbiddenError extends Error {
51
64
  constructor(message?: string);
52
65
  }
@@ -56,6 +69,7 @@ declare function sayHello({ firstName, lastName, age, }: sayHelloProps): void;
56
69
  type PrismaQuery = {
57
70
  where?: Record<string, any>;
58
71
  select?: Record<string, any>;
72
+ include?: Record<string, any>;
59
73
  orderBy?: Record<string, any> | Record<string, any>[];
60
74
  take?: number;
61
75
  skip?: number;
@@ -107,6 +121,10 @@ declare class PrismaCrud<T> {
107
121
  * Deletes a record by ID with optional lifecycle hooks.
108
122
  */
109
123
  delete(id: string | number, context?: UserContext): Promise<any>;
124
+ /**
125
+ * Deletes a file from the filesystem if it belongs to the uploads directory.
126
+ */
127
+ private deleteLocalFile;
110
128
  /**
111
129
  * Logic to detect if an ID is a number (Int) or string (UUID/CUID).
112
130
  * This allows the API to handle both `id=1` and `id=uuid-string` automatically.
@@ -132,4 +150,23 @@ declare function createApiHandler<T>(crud: PrismaCrud<T>, options?: {
132
150
  DELETE: (req: NextRequest, context: any) => Promise<NextResponse<unknown>>;
133
151
  };
134
152
 
135
- export { type CrudConfig, ForbiddenError, type LifecycleHooks, type PaginatedResponse, type ParseOptions, type PermissionConfig, PrismaCrud, type PrismaQuery, type UserContext, createApiHandler, parsePrismaQuery, sayHello, type sayHelloProps };
153
+ /**
154
+ * Creates a Next.js App Router handler for file uploads and management.
155
+ * POST: Upload new files
156
+ * GET: List existing files
157
+ */
158
+ declare function createUploadHandler(config?: UploadConfig): {
159
+ GET: () => Promise<NextResponse<any[]> | NextResponse<{
160
+ error: string;
161
+ }>>;
162
+ POST: (req: NextRequest) => Promise<NextResponse<{
163
+ error: string;
164
+ }> | NextResponse<UploadResult | UploadResult[]>>;
165
+ DELETE: (req: NextRequest) => Promise<NextResponse<{
166
+ error: string;
167
+ }> | NextResponse<{
168
+ success: boolean;
169
+ }>>;
170
+ };
171
+
172
+ export { type CrudConfig, ForbiddenError, type LifecycleHooks, type PaginatedResponse, type ParseOptions, type PermissionConfig, PrismaCrud, type PrismaQuery, type UploadConfig, type UploadResult, type UserContext, createApiHandler, createUploadHandler, parsePrismaQuery, sayHello, type sayHelloProps };
package/dist/index.js CHANGED
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
+ var __create = Object.create;
2
3
  var __defProp = Object.defineProperty;
3
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
5
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
6
8
  var __export = (target, all) => {
7
9
  for (var name in all)
@@ -15,6 +17,14 @@ var __copyProps = (to, from, except, desc) => {
15
17
  }
16
18
  return to;
17
19
  };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
18
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
29
 
20
30
  // src/index.ts
@@ -23,6 +33,7 @@ __export(index_exports, {
23
33
  ForbiddenError: () => ForbiddenError,
24
34
  PrismaCrud: () => PrismaCrud,
25
35
  createApiHandler: () => createApiHandler,
36
+ createUploadHandler: () => createUploadHandler,
26
37
  parsePrismaQuery: () => parsePrismaQuery,
27
38
  sayHello: () => sayHello
28
39
  });
@@ -96,7 +107,9 @@ function parsePrismaQuery(queryString, options = {}) {
96
107
  return;
97
108
  }
98
109
  if (key === "select") {
99
- result.select = parseSelect(value, allowedFields);
110
+ const parsed = parseSelect(value, allowedFields);
111
+ if (parsed.select) result.select = parsed.select;
112
+ if (parsed.include) result.include = parsed.include;
100
113
  return;
101
114
  }
102
115
  if (key === "order") return;
@@ -128,7 +141,7 @@ function parsePrismaQuery(queryString, options = {}) {
128
141
  }
129
142
  function parseSelect(selectStr, allowedFields, parentPath = "") {
130
143
  const result = {};
131
- if (!selectStr) return result;
144
+ if (!selectStr) return { select: result };
132
145
  let depth = 0;
133
146
  let current = "";
134
147
  const fields = [];
@@ -144,16 +157,18 @@ function parseSelect(selectStr, allowedFields, parentPath = "") {
144
157
  }
145
158
  }
146
159
  if (current) fields.push(current.trim());
160
+ const hasWildcard = fields.includes("*");
147
161
  fields.forEach((field) => {
162
+ if (field === "*") return;
148
163
  if (field.includes("(")) {
149
164
  const start = field.indexOf("(");
150
165
  const end = field.lastIndexOf(")");
151
166
  const key = field.substring(0, start).trim();
152
167
  const nestedContent = field.substring(start + 1, end).trim();
153
168
  const currentPath = parentPath ? `${parentPath}.${key}` : key;
154
- result[key] = {
155
- select: parseSelect(nestedContent, allowedFields, currentPath)
156
- };
169
+ const nested = parseSelect(nestedContent, allowedFields, currentPath);
170
+ const nestedValue = nested.select ? { select: nested.select } : nested.include ? { include: nested.include } : true;
171
+ result[key] = nestedValue;
157
172
  } else {
158
173
  const currentPath = parentPath ? `${parentPath}.${field}` : field;
159
174
  if (!allowedFields || allowedFields.includes(currentPath)) {
@@ -163,7 +178,10 @@ function parseSelect(selectStr, allowedFields, parentPath = "") {
163
178
  }
164
179
  }
165
180
  });
166
- return result;
181
+ if (hasWildcard) {
182
+ return { include: Object.keys(result).length ? result : void 0 };
183
+ }
184
+ return { select: result };
167
185
  }
168
186
  function parseValue(val, op) {
169
187
  if (val === "true") return true;
@@ -177,6 +195,8 @@ function parseValue(val, op) {
177
195
  }
178
196
 
179
197
  // src/core/prisma-crud.ts
198
+ var import_promises = require("fs/promises");
199
+ var import_path = require("path");
180
200
  var PrismaCrud = class {
181
201
  /**
182
202
  * @param model -The Prisma model delegate (e.g., prisma.user)
@@ -242,7 +262,8 @@ var PrismaCrud = class {
242
262
  const where = this.applySecurity({ id: this.parseId(id) }, context);
243
263
  const record = await this.model.findUnique({
244
264
  where,
245
- ...query.select ? { select: query.select } : {}
265
+ ...query.select ? { select: query.select } : {},
266
+ ...query.include ? { include: query.include } : {}
246
267
  });
247
268
  if (!record) throw new Error("Record not found");
248
269
  return record;
@@ -278,7 +299,29 @@ var PrismaCrud = class {
278
299
  if (!this.checkPermission("update", context)) throw new ForbiddenError();
279
300
  try {
280
301
  if (schema) data = schema.parse(data);
281
- const where = this.applySecurity({ id: this.parseId(id) }, context);
302
+ const parsedId = this.parseId(id);
303
+ const where = this.applySecurity({ id: parsedId }, context);
304
+ if (this.config.imageFields && this.config.imageFields.length > 0) {
305
+ const oldRecord = await this.model.findUnique({ where });
306
+ if (oldRecord) {
307
+ for (const field of this.config.imageFields) {
308
+ const newValue = data[field];
309
+ const oldValue = oldRecord[field];
310
+ if (newValue === void 0) continue;
311
+ if (typeof oldValue === "string" && typeof newValue === "string") {
312
+ if (oldValue !== newValue) {
313
+ await this.deleteLocalFile(oldValue);
314
+ }
315
+ } else if (Array.isArray(oldValue)) {
316
+ const newArray = Array.isArray(newValue) ? newValue : [];
317
+ const removedImages = oldValue.filter((img) => !newArray.includes(img));
318
+ for (const img of removedImages) {
319
+ await this.deleteLocalFile(img);
320
+ }
321
+ }
322
+ }
323
+ }
324
+ }
282
325
  if (this.config.hooks?.beforeUpdate) {
283
326
  data = await this.config.hooks.beforeUpdate(data);
284
327
  }
@@ -301,8 +344,24 @@ var PrismaCrud = class {
301
344
  async delete(id, context) {
302
345
  if (!this.checkPermission("delete", context)) throw new ForbiddenError();
303
346
  try {
304
- const where = this.applySecurity({ id: this.parseId(id) }, context);
305
347
  const parsedId = this.parseId(id);
348
+ const where = this.applySecurity({ id: parsedId }, context);
349
+ if (this.config.imageFields && this.config.imageFields.length > 0) {
350
+ const record = await this.model.findUnique({ where });
351
+ if (record) {
352
+ for (const field of this.config.imageFields) {
353
+ const value = record[field];
354
+ if (!value) continue;
355
+ if (Array.isArray(value)) {
356
+ for (const img of value) {
357
+ await this.deleteLocalFile(img);
358
+ }
359
+ } else if (typeof value === "string") {
360
+ await this.deleteLocalFile(value);
361
+ }
362
+ }
363
+ }
364
+ }
306
365
  if (this.config.hooks?.beforeDelete) {
307
366
  await this.config.hooks.beforeDelete(parsedId);
308
367
  }
@@ -316,6 +375,20 @@ var PrismaCrud = class {
316
375
  throw error;
317
376
  }
318
377
  }
378
+ /**
379
+ * Deletes a file from the filesystem if it belongs to the uploads directory.
380
+ */
381
+ async deleteLocalFile(relativeUrl) {
382
+ try {
383
+ if (!relativeUrl || relativeUrl.startsWith("http")) return;
384
+ const publicDir = this.config.publicDir || "public";
385
+ const absolutePath = (0, import_path.join)(process.cwd(), publicDir, relativeUrl);
386
+ await (0, import_promises.unlink)(absolutePath);
387
+ console.log(`[underme] Deleted old file: ${absolutePath}`);
388
+ } catch (error) {
389
+ console.warn(`[underme] Failed to delete file at ${relativeUrl}:`, error);
390
+ }
391
+ }
319
392
  /**
320
393
  * Logic to detect if an ID is a number (Int) or string (UUID/CUID).
321
394
  * This allows the API to handle both `id=1` and `id=uuid-string` automatically.
@@ -409,11 +482,103 @@ function createApiHandler(crud, options = {}) {
409
482
  };
410
483
  return { GET, POST, PATCH, DELETE };
411
484
  }
485
+
486
+ // src/adapters/upload-handler.ts
487
+ var import_server2 = require("next/server");
488
+ var import_promises2 = require("fs/promises");
489
+ var import_path2 = __toESM(require("path"));
490
+ var import_crypto = __toESM(require("crypto"));
491
+ function createUploadHandler(config = {}) {
492
+ const uploadDir = config.uploadDir || "public/uploads";
493
+ const maxSize = config.maxSize || 5 * 1024 * 1024;
494
+ const allowedTypes = config.allowedTypes || ["image/jpeg", "image/png", "image/webp", "image/gif"];
495
+ const GET = async () => {
496
+ try {
497
+ const absoluteUploadDir = import_path2.default.join(process.cwd(), uploadDir);
498
+ await (0, import_promises2.mkdir)(absoluteUploadDir, { recursive: true });
499
+ const files = await (0, import_promises2.readdir)(absoluteUploadDir);
500
+ const results = await Promise.all(
501
+ files.map(async (filename) => {
502
+ const filePath = import_path2.default.join(absoluteUploadDir, filename);
503
+ const fileStat = await (0, import_promises2.stat)(filePath);
504
+ if (fileStat.isDirectory()) return null;
505
+ return {
506
+ url: `/${uploadDir.replace("public/", "")}/${filename}`,
507
+ name: filename,
508
+ size: fileStat.size,
509
+ atime: fileStat.atime,
510
+ mtime: fileStat.mtime
511
+ };
512
+ })
513
+ );
514
+ return import_server2.NextResponse.json(results.filter((f) => f !== null).sort((a, b) => b.mtime.getTime() - a.mtime.getTime()));
515
+ } catch (error) {
516
+ return import_server2.NextResponse.json({ error: "Failed to list uploads: " + error.message }, { status: 500 });
517
+ }
518
+ };
519
+ const POST = async (req) => {
520
+ try {
521
+ const formData = await req.formData();
522
+ const files = formData.getAll("file");
523
+ if (!files || files.length === 0) {
524
+ return import_server2.NextResponse.json({ error: "No files uploaded" }, { status: 400 });
525
+ }
526
+ const results = [];
527
+ const absoluteUploadDir = import_path2.default.join(process.cwd(), uploadDir);
528
+ await (0, import_promises2.mkdir)(absoluteUploadDir, { recursive: true });
529
+ for (const file of files) {
530
+ if (file.size > maxSize) {
531
+ return import_server2.NextResponse.json({
532
+ error: `File ${file.name} is too large. Max size is ${maxSize / (1024 * 1024)}MB`
533
+ }, { status: 400 });
534
+ }
535
+ if (!allowedTypes.includes(file.type)) {
536
+ return import_server2.NextResponse.json({
537
+ error: `File type ${file.type} is not allowed.`
538
+ }, { status: 400 });
539
+ }
540
+ const buffer = Buffer.from(await file.arrayBuffer());
541
+ const extension = import_path2.default.extname(file.name);
542
+ const filename = `${import_crypto.default.randomUUID()}${extension}`;
543
+ const filePath = import_path2.default.join(absoluteUploadDir, filename);
544
+ await (0, import_promises2.writeFile)(filePath, buffer);
545
+ results.push({
546
+ url: `/${uploadDir.replace("public/", "")}/${filename}`,
547
+ name: filename,
548
+ // Original filename is replaced with UUID for safety
549
+ size: file.size,
550
+ type: file.type
551
+ });
552
+ }
553
+ return import_server2.NextResponse.json(files.length === 1 ? results[0] : results);
554
+ } catch (error) {
555
+ console.error("Upload Handler Error:", error);
556
+ return import_server2.NextResponse.json({ error: "Upload failed: " + error.message }, { status: 500 });
557
+ }
558
+ };
559
+ const DELETE = async (req) => {
560
+ try {
561
+ const { searchParams } = new URL(req.url);
562
+ const filename = searchParams.get("file");
563
+ if (!filename) {
564
+ return import_server2.NextResponse.json({ error: "Filename is required" }, { status: 400 });
565
+ }
566
+ const sanitizedFilename = import_path2.default.basename(filename);
567
+ const absolutePath = import_path2.default.join(process.cwd(), uploadDir, sanitizedFilename);
568
+ await (0, import_promises2.unlink)(absolutePath);
569
+ return import_server2.NextResponse.json({ success: true });
570
+ } catch (error) {
571
+ return import_server2.NextResponse.json({ error: "Failed to delete file: " + error.message }, { status: 500 });
572
+ }
573
+ };
574
+ return { GET, POST, DELETE };
575
+ }
412
576
  // Annotate the CommonJS export names for ESM import in node:
413
577
  0 && (module.exports = {
414
578
  ForbiddenError,
415
579
  PrismaCrud,
416
580
  createApiHandler,
581
+ createUploadHandler,
417
582
  parsePrismaQuery,
418
583
  sayHello
419
584
  });
package/dist/index.mjs CHANGED
@@ -66,7 +66,9 @@ function parsePrismaQuery(queryString, options = {}) {
66
66
  return;
67
67
  }
68
68
  if (key === "select") {
69
- result.select = parseSelect(value, allowedFields);
69
+ const parsed = parseSelect(value, allowedFields);
70
+ if (parsed.select) result.select = parsed.select;
71
+ if (parsed.include) result.include = parsed.include;
70
72
  return;
71
73
  }
72
74
  if (key === "order") return;
@@ -98,7 +100,7 @@ function parsePrismaQuery(queryString, options = {}) {
98
100
  }
99
101
  function parseSelect(selectStr, allowedFields, parentPath = "") {
100
102
  const result = {};
101
- if (!selectStr) return result;
103
+ if (!selectStr) return { select: result };
102
104
  let depth = 0;
103
105
  let current = "";
104
106
  const fields = [];
@@ -114,16 +116,18 @@ function parseSelect(selectStr, allowedFields, parentPath = "") {
114
116
  }
115
117
  }
116
118
  if (current) fields.push(current.trim());
119
+ const hasWildcard = fields.includes("*");
117
120
  fields.forEach((field) => {
121
+ if (field === "*") return;
118
122
  if (field.includes("(")) {
119
123
  const start = field.indexOf("(");
120
124
  const end = field.lastIndexOf(")");
121
125
  const key = field.substring(0, start).trim();
122
126
  const nestedContent = field.substring(start + 1, end).trim();
123
127
  const currentPath = parentPath ? `${parentPath}.${key}` : key;
124
- result[key] = {
125
- select: parseSelect(nestedContent, allowedFields, currentPath)
126
- };
128
+ const nested = parseSelect(nestedContent, allowedFields, currentPath);
129
+ const nestedValue = nested.select ? { select: nested.select } : nested.include ? { include: nested.include } : true;
130
+ result[key] = nestedValue;
127
131
  } else {
128
132
  const currentPath = parentPath ? `${parentPath}.${field}` : field;
129
133
  if (!allowedFields || allowedFields.includes(currentPath)) {
@@ -133,7 +137,10 @@ function parseSelect(selectStr, allowedFields, parentPath = "") {
133
137
  }
134
138
  }
135
139
  });
136
- return result;
140
+ if (hasWildcard) {
141
+ return { include: Object.keys(result).length ? result : void 0 };
142
+ }
143
+ return { select: result };
137
144
  }
138
145
  function parseValue(val, op) {
139
146
  if (val === "true") return true;
@@ -147,6 +154,8 @@ function parseValue(val, op) {
147
154
  }
148
155
 
149
156
  // src/core/prisma-crud.ts
157
+ import { unlink } from "fs/promises";
158
+ import { join } from "path";
150
159
  var PrismaCrud = class {
151
160
  /**
152
161
  * @param model -The Prisma model delegate (e.g., prisma.user)
@@ -212,7 +221,8 @@ var PrismaCrud = class {
212
221
  const where = this.applySecurity({ id: this.parseId(id) }, context);
213
222
  const record = await this.model.findUnique({
214
223
  where,
215
- ...query.select ? { select: query.select } : {}
224
+ ...query.select ? { select: query.select } : {},
225
+ ...query.include ? { include: query.include } : {}
216
226
  });
217
227
  if (!record) throw new Error("Record not found");
218
228
  return record;
@@ -248,7 +258,29 @@ var PrismaCrud = class {
248
258
  if (!this.checkPermission("update", context)) throw new ForbiddenError();
249
259
  try {
250
260
  if (schema) data = schema.parse(data);
251
- const where = this.applySecurity({ id: this.parseId(id) }, context);
261
+ const parsedId = this.parseId(id);
262
+ const where = this.applySecurity({ id: parsedId }, context);
263
+ if (this.config.imageFields && this.config.imageFields.length > 0) {
264
+ const oldRecord = await this.model.findUnique({ where });
265
+ if (oldRecord) {
266
+ for (const field of this.config.imageFields) {
267
+ const newValue = data[field];
268
+ const oldValue = oldRecord[field];
269
+ if (newValue === void 0) continue;
270
+ if (typeof oldValue === "string" && typeof newValue === "string") {
271
+ if (oldValue !== newValue) {
272
+ await this.deleteLocalFile(oldValue);
273
+ }
274
+ } else if (Array.isArray(oldValue)) {
275
+ const newArray = Array.isArray(newValue) ? newValue : [];
276
+ const removedImages = oldValue.filter((img) => !newArray.includes(img));
277
+ for (const img of removedImages) {
278
+ await this.deleteLocalFile(img);
279
+ }
280
+ }
281
+ }
282
+ }
283
+ }
252
284
  if (this.config.hooks?.beforeUpdate) {
253
285
  data = await this.config.hooks.beforeUpdate(data);
254
286
  }
@@ -271,8 +303,24 @@ var PrismaCrud = class {
271
303
  async delete(id, context) {
272
304
  if (!this.checkPermission("delete", context)) throw new ForbiddenError();
273
305
  try {
274
- const where = this.applySecurity({ id: this.parseId(id) }, context);
275
306
  const parsedId = this.parseId(id);
307
+ const where = this.applySecurity({ id: parsedId }, context);
308
+ if (this.config.imageFields && this.config.imageFields.length > 0) {
309
+ const record = await this.model.findUnique({ where });
310
+ if (record) {
311
+ for (const field of this.config.imageFields) {
312
+ const value = record[field];
313
+ if (!value) continue;
314
+ if (Array.isArray(value)) {
315
+ for (const img of value) {
316
+ await this.deleteLocalFile(img);
317
+ }
318
+ } else if (typeof value === "string") {
319
+ await this.deleteLocalFile(value);
320
+ }
321
+ }
322
+ }
323
+ }
276
324
  if (this.config.hooks?.beforeDelete) {
277
325
  await this.config.hooks.beforeDelete(parsedId);
278
326
  }
@@ -286,6 +334,20 @@ var PrismaCrud = class {
286
334
  throw error;
287
335
  }
288
336
  }
337
+ /**
338
+ * Deletes a file from the filesystem if it belongs to the uploads directory.
339
+ */
340
+ async deleteLocalFile(relativeUrl) {
341
+ try {
342
+ if (!relativeUrl || relativeUrl.startsWith("http")) return;
343
+ const publicDir = this.config.publicDir || "public";
344
+ const absolutePath = join(process.cwd(), publicDir, relativeUrl);
345
+ await unlink(absolutePath);
346
+ console.log(`[underme] Deleted old file: ${absolutePath}`);
347
+ } catch (error) {
348
+ console.warn(`[underme] Failed to delete file at ${relativeUrl}:`, error);
349
+ }
350
+ }
289
351
  /**
290
352
  * Logic to detect if an ID is a number (Int) or string (UUID/CUID).
291
353
  * This allows the API to handle both `id=1` and `id=uuid-string` automatically.
@@ -379,10 +441,102 @@ function createApiHandler(crud, options = {}) {
379
441
  };
380
442
  return { GET, POST, PATCH, DELETE };
381
443
  }
444
+
445
+ // src/adapters/upload-handler.ts
446
+ import { NextResponse as NextResponse2 } from "next/server";
447
+ import { writeFile, mkdir, readdir, stat, unlink as unlink2 } from "fs/promises";
448
+ import path from "path";
449
+ import crypto from "crypto";
450
+ function createUploadHandler(config = {}) {
451
+ const uploadDir = config.uploadDir || "public/uploads";
452
+ const maxSize = config.maxSize || 5 * 1024 * 1024;
453
+ const allowedTypes = config.allowedTypes || ["image/jpeg", "image/png", "image/webp", "image/gif"];
454
+ const GET = async () => {
455
+ try {
456
+ const absoluteUploadDir = path.join(process.cwd(), uploadDir);
457
+ await mkdir(absoluteUploadDir, { recursive: true });
458
+ const files = await readdir(absoluteUploadDir);
459
+ const results = await Promise.all(
460
+ files.map(async (filename) => {
461
+ const filePath = path.join(absoluteUploadDir, filename);
462
+ const fileStat = await stat(filePath);
463
+ if (fileStat.isDirectory()) return null;
464
+ return {
465
+ url: `/${uploadDir.replace("public/", "")}/${filename}`,
466
+ name: filename,
467
+ size: fileStat.size,
468
+ atime: fileStat.atime,
469
+ mtime: fileStat.mtime
470
+ };
471
+ })
472
+ );
473
+ return NextResponse2.json(results.filter((f) => f !== null).sort((a, b) => b.mtime.getTime() - a.mtime.getTime()));
474
+ } catch (error) {
475
+ return NextResponse2.json({ error: "Failed to list uploads: " + error.message }, { status: 500 });
476
+ }
477
+ };
478
+ const POST = async (req) => {
479
+ try {
480
+ const formData = await req.formData();
481
+ const files = formData.getAll("file");
482
+ if (!files || files.length === 0) {
483
+ return NextResponse2.json({ error: "No files uploaded" }, { status: 400 });
484
+ }
485
+ const results = [];
486
+ const absoluteUploadDir = path.join(process.cwd(), uploadDir);
487
+ await mkdir(absoluteUploadDir, { recursive: true });
488
+ for (const file of files) {
489
+ if (file.size > maxSize) {
490
+ return NextResponse2.json({
491
+ error: `File ${file.name} is too large. Max size is ${maxSize / (1024 * 1024)}MB`
492
+ }, { status: 400 });
493
+ }
494
+ if (!allowedTypes.includes(file.type)) {
495
+ return NextResponse2.json({
496
+ error: `File type ${file.type} is not allowed.`
497
+ }, { status: 400 });
498
+ }
499
+ const buffer = Buffer.from(await file.arrayBuffer());
500
+ const extension = path.extname(file.name);
501
+ const filename = `${crypto.randomUUID()}${extension}`;
502
+ const filePath = path.join(absoluteUploadDir, filename);
503
+ await writeFile(filePath, buffer);
504
+ results.push({
505
+ url: `/${uploadDir.replace("public/", "")}/${filename}`,
506
+ name: filename,
507
+ // Original filename is replaced with UUID for safety
508
+ size: file.size,
509
+ type: file.type
510
+ });
511
+ }
512
+ return NextResponse2.json(files.length === 1 ? results[0] : results);
513
+ } catch (error) {
514
+ console.error("Upload Handler Error:", error);
515
+ return NextResponse2.json({ error: "Upload failed: " + error.message }, { status: 500 });
516
+ }
517
+ };
518
+ const DELETE = async (req) => {
519
+ try {
520
+ const { searchParams } = new URL(req.url);
521
+ const filename = searchParams.get("file");
522
+ if (!filename) {
523
+ return NextResponse2.json({ error: "Filename is required" }, { status: 400 });
524
+ }
525
+ const sanitizedFilename = path.basename(filename);
526
+ const absolutePath = path.join(process.cwd(), uploadDir, sanitizedFilename);
527
+ await unlink2(absolutePath);
528
+ return NextResponse2.json({ success: true });
529
+ } catch (error) {
530
+ return NextResponse2.json({ error: "Failed to delete file: " + error.message }, { status: 500 });
531
+ }
532
+ };
533
+ return { GET, POST, DELETE };
534
+ }
382
535
  export {
383
536
  ForbiddenError,
384
537
  PrismaCrud,
385
538
  createApiHandler,
539
+ createUploadHandler,
386
540
  parsePrismaQuery,
387
541
  sayHello
388
542
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "say-under-me",
3
- "version": "1.3.1",
3
+ "version": "1.5.0",
4
4
  "main": "./dist/index.js",
5
5
  "module": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.ts",