@mustafahmed1997/learnify-backend 1.9.0 → 2.0.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/README.md +412 -373
- package/dist/errors/globalErrorHandler.js +2 -1
- package/dist/errors/globalErrorHandler.js.map +1 -1
- package/dist/middlewares/validationMiddleware.js +8 -1
- package/dist/middlewares/validationMiddleware.js.map +1 -1
- package/dist/utils/handleCaughtError.js +8 -2
- package/dist/utils/handleCaughtError.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,17 +1,54 @@
|
|
|
1
1
|
# @mustafahmed1997/learnify-backend
|
|
2
2
|
|
|
3
|
-
Shared backend
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
3
|
+
Shared backend building blocks for the Learnify e-learning microservices:
|
|
4
|
+
Clerk-based authentication and role middleware, Zod request validation, a
|
|
5
|
+
consistent error model with an Express error handler, Prisma error mapping,
|
|
6
|
+
response/message helpers, and the typed `req.user` augmentation.
|
|
7
|
+
|
|
8
|
+
Every service needs to answer the same questions: who is calling, are they
|
|
9
|
+
allowed to do this, is the request well-formed, and what shape should
|
|
10
|
+
responses and errors take. This package answers them once, so services don't
|
|
11
|
+
diverge.
|
|
12
|
+
|
|
13
|
+
## Contents
|
|
14
|
+
|
|
15
|
+
- [Features](#features)
|
|
16
|
+
- [Installation](#installation)
|
|
17
|
+
- [Requirements and compatibility](#requirements-and-compatibility)
|
|
18
|
+
- [Quick start](#quick-start)
|
|
19
|
+
- [API reference](#api-reference)
|
|
20
|
+
- [Middleware](#middleware)
|
|
21
|
+
- [Errors](#errors)
|
|
22
|
+
- [Response and message helpers](#response-and-message-helpers)
|
|
23
|
+
- [Role utilities and constants](#role-utilities-and-constants)
|
|
24
|
+
- [Types](#types)
|
|
25
|
+
- [Error handling](#error-handling)
|
|
26
|
+
- [Testing](#testing)
|
|
27
|
+
- [Build and development](#build-and-development)
|
|
28
|
+
- [Project structure](#project-structure)
|
|
29
|
+
- [Contributing](#contributing)
|
|
30
|
+
- [Publishing](#publishing)
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
| Export | Kind | Use it to |
|
|
35
|
+
| --- | --- | --- |
|
|
36
|
+
| `checkAuth` | middleware | Reject unauthenticated requests (`401`) and attach `req.user` |
|
|
37
|
+
| `checkRole` | middleware factory | Authorize by Clerk roles with `Any` / `All` / `Exact` matching |
|
|
38
|
+
| `validationMiddleware` | middleware factory | Validate `body`, `params` and `query` with Zod and write parsed values back |
|
|
39
|
+
| `AppError` | class | Throw an operational error with an HTTP status code |
|
|
40
|
+
| `ValidationError` | class | Throw a `400` error with field-level details |
|
|
41
|
+
| `globalErrorHandler` | error middleware | Turn errors into consistent JSON responses |
|
|
42
|
+
| `handlePrismaError` | function | Map a Prisma error to `{ status, message }` |
|
|
43
|
+
| `handleCaughtError` | function | Forward any caught error to `next()` in the right shape |
|
|
44
|
+
| `getJsonResponse` | function | Build a response body that omits `undefined` fields |
|
|
45
|
+
| `getSuccessMsg`, `getErrorMsg` | functions | Build consistent success/error sentences |
|
|
46
|
+
| `requiredFieldMsg`, `minLengthMsg`, … | functions | Reuse standard validation messages in Zod schemas |
|
|
47
|
+
| `checkUnique`, `checkValidRoles` | functions | Validate role arrays outside the middleware |
|
|
48
|
+
| `validRoles` | constant | The canonical role list: `STUDENT`, `INSTRUCTOR`, `ADMIN` |
|
|
49
|
+
| `Role`, `ClerkUser`, `Location`, `ValidationErrorItem`, `JsonResponseOptions`, `CheckRoleOptions`, `RoleCheckMode` | types | Type your services against the same contracts |
|
|
50
|
+
|
|
51
|
+
Everything is exported from the package root. There are no subpath exports.
|
|
15
52
|
|
|
16
53
|
## Installation
|
|
17
54
|
|
|
@@ -19,516 +56,518 @@ doesn't reimplement or subtly diverge on it.
|
|
|
19
56
|
npm install @mustafahmed1997/learnify-backend
|
|
20
57
|
```
|
|
21
58
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
`@clerk/express`, and `zod` installs (so there is a single shared instance of
|
|
26
|
-
each at runtime, and Clerk's request-scoped auth context works correctly):
|
|
59
|
+
`express`, `@clerk/express` and `zod` are **peer dependencies**. Install them in
|
|
60
|
+
the consuming service so a single instance of each is shared at runtime (Clerk's
|
|
61
|
+
request auth context and Zod's schema classes depend on this):
|
|
27
62
|
|
|
28
63
|
```bash
|
|
29
64
|
npm install express @clerk/express zod
|
|
30
65
|
```
|
|
31
66
|
|
|
32
|
-
|
|
67
|
+
| Peer dependency | Supported range |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
| `express` | `^5.1.0` |
|
|
70
|
+
| `@clerk/express` | `^1.7.74` |
|
|
71
|
+
| `zod` | `^4.5.4` |
|
|
72
|
+
|
|
73
|
+
## Requirements and compatibility
|
|
33
74
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
75
|
+
- **ESM only.** The package is `"type": "module"` and its `exports` map
|
|
76
|
+
provides only an `import` condition. Use `import`, not `require()`.
|
|
77
|
+
- **Express 5.** The middleware targets Express 5 (for example, it handles
|
|
78
|
+
Express 5's read-only `req.query` getter).
|
|
79
|
+
- **Node.js.** The package doesn't declare an `engines` field. Its peer
|
|
80
|
+
`@clerk/express` (1.7.x) requires Node.js `>= 18.17.0`.
|
|
81
|
+
- **TypeScript.** Declarations (`.d.ts`) are bundled; no `@types` package is
|
|
82
|
+
needed. Because entry points are resolved through `exports`, the consuming
|
|
83
|
+
project needs `moduleResolution` set to `node16`, `nodenext` or `bundler`.
|
|
84
|
+
The package itself compiles with TypeScript 5 to an `ES2020` target.
|
|
85
|
+
|
|
86
|
+
## Quick start
|
|
37
87
|
|
|
38
88
|
```ts
|
|
39
89
|
import express from "express";
|
|
40
90
|
import { clerkMiddleware } from "@clerk/express";
|
|
91
|
+
import { z } from "zod";
|
|
41
92
|
import {
|
|
93
|
+
AppError,
|
|
42
94
|
checkAuth,
|
|
43
95
|
checkRole,
|
|
44
|
-
|
|
96
|
+
getJsonResponse,
|
|
97
|
+
getSuccessMsg,
|
|
45
98
|
globalErrorHandler,
|
|
99
|
+
handleCaughtError,
|
|
100
|
+
validationMiddleware,
|
|
46
101
|
} from "@mustafahmed1997/learnify-backend";
|
|
102
|
+
import { prisma } from "./prisma.js"; // your service's Prisma client
|
|
47
103
|
|
|
48
104
|
const app = express();
|
|
49
105
|
|
|
50
106
|
app.use(express.json());
|
|
51
|
-
app.use(clerkMiddleware());
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
## Public exports
|
|
107
|
+
app.use(clerkMiddleware()); // must run before checkAuth / checkRole
|
|
55
108
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
| `validationMiddleware` | middleware factory | Validates `body` / `params` / `query` against Zod schemas |
|
|
61
|
-
| `validRoles`, `Role` | const / type | The canonical list of roles and their type |
|
|
62
|
-
| `AppError` | class | Base operational error carrying an HTTP status code |
|
|
63
|
-
| `ValidationError` | class | `AppError` subclass carrying structured field errors |
|
|
64
|
-
| `ValidationErrorItem` | type | Shape of one entry in `ValidationError.errors` |
|
|
65
|
-
| `globalErrorHandler` | middleware | Express error handler; register last |
|
|
66
|
-
| `handlePrismaError` | function | Maps a Prisma error to `{ status, message }` |
|
|
67
|
-
| `getErrorMsg`, `getSuccessMsg` | function | Consistent error/success message builders |
|
|
68
|
-
| `getJsonResponse`, `JsonResponseOptions` | function / type | Builds a sparse, consistent response body |
|
|
69
|
-
| `checkUnique` | function | Checks an array has no duplicate values |
|
|
70
|
-
| `checkValidRoles` | function | Checks every value in an array is a valid `Role` |
|
|
71
|
-
| `requiredFieldMsg`, `minLengthMsg`, `maxLengthMsg`, `invalidSchemaFormatMsg`, `invalidEmailMsg`, `invalidPasswordMsg`, `invalidDateFormatMsg`, `futureDateRequiredMsg`, `invalidNumberMsg`, `positiveNumberMsg`, `invalidUrlMsg` | function | Reusable validation error message builders (pair well with custom Zod `.refine()`/`.check()` messages) |
|
|
72
|
-
| `ClerkUser` | type | Shape of `req.user` |
|
|
73
|
-
| `Location` | type | `"body" \| "params" \| "query"` |
|
|
74
|
-
| `CheckRoleOptions`, `RoleCheckMode` | type | Options accepted by `checkRole` |
|
|
75
|
-
|
|
76
|
-
Importing anything from the package root also loads the Express `Request`
|
|
77
|
-
type augmentation (`req.user?: ClerkUser`), so no extra `express.d.ts` is
|
|
78
|
-
needed in the consuming service.
|
|
79
|
-
|
|
80
|
-
## `checkAuth`
|
|
81
|
-
|
|
82
|
-
Verifies the request is authenticated via Clerk and attaches a typed
|
|
83
|
-
`req.user` (a `ClerkUser`) for downstream handlers.
|
|
109
|
+
const createCourseSchema = z.object({
|
|
110
|
+
title: z.string().trim().min(3),
|
|
111
|
+
price: z.number().positive(),
|
|
112
|
+
});
|
|
84
113
|
|
|
85
|
-
|
|
86
|
-
|
|
114
|
+
app.post(
|
|
115
|
+
"/courses",
|
|
116
|
+
checkAuth,
|
|
117
|
+
checkRole(["INSTRUCTOR", "ADMIN"]),
|
|
118
|
+
validationMiddleware({ body: createCourseSchema }),
|
|
119
|
+
async (req, res, next) => {
|
|
120
|
+
try {
|
|
121
|
+
const course = await prisma.course.create({ data: req.body });
|
|
122
|
+
res.status(201).json(
|
|
123
|
+
getJsonResponse({
|
|
124
|
+
success: true,
|
|
125
|
+
message: getSuccessMsg("Course", "has", "created"),
|
|
126
|
+
data: course,
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
handleCaughtError(error, next);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
);
|
|
87
134
|
|
|
88
|
-
app.get("/
|
|
89
|
-
|
|
135
|
+
app.get("/courses/:id", async (req, res, next) => {
|
|
136
|
+
try {
|
|
137
|
+
const course = await prisma.course.findUnique({
|
|
138
|
+
where: { id: req.params.id },
|
|
139
|
+
});
|
|
140
|
+
if (!course) throw new AppError("Course not found", 404);
|
|
141
|
+
res.json(getJsonResponse({ success: true, data: course }));
|
|
142
|
+
} catch (error) {
|
|
143
|
+
handleCaughtError(error, next);
|
|
144
|
+
}
|
|
90
145
|
});
|
|
91
|
-
```
|
|
92
146
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
```json
|
|
96
|
-
{ "error": "Unauthenticated. Login first please." }
|
|
147
|
+
app.use(globalErrorHandler); // register last
|
|
97
148
|
```
|
|
98
149
|
|
|
99
|
-
|
|
100
|
-
error is passed to `next(error)` for `globalErrorHandler` to handle.
|
|
150
|
+
## API reference
|
|
101
151
|
|
|
102
|
-
|
|
152
|
+
### Middleware
|
|
103
153
|
|
|
104
|
-
|
|
105
|
-
(`sessionClaims.userMetadata.roles`, an array). Takes an array of allowed
|
|
106
|
-
roles and an optional `{ mode }`.
|
|
154
|
+
#### `checkAuth(req, res, next)`
|
|
107
155
|
|
|
108
|
-
|
|
109
|
-
|
|
156
|
+
Requires a Clerk-authenticated request and attaches a typed `req.user`.
|
|
157
|
+
`clerkMiddleware()` from `@clerk/express` must run first, because `checkAuth`
|
|
158
|
+
reads the auth state with `getAuth(req)`.
|
|
110
159
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
);
|
|
160
|
+
```ts
|
|
161
|
+
app.get("/me", checkAuth, (req, res) => {
|
|
162
|
+
res.json(req.user);
|
|
163
|
+
});
|
|
116
164
|
```
|
|
117
165
|
|
|
118
|
-
|
|
166
|
+
| Situation | Result |
|
|
167
|
+
| --- | --- |
|
|
168
|
+
| Authenticated with a `userId` | Sets `req.user`, calls `next()` |
|
|
169
|
+
| Not authenticated, or no `userId` | Responds `401` `{ "error": "Unauthenticated. Login first please." }`; `next()` is not called |
|
|
170
|
+
| `getAuth` throws (e.g. `clerkMiddleware()` missing) | Calls `next(error)` → `500` from `globalErrorHandler` |
|
|
119
171
|
|
|
120
|
-
`
|
|
172
|
+
`req.user` is built from the session token: `clerkId` comes from `userId`.
|
|
173
|
+
`email`, `firstName`, `lastName`, `imageURL` and `userMetadata` are read from
|
|
174
|
+
custom session claims of the same names. Add them to your Clerk session token
|
|
175
|
+
for them to be populated; missing claims become `undefined`.
|
|
121
176
|
|
|
122
|
-
|
|
123
|
-
either side are ignored.
|
|
177
|
+
#### `checkRole(allowedRoles, options?)`
|
|
124
178
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
```
|
|
179
|
+
Returns a middleware that authorizes the caller against the roles array at
|
|
180
|
+
`sessionClaims.userMetadata.roles`. Like `checkAuth`, it requires
|
|
181
|
+
`clerkMiddleware()` to have run.
|
|
129
182
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
```ts
|
|
134
|
-
// Passes only if the caller has both ADMIN and INSTRUCTOR (extra roles OK)
|
|
135
|
-
checkRole(["ADMIN", "INSTRUCTOR"], { mode: "All" });
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
- **`Exact`** — the caller's role set must match `allowedRoles` exactly, with
|
|
139
|
-
no missing or extra roles.
|
|
140
|
-
|
|
141
|
-
```ts
|
|
142
|
-
// Passes only if the caller's roles are exactly {ADMIN, INSTRUCTOR}
|
|
143
|
-
checkRole(["ADMIN", "INSTRUCTOR"], { mode: "Exact" });
|
|
144
|
-
```
|
|
183
|
+
```ts
|
|
184
|
+
import { checkRole } from "@mustafahmed1997/learnify-backend";
|
|
145
185
|
|
|
146
|
-
|
|
186
|
+
app.delete("/courses/:id", checkAuth, checkRole(["ADMIN"]), deleteCourse);
|
|
187
|
+
```
|
|
147
188
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
- No `sessionClaims`, no `userMetadata.roles`, or an empty roles array
|
|
153
|
-
responds `403` with `{ "message": "User roles not found" }` and does not
|
|
154
|
-
call `next()`.
|
|
155
|
-
- A caller whose roles don't satisfy the selected mode responds `403` with
|
|
156
|
-
`{ "message": "Forbidden" }` and does not call `next()`.
|
|
157
|
-
- An authorized caller calls `next()` with no arguments.
|
|
189
|
+
| Parameter | Type | Description |
|
|
190
|
+
| --- | --- | --- |
|
|
191
|
+
| `allowedRoles` | `Role[]` | Non-empty array of unique roles |
|
|
192
|
+
| `options.mode` | `"Any" \| "All" \| "Exact"` | Matching strategy. Default `"Any"` |
|
|
158
193
|
|
|
159
|
-
|
|
194
|
+
| Mode | Authorized when |
|
|
195
|
+
| --- | --- |
|
|
196
|
+
| `Any` | The user has at least one of `allowedRoles` |
|
|
197
|
+
| `All` | The user has every role in `allowedRoles` (extra roles allowed) |
|
|
198
|
+
| `Exact` | The user's roles are exactly `allowedRoles` (no missing or extra roles) |
|
|
160
199
|
|
|
161
200
|
```ts
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
checkRole(["ADMIN"]),
|
|
166
|
-
updateRoleController,
|
|
167
|
-
);
|
|
201
|
+
checkRole(["ADMIN", "INSTRUCTOR"]); // either role
|
|
202
|
+
checkRole(["ADMIN", "INSTRUCTOR"], { mode: "All" }); // both roles, extras OK
|
|
203
|
+
checkRole(["ADMIN", "INSTRUCTOR"], { mode: "Exact" }); // exactly these two
|
|
168
204
|
```
|
|
169
205
|
|
|
170
|
-
|
|
206
|
+
| Situation | Result |
|
|
207
|
+
| --- | --- |
|
|
208
|
+
| Authorized | Calls `next()` |
|
|
209
|
+
| No session claims, no `roles`, or empty `roles` | `403` `{ "message": "User roles not found" }` |
|
|
210
|
+
| Roles don't satisfy the mode (or an unknown mode is passed) | `403` `{ "message": "Forbidden" }` |
|
|
211
|
+
| `allowedRoles` empty or contains duplicates | `next(Error)` → `500` (misconfiguration) |
|
|
212
|
+
| Clerk roles contain duplicates or values outside `validRoles` | `next(Error)` → `500` (bad upstream data) |
|
|
171
213
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
214
|
+
#### `validationMiddleware(schemas)`
|
|
215
|
+
|
|
216
|
+
Returns a middleware that validates any combination of `body`, `params` and
|
|
217
|
+
`query` against Zod schemas. Omitted (or `undefined`) locations are skipped.
|
|
175
218
|
|
|
176
219
|
```ts
|
|
177
220
|
import { z } from "zod";
|
|
178
221
|
import { validationMiddleware } from "@mustafahmed1997/learnify-backend";
|
|
179
222
|
|
|
180
|
-
const createCourseSchema = z.object({
|
|
181
|
-
title: z.string().min(3),
|
|
182
|
-
price: z.number().positive(),
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
const courseIdParamsSchema = z.object({
|
|
186
|
-
id: z.string().uuid(),
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
const paginationQuerySchema = z.object({
|
|
190
|
-
page: z.coerce.number().int().positive().default(1),
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
app.post(
|
|
194
|
-
"/courses",
|
|
195
|
-
validationMiddleware({ body: createCourseSchema }),
|
|
196
|
-
createCourseController,
|
|
197
|
-
);
|
|
198
|
-
|
|
199
|
-
app.get(
|
|
200
|
-
"/courses/:id",
|
|
201
|
-
validationMiddleware({ params: courseIdParamsSchema }),
|
|
202
|
-
getCourseController,
|
|
203
|
-
);
|
|
204
|
-
|
|
205
223
|
app.get(
|
|
206
|
-
"/courses",
|
|
207
|
-
validationMiddleware({ query: paginationQuerySchema }),
|
|
208
|
-
listCoursesController,
|
|
209
|
-
);
|
|
210
|
-
|
|
211
|
-
// All three at once:
|
|
212
|
-
app.patch(
|
|
213
|
-
"/courses/:id",
|
|
224
|
+
"/courses/:id/lessons",
|
|
214
225
|
validationMiddleware({
|
|
215
|
-
params:
|
|
216
|
-
|
|
217
|
-
query: paginationQuerySchema,
|
|
226
|
+
params: z.object({ id: z.uuid() }),
|
|
227
|
+
query: z.object({ page: z.coerce.number().int().positive().default(1) }),
|
|
218
228
|
}),
|
|
219
|
-
|
|
229
|
+
(req, res) => {
|
|
230
|
+
// req.query.page is a number here, not a string
|
|
231
|
+
},
|
|
220
232
|
);
|
|
221
233
|
```
|
|
222
234
|
|
|
223
|
-
- On success
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
-
|
|
230
|
-
(
|
|
231
|
-
|
|
232
|
-
invalid location.
|
|
233
|
-
- Register `globalErrorHandler` after your routes to turn that
|
|
234
|
-
`ValidationError` into the `400` response documented below.
|
|
235
|
+
- **On success** it calls `next()` and replaces `req.body`, `req.params` and
|
|
236
|
+
`req.query` with each schema's parsed output. Coercions, defaults,
|
|
237
|
+
transforms and stripped unknown keys are all visible to the handler.
|
|
238
|
+
- **On failure** it calls `next(new ValidationError("Validation failed", errors))`.
|
|
239
|
+
It collects every issue from every location in one pass and never writes
|
|
240
|
+
to `res` itself. Invalid locations are left untouched on `req`.
|
|
241
|
+
- Each error item is `{ location, field, message }`. `field` is the Zod issue
|
|
242
|
+
path joined with `.` (e.g. `address.city`, `tags.1`), or `""` when the
|
|
243
|
+
whole location is invalid (e.g. a missing body).
|
|
235
244
|
|
|
236
|
-
|
|
245
|
+
> TypeScript note: `req.body`, `req.params` and `req.query` keep their Express
|
|
246
|
+
> types after validation. Cast or re-parse in the handler if you need the
|
|
247
|
+
> schema's inferred type.
|
|
237
248
|
|
|
238
|
-
###
|
|
249
|
+
### Errors
|
|
239
250
|
|
|
240
|
-
|
|
251
|
+
#### `AppError`
|
|
241
252
|
|
|
242
253
|
```ts
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if (!course) {
|
|
246
|
-
throw new AppError("Course not found", 404);
|
|
247
|
-
}
|
|
254
|
+
new AppError(message: string, statusCode: number)
|
|
248
255
|
```
|
|
249
256
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
An `AppError` subclass (status code always `400`) that also carries the list
|
|
253
|
-
of field-level failures. This is what `validationMiddleware` throws, but you
|
|
254
|
-
can also construct and throw it directly for manual validation:
|
|
257
|
+
An `Error` subclass with a `statusCode`. Its prototype chain is preserved, so
|
|
258
|
+
`instanceof AppError` works for subclasses too.
|
|
255
259
|
|
|
256
260
|
```ts
|
|
257
|
-
import {
|
|
258
|
-
import type { ValidationErrorItem } from "@mustafahmed1997/learnify-backend";
|
|
261
|
+
import { AppError } from "@mustafahmed1997/learnify-backend";
|
|
259
262
|
|
|
260
|
-
|
|
261
|
-
{ location: "body", field: "role", message: "Invalid role" },
|
|
262
|
-
];
|
|
263
|
+
throw new AppError("Course not found", 404);
|
|
263
264
|
|
|
264
|
-
|
|
265
|
+
class ConflictError extends AppError {
|
|
266
|
+
constructor(message: string) {
|
|
267
|
+
super(message, 409);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
265
270
|
```
|
|
266
271
|
|
|
267
|
-
`
|
|
272
|
+
#### `ValidationError`
|
|
268
273
|
|
|
269
274
|
```ts
|
|
270
|
-
|
|
271
|
-
location: "body" | "params" | "query";
|
|
272
|
-
field: string;
|
|
273
|
-
message: string;
|
|
274
|
-
};
|
|
275
|
+
new ValidationError(message: string, errors: ValidationErrorItem[])
|
|
275
276
|
```
|
|
276
277
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
278
|
+
An `AppError` whose `statusCode` is always `400`, carrying field-level
|
|
279
|
+
details in `errors`. `validationMiddleware` produces these, and you can throw
|
|
280
|
+
one yourself:
|
|
280
281
|
|
|
281
282
|
```ts
|
|
282
|
-
import {
|
|
283
|
+
import { ValidationError } from "@mustafahmed1997/learnify-backend";
|
|
283
284
|
|
|
284
|
-
|
|
285
|
+
throw new ValidationError("Validation failed", [
|
|
286
|
+
{ location: "body", field: "email", message: "Email already in use" },
|
|
287
|
+
]);
|
|
285
288
|
```
|
|
286
289
|
|
|
287
|
-
|
|
290
|
+
#### `globalErrorHandler(err, req, res, next)`
|
|
288
291
|
|
|
289
|
-
|
|
290
|
-
|
|
292
|
+
Express error-handling middleware. Register it after all routes. See
|
|
293
|
+
[Error handling](#error-handling) for its response contract.
|
|
291
294
|
|
|
292
|
-
|
|
293
|
-
{
|
|
294
|
-
"error": "Validation failed",
|
|
295
|
-
"errors": [
|
|
296
|
-
{ "location": "body", "field": "role", "message": "Invalid role" },
|
|
297
|
-
{ "location": "params", "field": "userId", "message": "Invalid user ID" }
|
|
298
|
-
]
|
|
299
|
-
}
|
|
300
|
-
```
|
|
295
|
+
#### `handlePrismaError(error)`
|
|
301
296
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
underlying error's message or stack:
|
|
306
|
-
|
|
307
|
-
```json
|
|
308
|
-
{ "error": "Internal server error" }
|
|
309
|
-
```
|
|
297
|
+
```ts
|
|
298
|
+
handlePrismaError(error: unknown): { status: number; message: string }
|
|
299
|
+
```
|
|
310
300
|
|
|
311
|
-
|
|
312
|
-
|
|
301
|
+
Maps a Prisma error to an HTTP status and message. It never throws, and it
|
|
302
|
+
doesn't import `@prisma/client`; it inspects the error's shape:
|
|
313
303
|
|
|
314
|
-
|
|
304
|
+
- A **known request error** is any object whose `code` is a string matching
|
|
305
|
+
`P` + four digits (e.g. `PrismaClientKnownRequestError`).
|
|
306
|
+
- A **validation error** is any object whose `name` is
|
|
307
|
+
`"PrismaClientValidationError"`.
|
|
315
308
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
309
|
+
| Input | `status` | `message` |
|
|
310
|
+
| --- | --- | --- |
|
|
311
|
+
| `P2002` unique constraint | `409` | `Duplicate value for <meta.target>` (falls back to `field`; arrays are comma-joined) |
|
|
312
|
+
| `P2003` foreign key | `400` | `Invalid foreign key reference` |
|
|
313
|
+
| `P2014` relation violation | `400` | `Invalid relation reference` |
|
|
314
|
+
| `P2025` record not found | `404` | `Record not found` |
|
|
315
|
+
| `P2000` value too long | `400` | `Value too long for <meta.column_name>` (falls back to `column`) |
|
|
316
|
+
| Any other `P####` code | `500` | `Database error: <error.message>` |
|
|
317
|
+
| `PrismaClientValidationError` | `400` | `Invalid input data: <error.message>` |
|
|
318
|
+
| Anything else (incl. `null`, strings) | `500` | `Non-prisma error: <message or String(value)>` |
|
|
319
319
|
|
|
320
320
|
```ts
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
try {
|
|
324
|
-
await prisma.course.create({ data });
|
|
325
|
-
} catch (err) {
|
|
326
|
-
const { status, message } = handlePrismaError(err);
|
|
327
|
-
throw new AppError(message, status);
|
|
328
|
-
}
|
|
321
|
+
const { status, message } = handlePrismaError(err);
|
|
329
322
|
```
|
|
330
323
|
|
|
331
|
-
|
|
332
|
-
(record not found), `P2014` (invalid relation), `P2000` (value too long),
|
|
333
|
-
`PrismaClientValidationError`, and falls back to a generic `500` message for
|
|
334
|
-
anything else — including `null`/`undefined`/non-object inputs.
|
|
335
|
-
|
|
336
|
-
## `validRoles` and `Role`
|
|
324
|
+
#### `handleCaughtError(error, next)`
|
|
337
325
|
|
|
338
326
|
```ts
|
|
339
|
-
|
|
340
|
-
import type { Role } from "@mustafahmed1997/learnify-backend";
|
|
341
|
-
|
|
342
|
-
console.log(validRoles); // ["STUDENT", "INSTRUCTOR", "ADMIN"]
|
|
343
|
-
|
|
344
|
-
function isValidRole(value: string): value is Role {
|
|
345
|
-
return (validRoles as readonly string[]).includes(value);
|
|
346
|
-
}
|
|
327
|
+
handleCaughtError(error: unknown, next: NextFunction): void
|
|
347
328
|
```
|
|
348
329
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
Low-level building blocks used internally by `checkRole`, exported for
|
|
352
|
-
services that need custom role-array validation outside of the middleware:
|
|
330
|
+
The recommended way to forward an error from a `catch` block. It decides what
|
|
331
|
+
reaches `globalErrorHandler`:
|
|
353
332
|
|
|
354
|
-
|
|
355
|
-
|
|
333
|
+
| Caught error | Passed to `next()` | Client sees |
|
|
334
|
+
| --- | --- | --- |
|
|
335
|
+
| `AppError` / `ValidationError` | The same error, unchanged | Its status and message |
|
|
336
|
+
| Prisma error mapped to a 4xx (`P2000`, `P2002`, `P2003`, `P2014`, `P2025`, validation) | `new AppError(message, status)` | e.g. `404 Record not found` |
|
|
337
|
+
| Anything mapped to `500` (other Prisma codes, non-Prisma errors, non-`Error` values) | The original value | Generic `500 Internal server error` |
|
|
356
338
|
|
|
357
|
-
|
|
358
|
-
|
|
339
|
+
Server errors are forwarded untouched on purpose. Their real messages are
|
|
340
|
+
logged by `globalErrorHandler` and never sent to clients.
|
|
359
341
|
|
|
360
|
-
|
|
361
|
-
checkValidRoles(["ADMIN", "SUPERADMIN"]); // false — not in validRoles
|
|
362
|
-
```
|
|
342
|
+
### Response and message helpers
|
|
363
343
|
|
|
364
|
-
|
|
344
|
+
#### `getJsonResponse(options)`
|
|
365
345
|
|
|
366
|
-
|
|
346
|
+
```ts
|
|
347
|
+
getJsonResponse<TData, TAdditionalInfo>({
|
|
348
|
+
success: boolean; // required, always included
|
|
349
|
+
message?: string;
|
|
350
|
+
data?: TData;
|
|
351
|
+
additionalInfo?: TAdditionalInfo;
|
|
352
|
+
error?: string;
|
|
353
|
+
})
|
|
354
|
+
```
|
|
367
355
|
|
|
368
|
-
Builds a response body
|
|
369
|
-
`undefined
|
|
356
|
+
Builds a response body that contains `success` plus only the optional fields
|
|
357
|
+
that are not `undefined`. Falsy values such as `0`, `false`, `""` and `null`
|
|
358
|
+
are kept.
|
|
370
359
|
|
|
371
360
|
```ts
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
res.status(200).json(
|
|
361
|
+
res.json(
|
|
375
362
|
getJsonResponse({
|
|
376
|
-
|
|
377
|
-
data:
|
|
363
|
+
success: true,
|
|
364
|
+
data: courses,
|
|
365
|
+
additionalInfo: { page: 1, total: 42 },
|
|
378
366
|
}),
|
|
379
367
|
);
|
|
380
|
-
// { "
|
|
368
|
+
// { "success": true, "data": [...], "additionalInfo": { "page": 1, "total": 42 } }
|
|
381
369
|
```
|
|
382
370
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
Small helpers for consistent, grammatically-correct singular/plural messages:
|
|
371
|
+
#### `getSuccessMsg(resource, verb, action)` / `getErrorMsg(resource, verb, message)`
|
|
386
372
|
|
|
387
373
|
```ts
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
getErrorMsg("Course", "was", "not found");
|
|
374
|
+
getSuccessMsg("Course", "has", "created"); // "Course has been created successfully"
|
|
375
|
+
getSuccessMsg("Courses", "have", "deleted"); // "Courses have been deleted successfully"
|
|
376
|
+
getErrorMsg("Course", "was", "not found"); // "Course was not found"
|
|
391
377
|
getErrorMsg("Courses", "were", "not found"); // "Courses were not found"
|
|
392
|
-
|
|
393
|
-
getSuccessMsg("Course", "has", "created"); // "Course has been created successfully"
|
|
394
|
-
getSuccessMsg("Courses", "have", "created"); // "Courses have been created successfully"
|
|
395
378
|
```
|
|
396
379
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
380
|
+
`verb` is typed as `"has" | "have"` and `"was" | "were"` respectively. The
|
|
381
|
+
resource is not pluralized automatically.
|
|
382
|
+
|
|
383
|
+
#### Validation message builders
|
|
384
|
+
|
|
385
|
+
| Function | Returns |
|
|
386
|
+
| --- | --- |
|
|
387
|
+
| `requiredFieldMsg(field)` | `"<field> is required"` |
|
|
388
|
+
| `minLengthMsg(min)` | `"Minimum length is <min>"` |
|
|
389
|
+
| `maxLengthMsg(max)` | `"Maximum length is <max>"` |
|
|
390
|
+
| `invalidSchemaFormatMsg(field, format)` | `"<field> should match format: <format>"` |
|
|
391
|
+
| `invalidEmailMsg()` | `"Invalid email format"` |
|
|
392
|
+
| `invalidPasswordMsg()` | `"Password should be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one special character"` |
|
|
393
|
+
| `invalidNumberMsg(field)` | `"Invalid number format for field: <field>"` |
|
|
394
|
+
| `positiveNumberMsg(field)` | `"<field> should be positive"` |
|
|
395
|
+
| `invalidUrlMsg()` | `"Invalid URL"` |
|
|
396
|
+
| `invalidDateFormatMsg()` | `"Invalid date format"` |
|
|
397
|
+
| `futureDateRequiredMsg()` | `"Date must be in the future"` |
|
|
401
398
|
|
|
402
399
|
```ts
|
|
403
400
|
import { z } from "zod";
|
|
404
401
|
import {
|
|
405
|
-
requiredFieldMsg,
|
|
406
|
-
minLengthMsg,
|
|
407
402
|
invalidEmailMsg,
|
|
403
|
+
maxLengthMsg,
|
|
404
|
+
minLengthMsg,
|
|
408
405
|
} from "@mustafahmed1997/learnify-backend";
|
|
409
406
|
|
|
410
|
-
const
|
|
411
|
-
email: z.
|
|
412
|
-
|
|
407
|
+
const signUpSchema = z.object({
|
|
408
|
+
email: z.email(invalidEmailMsg()),
|
|
409
|
+
name: z.string().min(2, minLengthMsg(2)).max(50, maxLengthMsg(50)),
|
|
413
410
|
});
|
|
414
411
|
```
|
|
415
412
|
|
|
416
|
-
|
|
417
|
-
`maxLengthMsg(max)`, `invalidSchemaFormatMsg(field, format)`,
|
|
418
|
-
`invalidEmailMsg()`, `invalidPasswordMsg()`, `invalidDateFormatMsg()`,
|
|
419
|
-
`futureDateRequiredMsg()`, `invalidNumberMsg(field)`,
|
|
420
|
-
`positiveNumberMsg(field)`, `invalidUrlMsg()`.
|
|
421
|
-
|
|
422
|
-
## `ClerkUser` and `req.user` typing
|
|
423
|
-
|
|
424
|
-
Importing anything from this package's root automatically loads the Express
|
|
425
|
-
`Request` augmentation, so `req.user` is typed as `ClerkUser | undefined`
|
|
426
|
-
wherever `express`'s `Request` type is used — no extra `express.d.ts` needed
|
|
427
|
-
in the consuming service.
|
|
413
|
+
### Role utilities and constants
|
|
428
414
|
|
|
429
415
|
```ts
|
|
430
|
-
import
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
416
|
+
import {
|
|
417
|
+
checkUnique,
|
|
418
|
+
checkValidRoles,
|
|
419
|
+
validRoles,
|
|
420
|
+
type Role,
|
|
421
|
+
} from "@mustafahmed1997/learnify-backend";
|
|
436
422
|
|
|
437
|
-
|
|
438
|
-
|
|
423
|
+
validRoles; // readonly ["STUDENT", "INSTRUCTOR", "ADMIN"]
|
|
424
|
+
checkUnique(["ADMIN", "ADMIN"]); // false (case-sensitive; [] → true)
|
|
425
|
+
checkValidRoles(["ADMIN", "STUDENT"]); // true
|
|
426
|
+
checkValidRoles(["ADMIN", "admin"]); // false (case-sensitive; [] → true)
|
|
439
427
|
```
|
|
440
428
|
|
|
441
|
-
`
|
|
429
|
+
`checkRole` uses these internally. They're exported for custom role checks,
|
|
430
|
+
for example when validating role updates.
|
|
442
431
|
|
|
443
|
-
|
|
444
|
-
type ClerkUser = {
|
|
445
|
-
clerkId: string;
|
|
446
|
-
userMetadata: {
|
|
447
|
-
role: "STUDENT" | "INSTRUCTOR" | "ADMIN";
|
|
448
|
-
dbUserId: string;
|
|
449
|
-
};
|
|
450
|
-
email?: string;
|
|
451
|
-
firstName?: string;
|
|
452
|
-
lastName?: string;
|
|
453
|
-
imageURL?: string;
|
|
454
|
-
};
|
|
455
|
-
```
|
|
432
|
+
### Types
|
|
456
433
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
434
|
+
| Type | Definition |
|
|
435
|
+
| --- | --- |
|
|
436
|
+
| `Role` | `"STUDENT" \| "INSTRUCTOR" \| "ADMIN"` |
|
|
437
|
+
| `Location` | `"body" \| "params" \| "query"` |
|
|
438
|
+
| `ValidationErrorItem` | `{ location: Location; field: string; message: string }` |
|
|
439
|
+
| `ClerkUser` | `{ clerkId: string; userMetadata: { role: Role; dbUserId: string }; email?; firstName?; lastName?; imageURL? }` |
|
|
440
|
+
| `JsonResponseOptions<TData, TAdditionalInfo>` | Options accepted by `getJsonResponse` |
|
|
441
|
+
| `RoleCheckMode` | `"All" \| "Exact" \| "Any"` |
|
|
442
|
+
| `CheckRoleOptions` | `{ mode: RoleCheckMode }` |
|
|
463
443
|
|
|
464
|
-
|
|
444
|
+
**`req.user` typing.** Importing anything from the package root loads a global
|
|
445
|
+
augmentation that types `req.user` as `ClerkUser | undefined` on Express's
|
|
446
|
+
`Request`. No local `express.d.ts` is needed.
|
|
465
447
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
448
|
+
> `ClerkUser.userMetadata.role` is a single role taken as-is from the session
|
|
449
|
+
> claims, while `checkRole` authorizes against the `userMetadata.roles` array.
|
|
450
|
+
> Make authorization decisions with `checkRole` rather than `req.user`.
|
|
469
451
|
|
|
470
|
-
##
|
|
452
|
+
## Error handling
|
|
471
453
|
|
|
472
|
-
|
|
454
|
+
There are two paths an error can take:
|
|
473
455
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
import { clerkMiddleware } from "@clerk/express";
|
|
477
|
-
import {
|
|
478
|
-
checkAuth,
|
|
479
|
-
checkRole,
|
|
480
|
-
validationMiddleware,
|
|
481
|
-
globalErrorHandler,
|
|
482
|
-
} from "@mustafahmed1997/learnify-backend";
|
|
456
|
+
**1. Responses sent directly by middleware** (`next()` is not called, and
|
|
457
|
+
`globalErrorHandler` doesn't see them):
|
|
483
458
|
|
|
484
|
-
|
|
459
|
+
| Source | Status | Body |
|
|
460
|
+
| --- | --- | --- |
|
|
461
|
+
| `checkAuth` | `401` | `{ "error": "Unauthenticated. Login first please." }` |
|
|
462
|
+
| `checkRole` | `403` | `{ "message": "User roles not found" }` or `{ "message": "Forbidden" }` |
|
|
485
463
|
|
|
486
|
-
|
|
487
|
-
app.use(clerkMiddleware()); // must run before checkAuth/checkRole
|
|
464
|
+
These bodies don't include a `success` field.
|
|
488
465
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
466
|
+
**2. Errors passed to `next()`** and rendered by `globalErrorHandler`. This
|
|
467
|
+
covers `ValidationError` from `validationMiddleware`, errors forwarded by
|
|
468
|
+
`handleCaughtError`, configuration errors from `checkRole`, errors from
|
|
469
|
+
`checkAuth`, and anything thrown or rejected in your handlers (Express 5
|
|
470
|
+
forwards rejected promises automatically).
|
|
471
|
+
|
|
472
|
+
`globalErrorHandler` logs every error with `console.error`, then responds:
|
|
473
|
+
|
|
474
|
+
| Error | Status | Body |
|
|
475
|
+
| --- | --- | --- |
|
|
476
|
+
| `ValidationError` | `400` | `{ "success": false, "error": "Validation failed", "errors": [ { "location": "body", "field": "title", "message": "..." } ] }` |
|
|
477
|
+
| Any other `AppError` | `err.statusCode` | `{ "success": false, "error": err.message }` |
|
|
478
|
+
| Anything else | `500` | `{ "success": false, "error": "Internal server error" }` |
|
|
496
479
|
|
|
497
|
-
|
|
480
|
+
The `500` body is fixed: messages and stack traces of unexpected errors are
|
|
481
|
+
never sent to clients. Only `AppError` instances get their own status, so a raw
|
|
482
|
+
Prisma error that reaches the handler becomes a `500`. Route `catch` blocks
|
|
483
|
+
through `handleCaughtError` to get `404`/`409`/`400` for Prisma errors:
|
|
498
484
|
|
|
499
|
-
|
|
485
|
+
```ts
|
|
486
|
+
try {
|
|
487
|
+
await prisma.enrollment.create({ data });
|
|
488
|
+
} catch (error) {
|
|
489
|
+
handleCaughtError(error, next); // P2002 → 409 "Duplicate value for ..."
|
|
490
|
+
}
|
|
500
491
|
```
|
|
501
492
|
|
|
502
|
-
|
|
503
|
-
(or pass them to `next(...)`) and rely on `globalErrorHandler` to produce a
|
|
504
|
-
consistent response shape across every microservice.
|
|
493
|
+
## Testing
|
|
505
494
|
|
|
506
|
-
|
|
495
|
+
Tests use [Vitest](https://vitest.dev) 3 in a Node environment and live in
|
|
496
|
+
`tests/**/*.test.ts` (see `vitest.config.ts`). Clerk is mocked with
|
|
497
|
+
`vi.mock("@clerk/express")`, and Express `req`/`res`/`next` are lightweight
|
|
498
|
+
stubs, so no network, Clerk instance or database is needed.
|
|
507
499
|
|
|
508
500
|
```bash
|
|
509
|
-
npm test
|
|
510
|
-
npm run test:watch
|
|
501
|
+
npm test # run the whole suite once
|
|
502
|
+
npm run test:watch # watch mode
|
|
503
|
+
npx vitest run tests/checkRole.test.ts # a single file
|
|
504
|
+
npx vitest run -t "handleCaughtError" # tests matching a name
|
|
511
505
|
```
|
|
512
506
|
|
|
513
|
-
|
|
507
|
+
Coverage isn't configured in this repository. To measure it locally, install
|
|
508
|
+
the V8 provider that matches the Vitest version, then run:
|
|
514
509
|
|
|
515
510
|
```bash
|
|
516
|
-
npm
|
|
511
|
+
npm install --no-save @vitest/coverage-v8@3
|
|
512
|
+
npx vitest run --coverage --coverage.include="src/**"
|
|
517
513
|
```
|
|
518
514
|
|
|
519
|
-
##
|
|
515
|
+
## Build and development
|
|
520
516
|
|
|
521
|
-
|
|
522
|
-
|
|
517
|
+
| Script | Command | Purpose |
|
|
518
|
+
| --- | --- | --- |
|
|
519
|
+
| `npm run build` | `tsc -p tsconfig.build.json` | Compile `src/` to `dist/` (JS, `.d.ts`, source maps) |
|
|
520
|
+
| `npm run typecheck` | `tsc -p tsconfig.json` | Type-check `src/` and `tests/` without emitting |
|
|
521
|
+
| `npm test` | `vitest run` | Run the test suite once |
|
|
522
|
+
| `npm run test:watch` | `vitest` | Run tests in watch mode |
|
|
523
|
+
|
|
524
|
+
There are no lint or format scripts.
|
|
525
|
+
|
|
526
|
+
## Project structure
|
|
527
|
+
|
|
528
|
+
```text
|
|
529
|
+
src/
|
|
530
|
+
index.ts # public entry point: every export lives here
|
|
531
|
+
constants/validRoles.ts # validRoles, Role
|
|
532
|
+
errors/ # AppError, ValidationError, globalErrorHandler, handlePrismaError
|
|
533
|
+
middlewares/ # checkAuth, checkRole, validationMiddleware
|
|
534
|
+
types/ # ClerkUser, Location, Express Request augmentation
|
|
535
|
+
utils/ # response/message helpers, role checks, handleCaughtError
|
|
536
|
+
tests/ # one *.test.ts per module, plus index.test.ts for the export list
|
|
537
|
+
tsconfig.json # strict, node16 modules; includes src and tests (typecheck)
|
|
538
|
+
tsconfig.build.json # extends tsconfig.json; emits src → dist
|
|
539
|
+
vitest.config.ts # Node environment, tests/**/*.test.ts
|
|
523
540
|
```
|
|
524
541
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
`
|
|
542
|
+
## Contributing
|
|
543
|
+
|
|
544
|
+
1. **Add the implementation** under the matching `src/` folder. Use explicit
|
|
545
|
+
`.js` extensions in relative imports (required by `node16` module
|
|
546
|
+
resolution).
|
|
547
|
+
2. **Export it** from `src/index.ts`. Use `export type` for type-only exports.
|
|
548
|
+
3. **Update the export list** in `tests/index.test.ts`. That test fails if the
|
|
549
|
+
public surface changes unexpectedly.
|
|
550
|
+
4. **Write tests** in `tests/<name>.test.ts`. Cover success, failure and edge
|
|
551
|
+
cases. Mock `@clerk/express` and stub `req`/`res`/`next` as the existing
|
|
552
|
+
tests do.
|
|
553
|
+
5. **Verify**: `npm run typecheck && npm test && npm run build`.
|
|
554
|
+
6. **Document it** in this README: add it to the Features table and the API
|
|
555
|
+
reference.
|
|
556
|
+
|
|
557
|
+
## Publishing
|
|
558
|
+
|
|
559
|
+
- The package entry is `dist/index.js` with types at `dist/index.d.ts`,
|
|
560
|
+
exposed through a single `"."` export (ESM `import` condition only).
|
|
561
|
+
- Only `dist/` is published (`"files": ["dist"]`). `dist/` is git-ignored.
|
|
562
|
+
- It's published publicly to npm (`publishConfig.access: "public"`).
|
|
563
|
+
- There is no `prepublishOnly` hook, so build before publishing:
|
|
564
|
+
|
|
565
|
+
```bash
|
|
566
|
+
npm run typecheck && npm test && npm run build
|
|
567
|
+
npm version <patch|minor|major>
|
|
568
|
+
npm publish
|
|
569
|
+
```
|
|
528
570
|
|
|
529
|
-
##
|
|
571
|
+
## License
|
|
530
572
|
|
|
531
|
-
|
|
532
|
-
error shapes — it never imports `@prisma/client`).
|
|
533
|
-
- Does not call the Auth Service (or any other service) over HTTP.
|
|
534
|
-
- Does not manage Clerk session creation — that's handled by `clerkMiddleware()`.
|
|
573
|
+
ISC
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { AppError } from "./AppError.js";
|
|
2
2
|
import { ValidationError } from "./validationError.js";
|
|
3
3
|
export function globalErrorHandler(err, req, res, next) {
|
|
4
|
-
console.error(err);
|
|
4
|
+
console.error("Inside global error handler : ", err);
|
|
5
|
+
console.log("End of global error logger !!");
|
|
5
6
|
if (err instanceof ValidationError) {
|
|
6
7
|
return res.status(err.statusCode).json({
|
|
7
8
|
success: false,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"globalErrorHandler.js","sourceRoot":"","sources":["../../src/errors/globalErrorHandler.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AACzC,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAEvD,MAAM,UAAU,kBAAkB,CAChC,GAAY,EACZ,GAAY,EACZ,GAAa,EACb,IAAkB;IAElB,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"globalErrorHandler.js","sourceRoot":"","sources":["../../src/errors/globalErrorHandler.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AACzC,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAEvD,MAAM,UAAU,kBAAkB,CAChC,GAAY,EACZ,GAAY,EACZ,GAAa,EACb,IAAkB;IAElB,OAAO,CAAC,KAAK,CAAC,gCAAgC,EAAE,GAAG,CAAC,CAAC;IACrD,OAAO,CAAC,GAAG,CAAC,+BAA+B,CAAC,CAAC;IAE7C,IAAI,GAAG,YAAY,eAAe,EAAE,CAAC;QACnC,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC;YACrC,OAAO,EAAE,KAAK;YACd,KAAK,EAAE,GAAG,CAAC,OAAO;YAClB,MAAM,EAAE,GAAG,CAAC,MAAM;SACnB,CAAC,CAAC;IACL,CAAC;IAED,IAAI,GAAG,YAAY,QAAQ,EAAE,CAAC;QAC5B,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC;YACrC,OAAO,EAAE,KAAK;YACd,KAAK,EAAE,GAAG,CAAC,OAAO;SACnB,CAAC,CAAC;IACL,CAAC;IAED,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;QAC1B,OAAO,EAAE,KAAK;QACd,KAAK,EAAE,uBAAuB;KAC/B,CAAC,CAAC;AACL,CAAC;AAED;;;;;;;;;;;;;;;;;;;;EAoBE"}
|
|
@@ -22,7 +22,14 @@ export function validationMiddleware(validaitonSchema) {
|
|
|
22
22
|
req.params = result.data;
|
|
23
23
|
break;
|
|
24
24
|
case "query":
|
|
25
|
-
req.query
|
|
25
|
+
// Express 5 exposes req.query as a getter-only property, so a
|
|
26
|
+
// plain assignment throws; shadow it with an own data property.
|
|
27
|
+
Object.defineProperty(req, "query", {
|
|
28
|
+
value: result.data,
|
|
29
|
+
writable: true,
|
|
30
|
+
enumerable: true,
|
|
31
|
+
configurable: true,
|
|
32
|
+
});
|
|
26
33
|
break;
|
|
27
34
|
}
|
|
28
35
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"validationMiddleware.js","sourceRoot":"","sources":["../../src/middlewares/validationMiddleware.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,eAAe,GAEhB,MAAM,8BAA8B,CAAC;AAQtC,MAAM,UAAU,oBAAoB,CAAC,gBAAsC;IACzE,OAAO,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB,EAAE,EAAE;QACzD,MAAM,gBAAgB,GAA0B,EAAE,CAAC;QAEnD,KAAK,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,gBAAgB,CAAC,EAAE,CAAC;YAClE,IAAI,CAAC,MAAM;gBAAE,SAAS;YAEtB,MAAM,MAAM,GAAG,MAAM,CAAC,SAAS,CAAC,GAAG,CAAC,QAAyB,CAAC,CAAC,CAAC;YAEhE,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;gBACpB,gBAAgB,CAAC,IAAI,CACnB,GAAG,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;oBACrC,QAAQ,EAAE,QAAoB;oBAC9B,KAAK,EAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC;oBAC3B,OAAO,EAAE,KAAK,CAAC,OAAO;iBACvB,CAAC,CAAC,CACJ,CAAC;YACJ,CAAC;iBAAM,CAAC;gBACN,QAAQ,QAAoB,EAAE,CAAC;oBAC7B,KAAK,MAAM;wBACT,GAAG,CAAC,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC;wBACvB,MAAM;oBACR,KAAK,QAAQ;wBACX,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC,IAAyB,CAAC;wBAC9C,MAAM;oBACR,KAAK,OAAO;wBACV,
|
|
1
|
+
{"version":3,"file":"validationMiddleware.js","sourceRoot":"","sources":["../../src/middlewares/validationMiddleware.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,eAAe,GAEhB,MAAM,8BAA8B,CAAC;AAQtC,MAAM,UAAU,oBAAoB,CAAC,gBAAsC;IACzE,OAAO,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB,EAAE,EAAE;QACzD,MAAM,gBAAgB,GAA0B,EAAE,CAAC;QAEnD,KAAK,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,gBAAgB,CAAC,EAAE,CAAC;YAClE,IAAI,CAAC,MAAM;gBAAE,SAAS;YAEtB,MAAM,MAAM,GAAG,MAAM,CAAC,SAAS,CAAC,GAAG,CAAC,QAAyB,CAAC,CAAC,CAAC;YAEhE,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;gBACpB,gBAAgB,CAAC,IAAI,CACnB,GAAG,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;oBACrC,QAAQ,EAAE,QAAoB;oBAC9B,KAAK,EAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC;oBAC3B,OAAO,EAAE,KAAK,CAAC,OAAO;iBACvB,CAAC,CAAC,CACJ,CAAC;YACJ,CAAC;iBAAM,CAAC;gBACN,QAAQ,QAAoB,EAAE,CAAC;oBAC7B,KAAK,MAAM;wBACT,GAAG,CAAC,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC;wBACvB,MAAM;oBACR,KAAK,QAAQ;wBACX,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC,IAAyB,CAAC;wBAC9C,MAAM;oBACR,KAAK,OAAO;wBACV,8DAA8D;wBAC9D,gEAAgE;wBAChE,MAAM,CAAC,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE;4BAClC,KAAK,EAAE,MAAM,CAAC,IAAI;4BAClB,QAAQ,EAAE,IAAI;4BACd,UAAU,EAAE,IAAI;4BAChB,YAAY,EAAE,IAAI;yBACnB,CAAC,CAAC;wBACH,MAAM;gBACV,CAAC;YACH,CAAC;QACH,CAAC;QAED,IAAI,gBAAgB,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YAChC,OAAO,IAAI,CAAC,IAAI,eAAe,CAAC,mBAAmB,EAAE,gBAAgB,CAAC,CAAC,CAAC;QAC1E,CAAC;aAAM,CAAC;YACN,OAAO,IAAI,EAAE,CAAC;QAChB,CAAC;IACH,CAAC,CAAC;AACJ,CAAC"}
|
|
@@ -4,7 +4,13 @@ export function handleCaughtError(error, next) {
|
|
|
4
4
|
if (error instanceof AppError) {
|
|
5
5
|
return next(error);
|
|
6
6
|
}
|
|
7
|
-
const
|
|
8
|
-
|
|
7
|
+
const { status, message } = handlePrismaError(error);
|
|
8
|
+
// Client errors (e.g. P2002, P2025) become an AppError so globalErrorHandler
|
|
9
|
+
// responds with the mapped status. Server errors are forwarded untouched so
|
|
10
|
+
// their internal messages are logged but never sent to the client.
|
|
11
|
+
if (status < 500) {
|
|
12
|
+
return next(new AppError(message, status));
|
|
13
|
+
}
|
|
14
|
+
return next(error);
|
|
9
15
|
}
|
|
10
16
|
//# sourceMappingURL=handleCaughtError.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"handleCaughtError.js","sourceRoot":"","sources":["../../src/utils/handleCaughtError.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AACjD,OAAO,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AAEnE,MAAM,UAAU,iBAAiB,CAAC,KAAc,EAAE,IAAkB;IAClE,IAAI,KAAK,YAAY,QAAQ,EAAE,CAAC;QAC9B,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC;IACrB,CAAC;
|
|
1
|
+
{"version":3,"file":"handleCaughtError.js","sourceRoot":"","sources":["../../src/utils/handleCaughtError.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AACjD,OAAO,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AAEnE,MAAM,UAAU,iBAAiB,CAAC,KAAc,EAAE,IAAkB;IAClE,IAAI,KAAK,YAAY,QAAQ,EAAE,CAAC;QAC9B,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC;IACrB,CAAC;IAED,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,iBAAiB,CAAC,KAAK,CAAC,CAAC;IAErD,6EAA6E;IAC7E,4EAA4E;IAC5E,mEAAmE;IACnE,IAAI,MAAM,GAAG,GAAG,EAAE,CAAC;QACjB,OAAO,IAAI,CAAC,IAAI,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC;IAC7C,CAAC;IAED,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC;AACrB,CAAC"}
|
package/package.json
CHANGED