omgkit 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/plugin/skills/SKILL_STANDARDS.md +743 -0
- package/plugin/skills/databases/mongodb/SKILL.md +797 -28
- package/plugin/skills/databases/postgresql/SKILL.md +494 -18
- package/plugin/skills/databases/prisma/SKILL.md +776 -30
- package/plugin/skills/databases/redis/SKILL.md +885 -25
- package/plugin/skills/devops/aws/SKILL.md +686 -28
- package/plugin/skills/devops/docker/SKILL.md +466 -18
- package/plugin/skills/devops/github-actions/SKILL.md +684 -29
- package/plugin/skills/devops/kubernetes/SKILL.md +621 -24
- package/plugin/skills/frameworks/django/SKILL.md +920 -20
- package/plugin/skills/frameworks/express/SKILL.md +1361 -35
- package/plugin/skills/frameworks/fastapi/SKILL.md +1260 -33
- package/plugin/skills/frameworks/laravel/SKILL.md +1244 -31
- package/plugin/skills/frameworks/nestjs/SKILL.md +1005 -26
- package/plugin/skills/frameworks/nextjs/SKILL.md +407 -44
- package/plugin/skills/frameworks/rails/SKILL.md +594 -28
- package/plugin/skills/frameworks/react/SKILL.md +1006 -32
- package/plugin/skills/frameworks/spring/SKILL.md +528 -35
- package/plugin/skills/frameworks/vue/SKILL.md +1296 -27
- package/plugin/skills/frontend/accessibility/SKILL.md +1108 -34
- package/plugin/skills/frontend/frontend-design/SKILL.md +1304 -26
- package/plugin/skills/frontend/responsive/SKILL.md +847 -21
- package/plugin/skills/frontend/shadcn-ui/SKILL.md +976 -38
- package/plugin/skills/frontend/tailwindcss/SKILL.md +831 -35
- package/plugin/skills/frontend/threejs/SKILL.md +1298 -29
- package/plugin/skills/languages/javascript/SKILL.md +935 -31
- package/plugin/skills/languages/python/SKILL.md +489 -25
- package/plugin/skills/languages/typescript/SKILL.md +379 -30
- package/plugin/skills/methodology/brainstorming/SKILL.md +597 -23
- package/plugin/skills/methodology/defense-in-depth/SKILL.md +832 -34
- package/plugin/skills/methodology/dispatching-parallel-agents/SKILL.md +665 -31
- package/plugin/skills/methodology/executing-plans/SKILL.md +556 -24
- package/plugin/skills/methodology/finishing-development-branch/SKILL.md +595 -25
- package/plugin/skills/methodology/problem-solving/SKILL.md +429 -61
- package/plugin/skills/methodology/receiving-code-review/SKILL.md +536 -24
- package/plugin/skills/methodology/requesting-code-review/SKILL.md +632 -21
- package/plugin/skills/methodology/root-cause-tracing/SKILL.md +641 -30
- package/plugin/skills/methodology/sequential-thinking/SKILL.md +262 -3
- package/plugin/skills/methodology/systematic-debugging/SKILL.md +571 -32
- package/plugin/skills/methodology/test-driven-development/SKILL.md +779 -24
- package/plugin/skills/methodology/testing-anti-patterns/SKILL.md +691 -29
- package/plugin/skills/methodology/token-optimization/SKILL.md +598 -29
- package/plugin/skills/methodology/verification-before-completion/SKILL.md +543 -22
- package/plugin/skills/methodology/writing-plans/SKILL.md +590 -18
- package/plugin/skills/omega/omega-architecture/SKILL.md +838 -39
- package/plugin/skills/omega/omega-coding/SKILL.md +636 -39
- package/plugin/skills/omega/omega-sprint/SKILL.md +855 -48
- package/plugin/skills/omega/omega-testing/SKILL.md +940 -41
- package/plugin/skills/omega/omega-thinking/SKILL.md +703 -50
- package/plugin/skills/security/better-auth/SKILL.md +1065 -28
- package/plugin/skills/security/oauth/SKILL.md +968 -31
- package/plugin/skills/security/owasp/SKILL.md +894 -33
- package/plugin/skills/testing/playwright/SKILL.md +764 -38
- package/plugin/skills/testing/pytest/SKILL.md +873 -36
- package/plugin/skills/testing/vitest/SKILL.md +980 -35
|
@@ -1,55 +1,1381 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: express
|
|
3
|
-
description: Express.js development
|
|
3
|
+
description: Enterprise Express.js development with TypeScript, middleware patterns, and production-ready APIs
|
|
4
|
+
category: frameworks
|
|
5
|
+
triggers:
|
|
6
|
+
- express
|
|
7
|
+
- expressjs
|
|
8
|
+
- express.js
|
|
9
|
+
- node api
|
|
10
|
+
- node rest api
|
|
11
|
+
- express middleware
|
|
12
|
+
- express routing
|
|
13
|
+
- node backend
|
|
4
14
|
---
|
|
5
15
|
|
|
6
|
-
# Express.js
|
|
16
|
+
# Express.js
|
|
7
17
|
|
|
8
|
-
|
|
18
|
+
Enterprise-grade **Express.js development** following industry best practices. This skill covers TypeScript integration, middleware patterns, authentication, error handling, validation, testing, and production deployment configurations used by top engineering teams.
|
|
9
19
|
|
|
10
|
-
|
|
11
|
-
```javascript
|
|
12
|
-
import express from 'express';
|
|
20
|
+
## Purpose
|
|
13
21
|
|
|
14
|
-
|
|
15
|
-
|
|
22
|
+
Build scalable Node.js APIs with confidence:
|
|
23
|
+
|
|
24
|
+
- Design clean API architectures with proper routing
|
|
25
|
+
- Implement robust middleware patterns
|
|
26
|
+
- Handle authentication and authorization securely
|
|
27
|
+
- Validate requests with comprehensive schemas
|
|
28
|
+
- Write comprehensive tests for reliability
|
|
29
|
+
- Deploy production-ready applications
|
|
30
|
+
- Optimize performance for high traffic
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
### 1. TypeScript Project Setup
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
// src/app.ts
|
|
38
|
+
import express, { Application, Request, Response, NextFunction } from 'express';
|
|
39
|
+
import cors from 'cors';
|
|
40
|
+
import helmet from 'helmet';
|
|
41
|
+
import compression from 'compression';
|
|
42
|
+
import rateLimit from 'express-rate-limit';
|
|
43
|
+
import { pinoHttp } from 'pino-http';
|
|
44
|
+
import { errorHandler } from './middleware/errorHandler';
|
|
45
|
+
import { notFoundHandler } from './middleware/notFoundHandler';
|
|
46
|
+
import { apiRouter } from './routes';
|
|
47
|
+
import { config } from './config';
|
|
48
|
+
import { logger } from './utils/logger';
|
|
49
|
+
|
|
50
|
+
export function createApp(): Application {
|
|
51
|
+
const app = express();
|
|
52
|
+
|
|
53
|
+
// Security middleware
|
|
54
|
+
app.use(helmet());
|
|
55
|
+
app.use(cors({
|
|
56
|
+
origin: config.cors.origins,
|
|
57
|
+
credentials: true,
|
|
58
|
+
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
|
59
|
+
allowedHeaders: ['Content-Type', 'Authorization'],
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
// Rate limiting
|
|
63
|
+
app.use(rateLimit({
|
|
64
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
65
|
+
max: 100,
|
|
66
|
+
standardHeaders: true,
|
|
67
|
+
legacyHeaders: false,
|
|
68
|
+
message: { error: 'Too many requests, please try again later.' },
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
// Request parsing
|
|
72
|
+
app.use(express.json({ limit: '10mb' }));
|
|
73
|
+
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
|
74
|
+
app.use(compression());
|
|
75
|
+
|
|
76
|
+
// Logging
|
|
77
|
+
app.use(pinoHttp({
|
|
78
|
+
logger,
|
|
79
|
+
customLogLevel: (req, res, err) => {
|
|
80
|
+
if (res.statusCode >= 500 || err) return 'error';
|
|
81
|
+
if (res.statusCode >= 400) return 'warn';
|
|
82
|
+
return 'info';
|
|
83
|
+
},
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
// Health check
|
|
87
|
+
app.get('/health', (req, res) => {
|
|
88
|
+
res.json({ status: 'healthy', timestamp: new Date().toISOString() });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// API routes
|
|
92
|
+
app.use('/api/v1', apiRouter);
|
|
93
|
+
|
|
94
|
+
// Error handling
|
|
95
|
+
app.use(notFoundHandler);
|
|
96
|
+
app.use(errorHandler);
|
|
97
|
+
|
|
98
|
+
return app;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/server.ts
|
|
102
|
+
import { createApp } from './app';
|
|
103
|
+
import { config } from './config';
|
|
104
|
+
import { logger } from './utils/logger';
|
|
105
|
+
import { connectDatabase } from './database';
|
|
106
|
+
|
|
107
|
+
async function bootstrap() {
|
|
108
|
+
try {
|
|
109
|
+
await connectDatabase();
|
|
110
|
+
logger.info('Database connected');
|
|
111
|
+
|
|
112
|
+
const app = createApp();
|
|
113
|
+
const server = app.listen(config.port, () => {
|
|
114
|
+
logger.info(`Server running on port ${config.port}`);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Graceful shutdown
|
|
118
|
+
const shutdown = async (signal: string) => {
|
|
119
|
+
logger.info(`${signal} received, shutting down gracefully`);
|
|
120
|
+
server.close(async () => {
|
|
121
|
+
logger.info('HTTP server closed');
|
|
122
|
+
process.exit(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
setTimeout(() => {
|
|
126
|
+
logger.error('Forced shutdown after timeout');
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}, 10000);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
132
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
133
|
+
} catch (error) {
|
|
134
|
+
logger.error('Failed to start server', error);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
bootstrap();
|
|
16
140
|
```
|
|
17
141
|
|
|
18
|
-
###
|
|
19
|
-
```javascript
|
|
20
|
-
app.get('/users', async (req, res) => {
|
|
21
|
-
const users = await db.users.findMany();
|
|
22
|
-
res.json(users);
|
|
23
|
-
});
|
|
142
|
+
### 2. Middleware Patterns
|
|
24
143
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
144
|
+
```typescript
|
|
145
|
+
// src/middleware/auth.ts
|
|
146
|
+
import { Request, Response, NextFunction } from 'express';
|
|
147
|
+
import jwt from 'jsonwebtoken';
|
|
148
|
+
import { config } from '../config';
|
|
149
|
+
import { AppError } from '../errors/AppError';
|
|
150
|
+
import { UserService } from '../services/UserService';
|
|
151
|
+
|
|
152
|
+
export interface AuthRequest extends Request {
|
|
153
|
+
user?: {
|
|
154
|
+
id: string;
|
|
155
|
+
email: string;
|
|
156
|
+
role: string;
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function authenticate() {
|
|
161
|
+
return async (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
162
|
+
try {
|
|
163
|
+
const authHeader = req.headers.authorization;
|
|
164
|
+
if (!authHeader?.startsWith('Bearer ')) {
|
|
165
|
+
throw new AppError('No token provided', 401, 'UNAUTHORIZED');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const token = authHeader.substring(7);
|
|
169
|
+
const decoded = jwt.verify(token, config.jwt.secret) as {
|
|
170
|
+
userId: string;
|
|
171
|
+
email: string;
|
|
172
|
+
role: string;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// Optionally verify user still exists
|
|
176
|
+
const user = await UserService.findById(decoded.userId);
|
|
177
|
+
if (!user) {
|
|
178
|
+
throw new AppError('User not found', 401, 'UNAUTHORIZED');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
req.user = {
|
|
182
|
+
id: decoded.userId,
|
|
183
|
+
email: decoded.email,
|
|
184
|
+
role: decoded.role,
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
next();
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if (error instanceof jwt.JsonWebTokenError) {
|
|
190
|
+
next(new AppError('Invalid token', 401, 'INVALID_TOKEN'));
|
|
191
|
+
} else if (error instanceof jwt.TokenExpiredError) {
|
|
192
|
+
next(new AppError('Token expired', 401, 'TOKEN_EXPIRED'));
|
|
193
|
+
} else {
|
|
194
|
+
next(error);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function authorize(...roles: string[]) {
|
|
201
|
+
return (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
202
|
+
if (!req.user) {
|
|
203
|
+
return next(new AppError('Authentication required', 401, 'UNAUTHORIZED'));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!roles.includes(req.user.role)) {
|
|
207
|
+
return next(new AppError('Insufficient permissions', 403, 'FORBIDDEN'));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
next();
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// src/middleware/validate.ts
|
|
215
|
+
import { Request, Response, NextFunction } from 'express';
|
|
216
|
+
import { ZodSchema, ZodError } from 'zod';
|
|
217
|
+
import { AppError } from '../errors/AppError';
|
|
218
|
+
|
|
219
|
+
interface ValidationSchemas {
|
|
220
|
+
body?: ZodSchema;
|
|
221
|
+
query?: ZodSchema;
|
|
222
|
+
params?: ZodSchema;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function validate(schemas: ValidationSchemas) {
|
|
226
|
+
return async (req: Request, res: Response, next: NextFunction) => {
|
|
227
|
+
try {
|
|
228
|
+
if (schemas.body) {
|
|
229
|
+
req.body = await schemas.body.parseAsync(req.body);
|
|
230
|
+
}
|
|
231
|
+
if (schemas.query) {
|
|
232
|
+
req.query = await schemas.query.parseAsync(req.query);
|
|
233
|
+
}
|
|
234
|
+
if (schemas.params) {
|
|
235
|
+
req.params = await schemas.params.parseAsync(req.params);
|
|
236
|
+
}
|
|
237
|
+
next();
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (error instanceof ZodError) {
|
|
240
|
+
const details = error.errors.map(err => ({
|
|
241
|
+
path: err.path.join('.'),
|
|
242
|
+
message: err.message,
|
|
243
|
+
}));
|
|
244
|
+
next(new AppError('Validation failed', 400, 'VALIDATION_ERROR', details));
|
|
245
|
+
} else {
|
|
246
|
+
next(error);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/middleware/requestId.ts
|
|
253
|
+
import { Request, Response, NextFunction } from 'express';
|
|
254
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
255
|
+
|
|
256
|
+
export interface RequestWithId extends Request {
|
|
257
|
+
id: string;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function requestId() {
|
|
261
|
+
return (req: RequestWithId, res: Response, next: NextFunction) => {
|
|
262
|
+
req.id = (req.headers['x-request-id'] as string) || uuidv4();
|
|
263
|
+
res.setHeader('X-Request-ID', req.id);
|
|
264
|
+
next();
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// src/middleware/cache.ts
|
|
269
|
+
import { Request, Response, NextFunction } from 'express';
|
|
270
|
+
import { redis } from '../database/redis';
|
|
271
|
+
|
|
272
|
+
interface CacheOptions {
|
|
273
|
+
ttl?: number;
|
|
274
|
+
keyGenerator?: (req: Request) => string;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function cache(options: CacheOptions = {}) {
|
|
278
|
+
const { ttl = 300, keyGenerator } = options;
|
|
279
|
+
|
|
280
|
+
return async (req: Request, res: Response, next: NextFunction) => {
|
|
281
|
+
if (req.method !== 'GET') {
|
|
282
|
+
return next();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const key = keyGenerator?.(req) || `cache:${req.originalUrl}`;
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
const cached = await redis.get(key);
|
|
289
|
+
if (cached) {
|
|
290
|
+
res.setHeader('X-Cache', 'HIT');
|
|
291
|
+
return res.json(JSON.parse(cached));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Override res.json to cache the response
|
|
295
|
+
const originalJson = res.json.bind(res);
|
|
296
|
+
res.json = (body: unknown) => {
|
|
297
|
+
redis.setex(key, ttl, JSON.stringify(body));
|
|
298
|
+
res.setHeader('X-Cache', 'MISS');
|
|
299
|
+
return originalJson(body);
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
next();
|
|
303
|
+
} catch (error) {
|
|
304
|
+
// If cache fails, continue without caching
|
|
305
|
+
next();
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
}
|
|
29
309
|
```
|
|
30
310
|
|
|
31
|
-
###
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
311
|
+
### 3. Error Handling
|
|
312
|
+
|
|
313
|
+
```typescript
|
|
314
|
+
// src/errors/AppError.ts
|
|
315
|
+
export class AppError extends Error {
|
|
316
|
+
public readonly statusCode: number;
|
|
317
|
+
public readonly code: string;
|
|
318
|
+
public readonly isOperational: boolean;
|
|
319
|
+
public readonly details?: unknown;
|
|
320
|
+
|
|
321
|
+
constructor(
|
|
322
|
+
message: string,
|
|
323
|
+
statusCode: number = 500,
|
|
324
|
+
code: string = 'INTERNAL_ERROR',
|
|
325
|
+
details?: unknown,
|
|
326
|
+
isOperational: boolean = true
|
|
327
|
+
) {
|
|
328
|
+
super(message);
|
|
329
|
+
this.statusCode = statusCode;
|
|
330
|
+
this.code = code;
|
|
331
|
+
this.isOperational = isOperational;
|
|
332
|
+
this.details = details;
|
|
333
|
+
|
|
334
|
+
Error.captureStackTrace(this, this.constructor);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export class NotFoundError extends AppError {
|
|
339
|
+
constructor(resource: string, id?: string) {
|
|
340
|
+
super(
|
|
341
|
+
id ? `${resource} with id ${id} not found` : `${resource} not found`,
|
|
342
|
+
404,
|
|
343
|
+
'NOT_FOUND'
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export class ValidationError extends AppError {
|
|
349
|
+
constructor(message: string, details?: unknown) {
|
|
350
|
+
super(message, 400, 'VALIDATION_ERROR', details);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export class UnauthorizedError extends AppError {
|
|
355
|
+
constructor(message: string = 'Unauthorized') {
|
|
356
|
+
super(message, 401, 'UNAUTHORIZED');
|
|
357
|
+
}
|
|
38
358
|
}
|
|
39
359
|
|
|
40
|
-
|
|
360
|
+
export class ForbiddenError extends AppError {
|
|
361
|
+
constructor(message: string = 'Forbidden') {
|
|
362
|
+
super(message, 403, 'FORBIDDEN');
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export class ConflictError extends AppError {
|
|
367
|
+
constructor(message: string) {
|
|
368
|
+
super(message, 409, 'CONFLICT');
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// src/middleware/errorHandler.ts
|
|
373
|
+
import { Request, Response, NextFunction } from 'express';
|
|
374
|
+
import { AppError } from '../errors/AppError';
|
|
375
|
+
import { logger } from '../utils/logger';
|
|
376
|
+
import { config } from '../config';
|
|
377
|
+
|
|
378
|
+
export function errorHandler(
|
|
379
|
+
err: Error,
|
|
380
|
+
req: Request,
|
|
381
|
+
res: Response,
|
|
382
|
+
next: NextFunction
|
|
383
|
+
) {
|
|
384
|
+
// Log error
|
|
385
|
+
logger.error({
|
|
386
|
+
error: err.message,
|
|
387
|
+
stack: err.stack,
|
|
388
|
+
path: req.path,
|
|
389
|
+
method: req.method,
|
|
390
|
+
body: req.body,
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// Handle known operational errors
|
|
394
|
+
if (err instanceof AppError) {
|
|
395
|
+
return res.status(err.statusCode).json({
|
|
396
|
+
error: {
|
|
397
|
+
code: err.code,
|
|
398
|
+
message: err.message,
|
|
399
|
+
details: err.details,
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Handle Prisma errors
|
|
405
|
+
if (err.constructor.name === 'PrismaClientKnownRequestError') {
|
|
406
|
+
const prismaError = err as { code: string; meta?: { target?: string[] } };
|
|
407
|
+
if (prismaError.code === 'P2002') {
|
|
408
|
+
const field = prismaError.meta?.target?.[0] || 'field';
|
|
409
|
+
return res.status(409).json({
|
|
410
|
+
error: {
|
|
411
|
+
code: 'CONFLICT',
|
|
412
|
+
message: `A record with this ${field} already exists`,
|
|
413
|
+
},
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
if (prismaError.code === 'P2025') {
|
|
417
|
+
return res.status(404).json({
|
|
418
|
+
error: {
|
|
419
|
+
code: 'NOT_FOUND',
|
|
420
|
+
message: 'Record not found',
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Handle unknown errors
|
|
427
|
+
const message = config.isProduction
|
|
428
|
+
? 'An unexpected error occurred'
|
|
429
|
+
: err.message;
|
|
430
|
+
|
|
431
|
+
res.status(500).json({
|
|
432
|
+
error: {
|
|
433
|
+
code: 'INTERNAL_ERROR',
|
|
434
|
+
message,
|
|
435
|
+
...(config.isDevelopment && { stack: err.stack }),
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// src/middleware/notFoundHandler.ts
|
|
441
|
+
import { Request, Response } from 'express';
|
|
442
|
+
|
|
443
|
+
export function notFoundHandler(req: Request, res: Response) {
|
|
444
|
+
res.status(404).json({
|
|
445
|
+
error: {
|
|
446
|
+
code: 'NOT_FOUND',
|
|
447
|
+
message: `Route ${req.method} ${req.path} not found`,
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
### 4. Router Organization
|
|
454
|
+
|
|
455
|
+
```typescript
|
|
456
|
+
// src/routes/index.ts
|
|
457
|
+
import { Router } from 'express';
|
|
458
|
+
import { userRouter } from './users';
|
|
459
|
+
import { authRouter } from './auth';
|
|
460
|
+
import { projectRouter } from './projects';
|
|
461
|
+
import { organizationRouter } from './organizations';
|
|
462
|
+
|
|
463
|
+
export const apiRouter = Router();
|
|
464
|
+
|
|
465
|
+
apiRouter.use('/auth', authRouter);
|
|
466
|
+
apiRouter.use('/users', userRouter);
|
|
467
|
+
apiRouter.use('/organizations', organizationRouter);
|
|
468
|
+
apiRouter.use('/projects', projectRouter);
|
|
469
|
+
|
|
470
|
+
// src/routes/users.ts
|
|
471
|
+
import { Router } from 'express';
|
|
472
|
+
import { UserController } from '../controllers/UserController';
|
|
473
|
+
import { authenticate, authorize } from '../middleware/auth';
|
|
474
|
+
import { validate } from '../middleware/validate';
|
|
475
|
+
import { userSchemas } from '../schemas/userSchemas';
|
|
476
|
+
|
|
477
|
+
export const userRouter = Router();
|
|
478
|
+
const controller = new UserController();
|
|
479
|
+
|
|
480
|
+
userRouter.get(
|
|
481
|
+
'/',
|
|
482
|
+
authenticate(),
|
|
483
|
+
authorize('admin'),
|
|
484
|
+
validate({ query: userSchemas.list }),
|
|
485
|
+
controller.list
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
userRouter.get(
|
|
489
|
+
'/me',
|
|
490
|
+
authenticate(),
|
|
491
|
+
controller.getCurrentUser
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
userRouter.patch(
|
|
495
|
+
'/me',
|
|
496
|
+
authenticate(),
|
|
497
|
+
validate({ body: userSchemas.updateProfile }),
|
|
498
|
+
controller.updateProfile
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
userRouter.get(
|
|
502
|
+
'/:id',
|
|
503
|
+
authenticate(),
|
|
504
|
+
validate({ params: userSchemas.params }),
|
|
505
|
+
controller.getById
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
userRouter.post(
|
|
509
|
+
'/',
|
|
510
|
+
authenticate(),
|
|
511
|
+
authorize('admin'),
|
|
512
|
+
validate({ body: userSchemas.create }),
|
|
513
|
+
controller.create
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
userRouter.patch(
|
|
517
|
+
'/:id',
|
|
518
|
+
authenticate(),
|
|
519
|
+
authorize('admin'),
|
|
520
|
+
validate({ params: userSchemas.params, body: userSchemas.update }),
|
|
521
|
+
controller.update
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
userRouter.delete(
|
|
525
|
+
'/:id',
|
|
526
|
+
authenticate(),
|
|
527
|
+
authorize('admin'),
|
|
528
|
+
validate({ params: userSchemas.params }),
|
|
529
|
+
controller.delete
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
// src/routes/projects.ts
|
|
533
|
+
import { Router } from 'express';
|
|
534
|
+
import { ProjectController } from '../controllers/ProjectController';
|
|
535
|
+
import { authenticate } from '../middleware/auth';
|
|
536
|
+
import { validate } from '../middleware/validate';
|
|
537
|
+
import { projectSchemas } from '../schemas/projectSchemas';
|
|
538
|
+
import { cache } from '../middleware/cache';
|
|
539
|
+
|
|
540
|
+
export const projectRouter = Router();
|
|
541
|
+
const controller = new ProjectController();
|
|
542
|
+
|
|
543
|
+
projectRouter.use(authenticate());
|
|
544
|
+
|
|
545
|
+
projectRouter.get(
|
|
546
|
+
'/',
|
|
547
|
+
validate({ query: projectSchemas.list }),
|
|
548
|
+
cache({ ttl: 60 }),
|
|
549
|
+
controller.list
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
projectRouter.get(
|
|
553
|
+
'/:id',
|
|
554
|
+
validate({ params: projectSchemas.params }),
|
|
555
|
+
cache({ ttl: 300 }),
|
|
556
|
+
controller.getById
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
projectRouter.post(
|
|
560
|
+
'/',
|
|
561
|
+
validate({ body: projectSchemas.create }),
|
|
562
|
+
controller.create
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
projectRouter.patch(
|
|
566
|
+
'/:id',
|
|
567
|
+
validate({ params: projectSchemas.params, body: projectSchemas.update }),
|
|
568
|
+
controller.update
|
|
569
|
+
);
|
|
570
|
+
|
|
571
|
+
projectRouter.delete(
|
|
572
|
+
'/:id',
|
|
573
|
+
validate({ params: projectSchemas.params }),
|
|
574
|
+
controller.delete
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
// Nested routes for project tasks
|
|
578
|
+
projectRouter.get(
|
|
579
|
+
'/:id/tasks',
|
|
580
|
+
validate({ params: projectSchemas.params, query: projectSchemas.taskList }),
|
|
581
|
+
controller.getTasks
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
projectRouter.post(
|
|
585
|
+
'/:id/tasks',
|
|
586
|
+
validate({ params: projectSchemas.params, body: projectSchemas.createTask }),
|
|
587
|
+
controller.createTask
|
|
588
|
+
);
|
|
41
589
|
```
|
|
42
590
|
|
|
43
|
-
###
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
591
|
+
### 5. Controllers and Services
|
|
592
|
+
|
|
593
|
+
```typescript
|
|
594
|
+
// src/controllers/UserController.ts
|
|
595
|
+
import { Response, NextFunction } from 'express';
|
|
596
|
+
import { AuthRequest } from '../middleware/auth';
|
|
597
|
+
import { UserService } from '../services/UserService';
|
|
598
|
+
import { NotFoundError } from '../errors/AppError';
|
|
599
|
+
|
|
600
|
+
export class UserController {
|
|
601
|
+
private userService = new UserService();
|
|
602
|
+
|
|
603
|
+
list = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
604
|
+
try {
|
|
605
|
+
const { page = 1, limit = 20, search, role } = req.query as {
|
|
606
|
+
page?: number;
|
|
607
|
+
limit?: number;
|
|
608
|
+
search?: string;
|
|
609
|
+
role?: string;
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
const result = await this.userService.findAll({
|
|
613
|
+
page: Number(page),
|
|
614
|
+
limit: Math.min(Number(limit), 100),
|
|
615
|
+
search,
|
|
616
|
+
role,
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
res.json(result);
|
|
620
|
+
} catch (error) {
|
|
621
|
+
next(error);
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
getCurrentUser = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
626
|
+
try {
|
|
627
|
+
const user = await this.userService.findById(req.user!.id);
|
|
628
|
+
if (!user) {
|
|
629
|
+
throw new NotFoundError('User');
|
|
630
|
+
}
|
|
631
|
+
res.json(user);
|
|
632
|
+
} catch (error) {
|
|
633
|
+
next(error);
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
getById = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
638
|
+
try {
|
|
639
|
+
const user = await this.userService.findById(req.params.id);
|
|
640
|
+
if (!user) {
|
|
641
|
+
throw new NotFoundError('User', req.params.id);
|
|
642
|
+
}
|
|
643
|
+
res.json(user);
|
|
644
|
+
} catch (error) {
|
|
645
|
+
next(error);
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
create = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
650
|
+
try {
|
|
651
|
+
const user = await this.userService.create(req.body);
|
|
652
|
+
res.status(201).json(user);
|
|
653
|
+
} catch (error) {
|
|
654
|
+
next(error);
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
updateProfile = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
659
|
+
try {
|
|
660
|
+
const user = await this.userService.update(req.user!.id, req.body);
|
|
661
|
+
res.json(user);
|
|
662
|
+
} catch (error) {
|
|
663
|
+
next(error);
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
update = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
668
|
+
try {
|
|
669
|
+
const user = await this.userService.update(req.params.id, req.body);
|
|
670
|
+
res.json(user);
|
|
671
|
+
} catch (error) {
|
|
672
|
+
next(error);
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
delete = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
677
|
+
try {
|
|
678
|
+
await this.userService.delete(req.params.id);
|
|
679
|
+
res.status(204).send();
|
|
680
|
+
} catch (error) {
|
|
681
|
+
next(error);
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// src/services/UserService.ts
|
|
687
|
+
import { Prisma } from '@prisma/client';
|
|
688
|
+
import { prisma } from '../database/prisma';
|
|
689
|
+
import { ConflictError, NotFoundError } from '../errors/AppError';
|
|
690
|
+
import { hashPassword, verifyPassword } from '../utils/password';
|
|
691
|
+
|
|
692
|
+
interface FindAllOptions {
|
|
693
|
+
page: number;
|
|
694
|
+
limit: number;
|
|
695
|
+
search?: string;
|
|
696
|
+
role?: string;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
interface CreateUserData {
|
|
700
|
+
email: string;
|
|
701
|
+
password: string;
|
|
702
|
+
name: string;
|
|
703
|
+
role?: string;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
interface UpdateUserData {
|
|
707
|
+
email?: string;
|
|
708
|
+
name?: string;
|
|
709
|
+
role?: string;
|
|
710
|
+
password?: string;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
export class UserService {
|
|
714
|
+
async findAll(options: FindAllOptions) {
|
|
715
|
+
const { page, limit, search, role } = options;
|
|
716
|
+
const skip = (page - 1) * limit;
|
|
717
|
+
|
|
718
|
+
const where: Prisma.UserWhereInput = {
|
|
719
|
+
deletedAt: null,
|
|
720
|
+
...(search && {
|
|
721
|
+
OR: [
|
|
722
|
+
{ name: { contains: search, mode: 'insensitive' } },
|
|
723
|
+
{ email: { contains: search, mode: 'insensitive' } },
|
|
724
|
+
],
|
|
725
|
+
}),
|
|
726
|
+
...(role && { role }),
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
const [users, total] = await Promise.all([
|
|
730
|
+
prisma.user.findMany({
|
|
731
|
+
where,
|
|
732
|
+
skip,
|
|
733
|
+
take: limit,
|
|
734
|
+
select: {
|
|
735
|
+
id: true,
|
|
736
|
+
email: true,
|
|
737
|
+
name: true,
|
|
738
|
+
role: true,
|
|
739
|
+
createdAt: true,
|
|
740
|
+
},
|
|
741
|
+
orderBy: { createdAt: 'desc' },
|
|
742
|
+
}),
|
|
743
|
+
prisma.user.count({ where }),
|
|
744
|
+
]);
|
|
745
|
+
|
|
746
|
+
return {
|
|
747
|
+
data: users,
|
|
748
|
+
pagination: {
|
|
749
|
+
page,
|
|
750
|
+
limit,
|
|
751
|
+
total,
|
|
752
|
+
totalPages: Math.ceil(total / limit),
|
|
753
|
+
hasMore: skip + users.length < total,
|
|
754
|
+
},
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async findById(id: string) {
|
|
759
|
+
return prisma.user.findFirst({
|
|
760
|
+
where: { id, deletedAt: null },
|
|
761
|
+
select: {
|
|
762
|
+
id: true,
|
|
763
|
+
email: true,
|
|
764
|
+
name: true,
|
|
765
|
+
role: true,
|
|
766
|
+
createdAt: true,
|
|
767
|
+
updatedAt: true,
|
|
768
|
+
organizations: {
|
|
769
|
+
select: {
|
|
770
|
+
organization: {
|
|
771
|
+
select: { id: true, name: true, slug: true },
|
|
772
|
+
},
|
|
773
|
+
role: true,
|
|
774
|
+
},
|
|
775
|
+
},
|
|
776
|
+
},
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
async findByEmail(email: string) {
|
|
781
|
+
return prisma.user.findFirst({
|
|
782
|
+
where: { email, deletedAt: null },
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
async create(data: CreateUserData) {
|
|
787
|
+
const existing = await this.findByEmail(data.email);
|
|
788
|
+
if (existing) {
|
|
789
|
+
throw new ConflictError('Email already in use');
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const hashedPassword = await hashPassword(data.password);
|
|
793
|
+
|
|
794
|
+
return prisma.user.create({
|
|
795
|
+
data: {
|
|
796
|
+
...data,
|
|
797
|
+
password: hashedPassword,
|
|
798
|
+
},
|
|
799
|
+
select: {
|
|
800
|
+
id: true,
|
|
801
|
+
email: true,
|
|
802
|
+
name: true,
|
|
803
|
+
role: true,
|
|
804
|
+
createdAt: true,
|
|
805
|
+
},
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async update(id: string, data: UpdateUserData) {
|
|
810
|
+
const user = await this.findById(id);
|
|
811
|
+
if (!user) {
|
|
812
|
+
throw new NotFoundError('User', id);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
if (data.email && data.email !== user.email) {
|
|
816
|
+
const existing = await this.findByEmail(data.email);
|
|
817
|
+
if (existing) {
|
|
818
|
+
throw new ConflictError('Email already in use');
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const updateData: Prisma.UserUpdateInput = { ...data };
|
|
823
|
+
if (data.password) {
|
|
824
|
+
updateData.password = await hashPassword(data.password);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
return prisma.user.update({
|
|
828
|
+
where: { id },
|
|
829
|
+
data: updateData,
|
|
830
|
+
select: {
|
|
831
|
+
id: true,
|
|
832
|
+
email: true,
|
|
833
|
+
name: true,
|
|
834
|
+
role: true,
|
|
835
|
+
updatedAt: true,
|
|
836
|
+
},
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
async delete(id: string) {
|
|
841
|
+
const user = await this.findById(id);
|
|
842
|
+
if (!user) {
|
|
843
|
+
throw new NotFoundError('User', id);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Soft delete
|
|
847
|
+
await prisma.user.update({
|
|
848
|
+
where: { id },
|
|
849
|
+
data: { deletedAt: new Date() },
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
async verifyCredentials(email: string, password: string) {
|
|
854
|
+
const user = await prisma.user.findFirst({
|
|
855
|
+
where: { email, deletedAt: null },
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
if (!user) {
|
|
859
|
+
return null;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const isValid = await verifyPassword(password, user.password);
|
|
863
|
+
if (!isValid) {
|
|
864
|
+
return null;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
return {
|
|
868
|
+
id: user.id,
|
|
869
|
+
email: user.email,
|
|
870
|
+
name: user.name,
|
|
871
|
+
role: user.role,
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
```
|
|
876
|
+
|
|
877
|
+
### 6. Validation Schemas
|
|
878
|
+
|
|
879
|
+
```typescript
|
|
880
|
+
// src/schemas/userSchemas.ts
|
|
881
|
+
import { z } from 'zod';
|
|
882
|
+
|
|
883
|
+
export const userSchemas = {
|
|
884
|
+
params: z.object({
|
|
885
|
+
id: z.string().uuid('Invalid user ID'),
|
|
886
|
+
}),
|
|
887
|
+
|
|
888
|
+
list: z.object({
|
|
889
|
+
page: z.coerce.number().int().positive().default(1),
|
|
890
|
+
limit: z.coerce.number().int().positive().max(100).default(20),
|
|
891
|
+
search: z.string().optional(),
|
|
892
|
+
role: z.enum(['admin', 'user', 'guest']).optional(),
|
|
893
|
+
}),
|
|
894
|
+
|
|
895
|
+
create: z.object({
|
|
896
|
+
email: z.string().email('Invalid email address'),
|
|
897
|
+
password: z
|
|
898
|
+
.string()
|
|
899
|
+
.min(8, 'Password must be at least 8 characters')
|
|
900
|
+
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
|
|
901
|
+
.regex(/[0-9]/, 'Password must contain a number'),
|
|
902
|
+
name: z.string().min(2).max(100),
|
|
903
|
+
role: z.enum(['admin', 'user', 'guest']).default('user'),
|
|
904
|
+
}),
|
|
905
|
+
|
|
906
|
+
update: z.object({
|
|
907
|
+
email: z.string().email('Invalid email address').optional(),
|
|
908
|
+
name: z.string().min(2).max(100).optional(),
|
|
909
|
+
role: z.enum(['admin', 'user', 'guest']).optional(),
|
|
910
|
+
password: z
|
|
911
|
+
.string()
|
|
912
|
+
.min(8, 'Password must be at least 8 characters')
|
|
913
|
+
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
|
|
914
|
+
.regex(/[0-9]/, 'Password must contain a number')
|
|
915
|
+
.optional(),
|
|
916
|
+
}).refine(data => Object.keys(data).length > 0, {
|
|
917
|
+
message: 'At least one field must be provided',
|
|
918
|
+
}),
|
|
919
|
+
|
|
920
|
+
updateProfile: z.object({
|
|
921
|
+
name: z.string().min(2).max(100).optional(),
|
|
922
|
+
avatar: z.string().url().optional(),
|
|
923
|
+
}),
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
// src/schemas/projectSchemas.ts
|
|
927
|
+
import { z } from 'zod';
|
|
928
|
+
|
|
929
|
+
export const projectSchemas = {
|
|
930
|
+
params: z.object({
|
|
931
|
+
id: z.string().uuid('Invalid project ID'),
|
|
932
|
+
}),
|
|
933
|
+
|
|
934
|
+
list: z.object({
|
|
935
|
+
page: z.coerce.number().int().positive().default(1),
|
|
936
|
+
limit: z.coerce.number().int().positive().max(100).default(20),
|
|
937
|
+
status: z.enum(['draft', 'active', 'completed', 'archived']).optional(),
|
|
938
|
+
search: z.string().optional(),
|
|
939
|
+
sortBy: z.enum(['createdAt', 'name', 'updatedAt']).default('createdAt'),
|
|
940
|
+
sortOrder: z.enum(['asc', 'desc']).default('desc'),
|
|
941
|
+
}),
|
|
942
|
+
|
|
943
|
+
create: z.object({
|
|
944
|
+
name: z.string().min(1).max(255),
|
|
945
|
+
description: z.string().max(5000).optional(),
|
|
946
|
+
organizationId: z.string().uuid(),
|
|
947
|
+
}),
|
|
948
|
+
|
|
949
|
+
update: z.object({
|
|
950
|
+
name: z.string().min(1).max(255).optional(),
|
|
951
|
+
description: z.string().max(5000).optional(),
|
|
952
|
+
status: z.enum(['draft', 'active', 'completed', 'archived']).optional(),
|
|
953
|
+
}),
|
|
954
|
+
|
|
955
|
+
taskList: z.object({
|
|
956
|
+
page: z.coerce.number().int().positive().default(1),
|
|
957
|
+
limit: z.coerce.number().int().positive().max(100).default(20),
|
|
958
|
+
status: z.enum(['todo', 'in_progress', 'done']).optional(),
|
|
959
|
+
}),
|
|
960
|
+
|
|
961
|
+
createTask: z.object({
|
|
962
|
+
title: z.string().min(1).max(255),
|
|
963
|
+
description: z.string().max(5000).optional(),
|
|
964
|
+
priority: z.enum(['low', 'medium', 'high']).default('medium'),
|
|
965
|
+
assigneeId: z.string().uuid().optional(),
|
|
966
|
+
dueDate: z.coerce.date().optional(),
|
|
967
|
+
}),
|
|
968
|
+
};
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
### 7. Testing Patterns
|
|
972
|
+
|
|
973
|
+
```typescript
|
|
974
|
+
// tests/setup.ts
|
|
975
|
+
import { PrismaClient } from '@prisma/client';
|
|
976
|
+
import { mockDeep, DeepMockProxy } from 'jest-mock-extended';
|
|
977
|
+
|
|
978
|
+
export const prismaMock = mockDeep<PrismaClient>();
|
|
979
|
+
|
|
980
|
+
jest.mock('../src/database/prisma', () => ({
|
|
981
|
+
prisma: prismaMock,
|
|
982
|
+
}));
|
|
983
|
+
|
|
984
|
+
beforeEach(() => {
|
|
985
|
+
jest.clearAllMocks();
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
// tests/integration/users.test.ts
|
|
989
|
+
import request from 'supertest';
|
|
990
|
+
import { createApp } from '../../src/app';
|
|
991
|
+
import { prisma } from '../../src/database/prisma';
|
|
992
|
+
import { generateToken } from '../../src/utils/jwt';
|
|
993
|
+
|
|
994
|
+
const app = createApp();
|
|
995
|
+
|
|
996
|
+
describe('Users API', () => {
|
|
997
|
+
let adminToken: string;
|
|
998
|
+
let userToken: string;
|
|
999
|
+
|
|
1000
|
+
beforeAll(async () => {
|
|
1001
|
+
// Create test users
|
|
1002
|
+
const admin = await prisma.user.create({
|
|
1003
|
+
data: {
|
|
1004
|
+
email: 'admin@test.com',
|
|
1005
|
+
password: await hashPassword('Admin123!'),
|
|
1006
|
+
name: 'Admin User',
|
|
1007
|
+
role: 'admin',
|
|
1008
|
+
},
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
const user = await prisma.user.create({
|
|
1012
|
+
data: {
|
|
1013
|
+
email: 'user@test.com',
|
|
1014
|
+
password: await hashPassword('User123!'),
|
|
1015
|
+
name: 'Regular User',
|
|
1016
|
+
role: 'user',
|
|
1017
|
+
},
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
adminToken = generateToken(admin);
|
|
1021
|
+
userToken = generateToken(user);
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
afterAll(async () => {
|
|
1025
|
+
await prisma.user.deleteMany();
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
describe('GET /api/v1/users', () => {
|
|
1029
|
+
it('should return paginated users for admin', async () => {
|
|
1030
|
+
const response = await request(app)
|
|
1031
|
+
.get('/api/v1/users')
|
|
1032
|
+
.set('Authorization', `Bearer ${adminToken}`)
|
|
1033
|
+
.expect(200);
|
|
1034
|
+
|
|
1035
|
+
expect(response.body).toHaveProperty('data');
|
|
1036
|
+
expect(response.body).toHaveProperty('pagination');
|
|
1037
|
+
expect(Array.isArray(response.body.data)).toBe(true);
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
it('should return 403 for non-admin users', async () => {
|
|
1041
|
+
await request(app)
|
|
1042
|
+
.get('/api/v1/users')
|
|
1043
|
+
.set('Authorization', `Bearer ${userToken}`)
|
|
1044
|
+
.expect(403);
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
it('should return 401 without token', async () => {
|
|
1048
|
+
await request(app)
|
|
1049
|
+
.get('/api/v1/users')
|
|
1050
|
+
.expect(401);
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
it('should filter users by search term', async () => {
|
|
1054
|
+
const response = await request(app)
|
|
1055
|
+
.get('/api/v1/users?search=admin')
|
|
1056
|
+
.set('Authorization', `Bearer ${adminToken}`)
|
|
1057
|
+
.expect(200);
|
|
1058
|
+
|
|
1059
|
+
expect(response.body.data.every((u: any) =>
|
|
1060
|
+
u.name.toLowerCase().includes('admin') ||
|
|
1061
|
+
u.email.toLowerCase().includes('admin')
|
|
1062
|
+
)).toBe(true);
|
|
1063
|
+
});
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
describe('GET /api/v1/users/me', () => {
|
|
1067
|
+
it('should return current user profile', async () => {
|
|
1068
|
+
const response = await request(app)
|
|
1069
|
+
.get('/api/v1/users/me')
|
|
1070
|
+
.set('Authorization', `Bearer ${userToken}`)
|
|
1071
|
+
.expect(200);
|
|
1072
|
+
|
|
1073
|
+
expect(response.body.email).toBe('user@test.com');
|
|
1074
|
+
expect(response.body).not.toHaveProperty('password');
|
|
1075
|
+
});
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
describe('POST /api/v1/users', () => {
|
|
1079
|
+
it('should create a new user', async () => {
|
|
1080
|
+
const newUser = {
|
|
1081
|
+
email: 'new@test.com',
|
|
1082
|
+
password: 'NewUser123!',
|
|
1083
|
+
name: 'New User',
|
|
1084
|
+
role: 'user',
|
|
1085
|
+
};
|
|
1086
|
+
|
|
1087
|
+
const response = await request(app)
|
|
1088
|
+
.post('/api/v1/users')
|
|
1089
|
+
.set('Authorization', `Bearer ${adminToken}`)
|
|
1090
|
+
.send(newUser)
|
|
1091
|
+
.expect(201);
|
|
1092
|
+
|
|
1093
|
+
expect(response.body.email).toBe(newUser.email);
|
|
1094
|
+
expect(response.body).toHaveProperty('id');
|
|
1095
|
+
expect(response.body).not.toHaveProperty('password');
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
it('should return 400 for invalid email', async () => {
|
|
1099
|
+
const response = await request(app)
|
|
1100
|
+
.post('/api/v1/users')
|
|
1101
|
+
.set('Authorization', `Bearer ${adminToken}`)
|
|
1102
|
+
.send({
|
|
1103
|
+
email: 'invalid-email',
|
|
1104
|
+
password: 'Valid123!',
|
|
1105
|
+
name: 'Test User',
|
|
1106
|
+
})
|
|
1107
|
+
.expect(400);
|
|
1108
|
+
|
|
1109
|
+
expect(response.body.error.code).toBe('VALIDATION_ERROR');
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
it('should return 409 for duplicate email', async () => {
|
|
1113
|
+
await request(app)
|
|
1114
|
+
.post('/api/v1/users')
|
|
1115
|
+
.set('Authorization', `Bearer ${adminToken}`)
|
|
1116
|
+
.send({
|
|
1117
|
+
email: 'user@test.com',
|
|
1118
|
+
password: 'Duplicate123!',
|
|
1119
|
+
name: 'Duplicate User',
|
|
1120
|
+
})
|
|
1121
|
+
.expect(409);
|
|
1122
|
+
});
|
|
1123
|
+
});
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
// tests/unit/UserService.test.ts
|
|
1127
|
+
import { UserService } from '../../src/services/UserService';
|
|
1128
|
+
import { prismaMock } from '../setup';
|
|
1129
|
+
|
|
1130
|
+
describe('UserService', () => {
|
|
1131
|
+
const service = new UserService();
|
|
1132
|
+
|
|
1133
|
+
describe('findAll', () => {
|
|
1134
|
+
it('should return paginated users', async () => {
|
|
1135
|
+
const mockUsers = [
|
|
1136
|
+
{ id: '1', email: 'user1@test.com', name: 'User 1', role: 'user', createdAt: new Date() },
|
|
1137
|
+
{ id: '2', email: 'user2@test.com', name: 'User 2', role: 'user', createdAt: new Date() },
|
|
1138
|
+
];
|
|
1139
|
+
|
|
1140
|
+
prismaMock.user.findMany.mockResolvedValue(mockUsers);
|
|
1141
|
+
prismaMock.user.count.mockResolvedValue(2);
|
|
1142
|
+
|
|
1143
|
+
const result = await service.findAll({ page: 1, limit: 10 });
|
|
1144
|
+
|
|
1145
|
+
expect(result.data).toHaveLength(2);
|
|
1146
|
+
expect(result.pagination.total).toBe(2);
|
|
1147
|
+
expect(prismaMock.user.findMany).toHaveBeenCalledWith(
|
|
1148
|
+
expect.objectContaining({
|
|
1149
|
+
skip: 0,
|
|
1150
|
+
take: 10,
|
|
1151
|
+
})
|
|
1152
|
+
);
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
it('should apply search filter', async () => {
|
|
1156
|
+
prismaMock.user.findMany.mockResolvedValue([]);
|
|
1157
|
+
prismaMock.user.count.mockResolvedValue(0);
|
|
1158
|
+
|
|
1159
|
+
await service.findAll({ page: 1, limit: 10, search: 'john' });
|
|
1160
|
+
|
|
1161
|
+
expect(prismaMock.user.findMany).toHaveBeenCalledWith(
|
|
1162
|
+
expect.objectContaining({
|
|
1163
|
+
where: expect.objectContaining({
|
|
1164
|
+
OR: expect.arrayContaining([
|
|
1165
|
+
{ name: { contains: 'john', mode: 'insensitive' } },
|
|
1166
|
+
]),
|
|
1167
|
+
}),
|
|
1168
|
+
})
|
|
1169
|
+
);
|
|
1170
|
+
});
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
describe('create', () => {
|
|
1174
|
+
it('should create a new user with hashed password', async () => {
|
|
1175
|
+
const userData = {
|
|
1176
|
+
email: 'new@test.com',
|
|
1177
|
+
password: 'Password123!',
|
|
1178
|
+
name: 'New User',
|
|
1179
|
+
};
|
|
1180
|
+
|
|
1181
|
+
prismaMock.user.findFirst.mockResolvedValue(null);
|
|
1182
|
+
prismaMock.user.create.mockResolvedValue({
|
|
1183
|
+
id: 'new-id',
|
|
1184
|
+
...userData,
|
|
1185
|
+
password: 'hashed',
|
|
1186
|
+
role: 'user',
|
|
1187
|
+
createdAt: new Date(),
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
const result = await service.create(userData);
|
|
1191
|
+
|
|
1192
|
+
expect(result.email).toBe(userData.email);
|
|
1193
|
+
expect(prismaMock.user.create).toHaveBeenCalledWith(
|
|
1194
|
+
expect.objectContaining({
|
|
1195
|
+
data: expect.objectContaining({
|
|
1196
|
+
email: userData.email,
|
|
1197
|
+
password: expect.not.stringContaining(userData.password),
|
|
1198
|
+
}),
|
|
1199
|
+
})
|
|
1200
|
+
);
|
|
1201
|
+
});
|
|
1202
|
+
});
|
|
48
1203
|
});
|
|
49
1204
|
```
|
|
50
1205
|
|
|
1206
|
+
## Use Cases
|
|
1207
|
+
|
|
1208
|
+
### REST API for E-commerce Platform
|
|
1209
|
+
|
|
1210
|
+
```typescript
|
|
1211
|
+
// src/routes/products.ts
|
|
1212
|
+
import { Router } from 'express';
|
|
1213
|
+
import { ProductController } from '../controllers/ProductController';
|
|
1214
|
+
import { authenticate, authorize } from '../middleware/auth';
|
|
1215
|
+
import { validate } from '../middleware/validate';
|
|
1216
|
+
import { cache } from '../middleware/cache';
|
|
1217
|
+
import { upload } from '../middleware/upload';
|
|
1218
|
+
import { z } from 'zod';
|
|
1219
|
+
|
|
1220
|
+
const router = Router();
|
|
1221
|
+
const controller = new ProductController();
|
|
1222
|
+
|
|
1223
|
+
const productSchemas = {
|
|
1224
|
+
list: z.object({
|
|
1225
|
+
category: z.string().optional(),
|
|
1226
|
+
minPrice: z.coerce.number().positive().optional(),
|
|
1227
|
+
maxPrice: z.coerce.number().positive().optional(),
|
|
1228
|
+
search: z.string().optional(),
|
|
1229
|
+
sortBy: z.enum(['price', 'name', 'createdAt', 'popularity']).default('createdAt'),
|
|
1230
|
+
sortOrder: z.enum(['asc', 'desc']).default('desc'),
|
|
1231
|
+
page: z.coerce.number().positive().default(1),
|
|
1232
|
+
limit: z.coerce.number().positive().max(50).default(20),
|
|
1233
|
+
}),
|
|
1234
|
+
create: z.object({
|
|
1235
|
+
name: z.string().min(1).max(255),
|
|
1236
|
+
description: z.string().max(5000),
|
|
1237
|
+
price: z.number().positive(),
|
|
1238
|
+
categoryId: z.string().uuid(),
|
|
1239
|
+
inventory: z.number().int().nonnegative().default(0),
|
|
1240
|
+
sku: z.string().optional(),
|
|
1241
|
+
}),
|
|
1242
|
+
};
|
|
1243
|
+
|
|
1244
|
+
// Public routes
|
|
1245
|
+
router.get(
|
|
1246
|
+
'/',
|
|
1247
|
+
validate({ query: productSchemas.list }),
|
|
1248
|
+
cache({ ttl: 60 }),
|
|
1249
|
+
controller.list
|
|
1250
|
+
);
|
|
1251
|
+
|
|
1252
|
+
router.get(
|
|
1253
|
+
'/:id',
|
|
1254
|
+
cache({ ttl: 300 }),
|
|
1255
|
+
controller.getById
|
|
1256
|
+
);
|
|
1257
|
+
|
|
1258
|
+
// Admin routes
|
|
1259
|
+
router.post(
|
|
1260
|
+
'/',
|
|
1261
|
+
authenticate(),
|
|
1262
|
+
authorize('admin'),
|
|
1263
|
+
upload.array('images', 5),
|
|
1264
|
+
validate({ body: productSchemas.create }),
|
|
1265
|
+
controller.create
|
|
1266
|
+
);
|
|
1267
|
+
|
|
1268
|
+
router.patch(
|
|
1269
|
+
'/:id',
|
|
1270
|
+
authenticate(),
|
|
1271
|
+
authorize('admin'),
|
|
1272
|
+
controller.update
|
|
1273
|
+
);
|
|
1274
|
+
|
|
1275
|
+
export { router as productRouter };
|
|
1276
|
+
```
|
|
1277
|
+
|
|
1278
|
+
### WebSocket Integration for Real-time Updates
|
|
1279
|
+
|
|
1280
|
+
```typescript
|
|
1281
|
+
// src/websocket/index.ts
|
|
1282
|
+
import { Server as SocketIOServer } from 'socket.io';
|
|
1283
|
+
import { Server } from 'http';
|
|
1284
|
+
import { verifyToken } from '../utils/jwt';
|
|
1285
|
+
import { logger } from '../utils/logger';
|
|
1286
|
+
|
|
1287
|
+
export function setupWebSocket(server: Server) {
|
|
1288
|
+
const io = new SocketIOServer(server, {
|
|
1289
|
+
cors: {
|
|
1290
|
+
origin: process.env.CORS_ORIGINS?.split(',') || '*',
|
|
1291
|
+
credentials: true,
|
|
1292
|
+
},
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
// Authentication middleware
|
|
1296
|
+
io.use(async (socket, next) => {
|
|
1297
|
+
try {
|
|
1298
|
+
const token = socket.handshake.auth.token;
|
|
1299
|
+
if (!token) {
|
|
1300
|
+
return next(new Error('Authentication required'));
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
const user = await verifyToken(token);
|
|
1304
|
+
socket.data.user = user;
|
|
1305
|
+
next();
|
|
1306
|
+
} catch (error) {
|
|
1307
|
+
next(new Error('Invalid token'));
|
|
1308
|
+
}
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
io.on('connection', (socket) => {
|
|
1312
|
+
const user = socket.data.user;
|
|
1313
|
+
logger.info(`User connected: ${user.id}`);
|
|
1314
|
+
|
|
1315
|
+
// Join user's personal room
|
|
1316
|
+
socket.join(`user:${user.id}`);
|
|
1317
|
+
|
|
1318
|
+
// Join organization rooms
|
|
1319
|
+
socket.on('join:organization', async (orgId: string) => {
|
|
1320
|
+
// Verify user belongs to organization
|
|
1321
|
+
const membership = await checkMembership(user.id, orgId);
|
|
1322
|
+
if (membership) {
|
|
1323
|
+
socket.join(`org:${orgId}`);
|
|
1324
|
+
socket.emit('joined:organization', { orgId });
|
|
1325
|
+
}
|
|
1326
|
+
});
|
|
1327
|
+
|
|
1328
|
+
// Handle real-time notifications
|
|
1329
|
+
socket.on('disconnect', () => {
|
|
1330
|
+
logger.info(`User disconnected: ${user.id}`);
|
|
1331
|
+
});
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
return io;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// Emit events from services
|
|
1338
|
+
export function emitToUser(io: SocketIOServer, userId: string, event: string, data: unknown) {
|
|
1339
|
+
io.to(`user:${userId}`).emit(event, data);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
export function emitToOrganization(io: SocketIOServer, orgId: string, event: string, data: unknown) {
|
|
1343
|
+
io.to(`org:${orgId}`).emit(event, data);
|
|
1344
|
+
}
|
|
1345
|
+
```
|
|
1346
|
+
|
|
51
1347
|
## Best Practices
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
- Use
|
|
1348
|
+
|
|
1349
|
+
### Do's
|
|
1350
|
+
|
|
1351
|
+
- Use TypeScript for type safety
|
|
1352
|
+
- Implement proper error handling with custom error classes
|
|
1353
|
+
- Use validation middleware for all inputs
|
|
1354
|
+
- Use dependency injection for testability
|
|
1355
|
+
- Implement proper logging with correlation IDs
|
|
1356
|
+
- Use environment variables for configuration
|
|
1357
|
+
- Implement graceful shutdown handling
|
|
1358
|
+
- Use compression and rate limiting
|
|
1359
|
+
- Write integration and unit tests
|
|
1360
|
+
- Use async/await consistently
|
|
1361
|
+
|
|
1362
|
+
### Don'ts
|
|
1363
|
+
|
|
1364
|
+
- Don't use callbacks when async/await is available
|
|
1365
|
+
- Don't catch errors without proper handling
|
|
1366
|
+
- Don't expose stack traces in production
|
|
1367
|
+
- Don't store secrets in code
|
|
1368
|
+
- Don't skip input validation
|
|
1369
|
+
- Don't use synchronous file operations
|
|
1370
|
+
- Don't ignore error handling in middleware
|
|
1371
|
+
- Don't use `any` type unnecessarily
|
|
1372
|
+
- Don't forget to handle promise rejections
|
|
1373
|
+
- Don't skip security headers
|
|
1374
|
+
|
|
1375
|
+
## References
|
|
1376
|
+
|
|
1377
|
+
- [Express.js Documentation](https://expressjs.com/)
|
|
1378
|
+
- [Node.js Best Practices](https://github.com/goldbergyoni/nodebestpractices)
|
|
1379
|
+
- [Prisma Documentation](https://www.prisma.io/docs)
|
|
1380
|
+
- [Zod Documentation](https://zod.dev/)
|
|
1381
|
+
- [TypeScript Express Tutorial](https://developer.okta.com/blog/2018/11/15/node-express-typescript)
|