servcraft 0.1.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/.dockerignore +45 -0
- package/.env.example +46 -0
- package/.husky/commit-msg +1 -0
- package/.husky/pre-commit +1 -0
- package/.prettierignore +4 -0
- package/.prettierrc +11 -0
- package/Dockerfile +76 -0
- package/Dockerfile.dev +31 -0
- package/README.md +232 -0
- package/commitlint.config.js +24 -0
- package/dist/cli/index.cjs +3968 -0
- package/dist/cli/index.cjs.map +1 -0
- package/dist/cli/index.d.cts +1 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +3945 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/index.cjs +2458 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +828 -0
- package/dist/index.d.ts +828 -0
- package/dist/index.js +2332 -0
- package/dist/index.js.map +1 -0
- package/docker-compose.prod.yml +118 -0
- package/docker-compose.yml +147 -0
- package/eslint.config.js +27 -0
- package/npm-cache/_cacache/content-v2/sha512/1c/d0/03440d500a0487621aad1d6402978340698976602046db8e24fa03c01ee6c022c69b0582f969042d9442ee876ac35c038e960dd427d1e622fa24b8eb7dba +0 -0
- package/npm-cache/_cacache/content-v2/sha512/42/55/28b493ca491833e5aab0e9c3108d29ab3f36c248ca88f45d4630674fce9130959e56ae308797ac2b6328fa7f09a610b9550ed09cb971d039876d293fc69d +0 -0
- package/npm-cache/_cacache/content-v2/sha512/e0/12/f360dc9315ee5f17844a0c8c233ee6bf7c30837c4a02ea0d56c61c7f7ab21c0e958e50ed2c57c59f983c762b93056778c9009b2398ffc26def0183999b13 +0 -0
- package/npm-cache/_cacache/content-v2/sha512/ed/b0/fae1161902898f4c913c67d7f6cdf6be0665aec3b389b9c4f4f0a101ca1da59badf1b59c4e0030f5223023b8d63cfe501c46a32c20c895d4fb3f11ca2232 +0 -0
- package/npm-cache/_cacache/index-v5/58/94/c2cba79e0f16b4c10e95a87e32255741149e8222cc314a476aab67c39cc0 +5 -0
- package/npm-cache/_update-notifier-last-checked +0 -0
- package/package.json +112 -0
- package/prisma/schema.prisma +157 -0
- package/src/cli/commands/add-module.ts +422 -0
- package/src/cli/commands/db.ts +137 -0
- package/src/cli/commands/docs.ts +16 -0
- package/src/cli/commands/generate.ts +459 -0
- package/src/cli/commands/init.ts +640 -0
- package/src/cli/index.ts +32 -0
- package/src/cli/templates/controller.ts +67 -0
- package/src/cli/templates/dynamic-prisma.ts +89 -0
- package/src/cli/templates/dynamic-schemas.ts +232 -0
- package/src/cli/templates/dynamic-types.ts +60 -0
- package/src/cli/templates/module-index.ts +33 -0
- package/src/cli/templates/prisma-model.ts +17 -0
- package/src/cli/templates/repository.ts +104 -0
- package/src/cli/templates/routes.ts +70 -0
- package/src/cli/templates/schemas.ts +26 -0
- package/src/cli/templates/service.ts +58 -0
- package/src/cli/templates/types.ts +27 -0
- package/src/cli/utils/docs-generator.ts +47 -0
- package/src/cli/utils/field-parser.ts +315 -0
- package/src/cli/utils/helpers.ts +89 -0
- package/src/config/env.ts +80 -0
- package/src/config/index.ts +97 -0
- package/src/core/index.ts +5 -0
- package/src/core/logger.ts +43 -0
- package/src/core/server.ts +132 -0
- package/src/database/index.ts +7 -0
- package/src/database/prisma.ts +54 -0
- package/src/database/seed.ts +59 -0
- package/src/index.ts +63 -0
- package/src/middleware/error-handler.ts +73 -0
- package/src/middleware/index.ts +3 -0
- package/src/middleware/security.ts +116 -0
- package/src/modules/audit/audit.service.ts +192 -0
- package/src/modules/audit/index.ts +2 -0
- package/src/modules/audit/types.ts +37 -0
- package/src/modules/auth/auth.controller.ts +182 -0
- package/src/modules/auth/auth.middleware.ts +87 -0
- package/src/modules/auth/auth.routes.ts +123 -0
- package/src/modules/auth/auth.service.ts +142 -0
- package/src/modules/auth/index.ts +49 -0
- package/src/modules/auth/schemas.ts +52 -0
- package/src/modules/auth/types.ts +69 -0
- package/src/modules/email/email.service.ts +212 -0
- package/src/modules/email/index.ts +10 -0
- package/src/modules/email/templates.ts +213 -0
- package/src/modules/email/types.ts +57 -0
- package/src/modules/swagger/index.ts +3 -0
- package/src/modules/swagger/schema-builder.ts +263 -0
- package/src/modules/swagger/swagger.service.ts +169 -0
- package/src/modules/swagger/types.ts +68 -0
- package/src/modules/user/index.ts +30 -0
- package/src/modules/user/schemas.ts +49 -0
- package/src/modules/user/types.ts +78 -0
- package/src/modules/user/user.controller.ts +139 -0
- package/src/modules/user/user.repository.ts +156 -0
- package/src/modules/user/user.routes.ts +199 -0
- package/src/modules/user/user.service.ts +145 -0
- package/src/modules/validation/index.ts +18 -0
- package/src/modules/validation/validator.ts +104 -0
- package/src/types/common.ts +61 -0
- package/src/types/index.ts +10 -0
- package/src/utils/errors.ts +66 -0
- package/src/utils/index.ts +33 -0
- package/src/utils/pagination.ts +38 -0
- package/src/utils/response.ts +63 -0
- package/tests/integration/auth.test.ts +59 -0
- package/tests/setup.ts +17 -0
- package/tests/unit/modules/validation.test.ts +88 -0
- package/tests/unit/utils/errors.test.ts +113 -0
- package/tests/unit/utils/pagination.test.ts +82 -0
- package/tsconfig.json +33 -0
- package/tsup.config.ts +14 -0
- package/vitest.config.ts +34 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import type { UserController } from './user.controller.js';
|
|
3
|
+
import type { AuthService } from '../auth/auth.service.js';
|
|
4
|
+
import { createAuthMiddleware, createRoleMiddleware } from '../auth/auth.middleware.js';
|
|
5
|
+
import { commonResponses, paginationQuery, idParam } from '../swagger/index.js';
|
|
6
|
+
|
|
7
|
+
const userTag = 'Users';
|
|
8
|
+
const userResponse = {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
success: { type: 'boolean', example: true },
|
|
12
|
+
data: { type: 'object' },
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function registerUserRoutes(
|
|
17
|
+
app: FastifyInstance,
|
|
18
|
+
controller: UserController,
|
|
19
|
+
authService: AuthService
|
|
20
|
+
): void {
|
|
21
|
+
const authenticate = createAuthMiddleware(authService);
|
|
22
|
+
const isAdmin = createRoleMiddleware(['admin', 'super_admin']);
|
|
23
|
+
const isModerator = createRoleMiddleware(['moderator', 'admin', 'super_admin']);
|
|
24
|
+
|
|
25
|
+
// Profile routes (authenticated users)
|
|
26
|
+
app.get(
|
|
27
|
+
'/profile',
|
|
28
|
+
{
|
|
29
|
+
preHandler: [authenticate],
|
|
30
|
+
schema: {
|
|
31
|
+
tags: [userTag],
|
|
32
|
+
summary: 'Get current user profile',
|
|
33
|
+
security: [{ bearerAuth: [] }],
|
|
34
|
+
response: {
|
|
35
|
+
200: userResponse,
|
|
36
|
+
401: commonResponses.unauthorized,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
controller.getProfile.bind(controller)
|
|
41
|
+
);
|
|
42
|
+
app.patch(
|
|
43
|
+
'/profile',
|
|
44
|
+
{
|
|
45
|
+
preHandler: [authenticate],
|
|
46
|
+
schema: {
|
|
47
|
+
tags: [userTag],
|
|
48
|
+
summary: 'Update current user profile',
|
|
49
|
+
security: [{ bearerAuth: [] }],
|
|
50
|
+
body: { type: 'object' },
|
|
51
|
+
response: {
|
|
52
|
+
200: userResponse,
|
|
53
|
+
401: commonResponses.unauthorized,
|
|
54
|
+
400: commonResponses.error,
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
controller.updateProfile.bind(controller)
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Admin routes
|
|
62
|
+
app.get(
|
|
63
|
+
'/users',
|
|
64
|
+
{
|
|
65
|
+
preHandler: [authenticate, isModerator],
|
|
66
|
+
schema: {
|
|
67
|
+
tags: [userTag],
|
|
68
|
+
summary: 'List users',
|
|
69
|
+
security: [{ bearerAuth: [] }],
|
|
70
|
+
querystring: {
|
|
71
|
+
...paginationQuery,
|
|
72
|
+
properties: {
|
|
73
|
+
...paginationQuery.properties,
|
|
74
|
+
status: { type: 'string', enum: ['active', 'inactive', 'suspended', 'banned'] },
|
|
75
|
+
role: { type: 'string', enum: ['user', 'admin', 'moderator', 'super_admin'] },
|
|
76
|
+
search: { type: 'string' },
|
|
77
|
+
emailVerified: { type: 'boolean' },
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
response: {
|
|
81
|
+
200: commonResponses.paginated,
|
|
82
|
+
401: commonResponses.unauthorized,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
controller.list.bind(controller)
|
|
87
|
+
);
|
|
88
|
+
app.get(
|
|
89
|
+
'/users/:id',
|
|
90
|
+
{
|
|
91
|
+
preHandler: [authenticate, isModerator],
|
|
92
|
+
schema: {
|
|
93
|
+
tags: [userTag],
|
|
94
|
+
summary: 'Get user by id',
|
|
95
|
+
security: [{ bearerAuth: [] }],
|
|
96
|
+
params: idParam,
|
|
97
|
+
response: {
|
|
98
|
+
200: userResponse,
|
|
99
|
+
401: commonResponses.unauthorized,
|
|
100
|
+
404: commonResponses.notFound,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
controller.getById.bind(controller)
|
|
105
|
+
);
|
|
106
|
+
app.patch(
|
|
107
|
+
'/users/:id',
|
|
108
|
+
{
|
|
109
|
+
preHandler: [authenticate, isAdmin],
|
|
110
|
+
schema: {
|
|
111
|
+
tags: [userTag],
|
|
112
|
+
summary: 'Update user',
|
|
113
|
+
security: [{ bearerAuth: [] }],
|
|
114
|
+
params: idParam,
|
|
115
|
+
body: { type: 'object' },
|
|
116
|
+
response: {
|
|
117
|
+
200: userResponse,
|
|
118
|
+
401: commonResponses.unauthorized,
|
|
119
|
+
404: commonResponses.notFound,
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
controller.update.bind(controller)
|
|
124
|
+
);
|
|
125
|
+
app.delete(
|
|
126
|
+
'/users/:id',
|
|
127
|
+
{
|
|
128
|
+
preHandler: [authenticate, isAdmin],
|
|
129
|
+
schema: {
|
|
130
|
+
tags: [userTag],
|
|
131
|
+
summary: 'Delete user',
|
|
132
|
+
security: [{ bearerAuth: [] }],
|
|
133
|
+
params: idParam,
|
|
134
|
+
response: {
|
|
135
|
+
204: { description: 'User deleted' },
|
|
136
|
+
401: commonResponses.unauthorized,
|
|
137
|
+
404: commonResponses.notFound,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
controller.delete.bind(controller)
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// User status management
|
|
145
|
+
app.post(
|
|
146
|
+
'/users/:id/suspend',
|
|
147
|
+
{
|
|
148
|
+
preHandler: [authenticate, isAdmin],
|
|
149
|
+
schema: {
|
|
150
|
+
tags: [userTag],
|
|
151
|
+
summary: 'Suspend user',
|
|
152
|
+
security: [{ bearerAuth: [] }],
|
|
153
|
+
params: idParam,
|
|
154
|
+
response: {
|
|
155
|
+
200: userResponse,
|
|
156
|
+
401: commonResponses.unauthorized,
|
|
157
|
+
404: commonResponses.notFound,
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
controller.suspend.bind(controller)
|
|
162
|
+
);
|
|
163
|
+
app.post(
|
|
164
|
+
'/users/:id/ban',
|
|
165
|
+
{
|
|
166
|
+
preHandler: [authenticate, isAdmin],
|
|
167
|
+
schema: {
|
|
168
|
+
tags: [userTag],
|
|
169
|
+
summary: 'Ban user',
|
|
170
|
+
security: [{ bearerAuth: [] }],
|
|
171
|
+
params: idParam,
|
|
172
|
+
response: {
|
|
173
|
+
200: userResponse,
|
|
174
|
+
401: commonResponses.unauthorized,
|
|
175
|
+
404: commonResponses.notFound,
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
controller.ban.bind(controller)
|
|
180
|
+
);
|
|
181
|
+
app.post(
|
|
182
|
+
'/users/:id/activate',
|
|
183
|
+
{
|
|
184
|
+
preHandler: [authenticate, isAdmin],
|
|
185
|
+
schema: {
|
|
186
|
+
tags: [userTag],
|
|
187
|
+
summary: 'Activate user',
|
|
188
|
+
security: [{ bearerAuth: [] }],
|
|
189
|
+
params: idParam,
|
|
190
|
+
response: {
|
|
191
|
+
200: userResponse,
|
|
192
|
+
401: commonResponses.unauthorized,
|
|
193
|
+
404: commonResponses.notFound,
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
controller.activate.bind(controller)
|
|
198
|
+
);
|
|
199
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { PaginatedResult, PaginationParams } from '../../types/index.js';
|
|
2
|
+
import { NotFoundError, ConflictError } from '../../utils/errors.js';
|
|
3
|
+
import { UserRepository, createUserRepository } from './user.repository.js';
|
|
4
|
+
import type { User, CreateUserData, UpdateUserData, UserFilters, UserRole } from './types.js';
|
|
5
|
+
import { DEFAULT_ROLE_PERMISSIONS } from './types.js';
|
|
6
|
+
import { logger } from '../../core/logger.js';
|
|
7
|
+
|
|
8
|
+
export class UserService {
|
|
9
|
+
constructor(private repository: UserRepository) {}
|
|
10
|
+
|
|
11
|
+
async findById(id: string): Promise<User | null> {
|
|
12
|
+
return this.repository.findById(id);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async findByEmail(email: string): Promise<User | null> {
|
|
16
|
+
return this.repository.findByEmail(email);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async findMany(
|
|
20
|
+
params: PaginationParams,
|
|
21
|
+
filters?: UserFilters
|
|
22
|
+
): Promise<PaginatedResult<Omit<User, 'password'>>> {
|
|
23
|
+
const result = await this.repository.findMany(params, filters);
|
|
24
|
+
|
|
25
|
+
// Remove passwords from results
|
|
26
|
+
return {
|
|
27
|
+
...result,
|
|
28
|
+
data: result.data.map(({ password, ...user }) => user) as Omit<User, 'password'>[],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async create(data: CreateUserData): Promise<User> {
|
|
33
|
+
// Check for existing user
|
|
34
|
+
const existing = await this.repository.findByEmail(data.email);
|
|
35
|
+
if (existing) {
|
|
36
|
+
throw new ConflictError('User with this email already exists');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const user = await this.repository.create(data);
|
|
40
|
+
logger.info({ userId: user.id, email: user.email }, 'User created');
|
|
41
|
+
return user;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async update(id: string, data: UpdateUserData): Promise<User> {
|
|
45
|
+
const user = await this.repository.findById(id);
|
|
46
|
+
if (!user) {
|
|
47
|
+
throw new NotFoundError('User');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Check email uniqueness if changing email
|
|
51
|
+
if (data.email && data.email !== user.email) {
|
|
52
|
+
const existing = await this.repository.findByEmail(data.email);
|
|
53
|
+
if (existing) {
|
|
54
|
+
throw new ConflictError('Email already in use');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const updatedUser = await this.repository.update(id, data);
|
|
59
|
+
if (!updatedUser) {
|
|
60
|
+
throw new NotFoundError('User');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
logger.info({ userId: id }, 'User updated');
|
|
64
|
+
return updatedUser;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async updatePassword(id: string, hashedPassword: string): Promise<User> {
|
|
68
|
+
const user = await this.repository.updatePassword(id, hashedPassword);
|
|
69
|
+
if (!user) {
|
|
70
|
+
throw new NotFoundError('User');
|
|
71
|
+
}
|
|
72
|
+
logger.info({ userId: id }, 'User password updated');
|
|
73
|
+
return user;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async updateLastLogin(id: string): Promise<User> {
|
|
77
|
+
const user = await this.repository.updateLastLogin(id);
|
|
78
|
+
if (!user) {
|
|
79
|
+
throw new NotFoundError('User');
|
|
80
|
+
}
|
|
81
|
+
return user;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async delete(id: string): Promise<void> {
|
|
85
|
+
const user = await this.repository.findById(id);
|
|
86
|
+
if (!user) {
|
|
87
|
+
throw new NotFoundError('User');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
await this.repository.delete(id);
|
|
91
|
+
logger.info({ userId: id }, 'User deleted');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async suspend(id: string): Promise<User> {
|
|
95
|
+
return this.update(id, { status: 'suspended' });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async ban(id: string): Promise<User> {
|
|
99
|
+
return this.update(id, { status: 'banned' });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async activate(id: string): Promise<User> {
|
|
103
|
+
return this.update(id, { status: 'active' });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async verifyEmail(id: string): Promise<User> {
|
|
107
|
+
return this.update(id, { emailVerified: true });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async changeRole(id: string, role: UserRole): Promise<User> {
|
|
111
|
+
return this.update(id, { role });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// RBAC helpers
|
|
115
|
+
hasPermission(role: UserRole, permission: string): boolean {
|
|
116
|
+
const permissions = DEFAULT_ROLE_PERMISSIONS[role] || [];
|
|
117
|
+
|
|
118
|
+
// Super admin has all permissions
|
|
119
|
+
if (permissions.includes('*:manage')) {
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Check exact match
|
|
124
|
+
if (permissions.includes(permission)) {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Check wildcard match (e.g., "content:manage" matches "content:read")
|
|
129
|
+
const [resource, action] = permission.split(':');
|
|
130
|
+
const managePermission = `${resource}:manage`;
|
|
131
|
+
if (permissions.includes(managePermission)) {
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
getPermissions(role: UserRole): string[] {
|
|
139
|
+
return DEFAULT_ROLE_PERMISSIONS[role] || [];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function createUserService(repository?: UserRepository): UserService {
|
|
144
|
+
return new UserService(repository || createUserRepository());
|
|
145
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export {
|
|
2
|
+
validate,
|
|
3
|
+
validateBody,
|
|
4
|
+
validateQuery,
|
|
5
|
+
validateParams,
|
|
6
|
+
idParamSchema,
|
|
7
|
+
paginationSchema,
|
|
8
|
+
searchSchema,
|
|
9
|
+
emailSchema,
|
|
10
|
+
passwordSchema,
|
|
11
|
+
urlSchema,
|
|
12
|
+
phoneSchema,
|
|
13
|
+
dateSchema,
|
|
14
|
+
futureDateSchema,
|
|
15
|
+
pastDateSchema,
|
|
16
|
+
} from './validator.js';
|
|
17
|
+
|
|
18
|
+
export type { IdParam, PaginationInput } from './validator.js';
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ZodError, ZodTypeAny } from 'zod';
|
|
3
|
+
import { ValidationError } from '../../utils/errors.js';
|
|
4
|
+
|
|
5
|
+
export function validateBody<T extends ZodTypeAny>(schema: T, data: unknown): z.infer<T> {
|
|
6
|
+
const result = schema.safeParse(data);
|
|
7
|
+
|
|
8
|
+
if (!result.success) {
|
|
9
|
+
throw new ValidationError(formatZodErrors(result.error));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return result.data;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function validateQuery<T extends ZodTypeAny>(schema: T, data: unknown): z.infer<T> {
|
|
16
|
+
const result = schema.safeParse(data);
|
|
17
|
+
|
|
18
|
+
if (!result.success) {
|
|
19
|
+
throw new ValidationError(formatZodErrors(result.error));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return result.data;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function validateParams<T extends ZodTypeAny>(schema: T, data: unknown): z.infer<T> {
|
|
26
|
+
const result = schema.safeParse(data);
|
|
27
|
+
|
|
28
|
+
if (!result.success) {
|
|
29
|
+
throw new ValidationError(formatZodErrors(result.error));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return result.data;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function validate<T extends ZodTypeAny>(schema: T, data: unknown): z.infer<T> {
|
|
36
|
+
return validateBody(schema, data);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function formatZodErrors(error: ZodError): Record<string, string[]> {
|
|
40
|
+
const errors: Record<string, string[]> = {};
|
|
41
|
+
|
|
42
|
+
for (const issue of error.issues) {
|
|
43
|
+
const path = issue.path.join('.') || 'root';
|
|
44
|
+
if (!errors[path]) {
|
|
45
|
+
errors[path] = [];
|
|
46
|
+
}
|
|
47
|
+
errors[path].push(issue.message);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return errors;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Common validation schemas
|
|
54
|
+
export const idParamSchema = z.object({
|
|
55
|
+
id: z.string().uuid('Invalid ID format'),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export const paginationSchema = z.object({
|
|
59
|
+
page: z.string().transform(Number).optional().default('1'),
|
|
60
|
+
limit: z.string().transform(Number).optional().default('20'),
|
|
61
|
+
sortBy: z.string().optional(),
|
|
62
|
+
sortOrder: z.enum(['asc', 'desc']).optional().default('asc'),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export const searchSchema = z.object({
|
|
66
|
+
q: z.string().min(1, 'Search query is required').optional(),
|
|
67
|
+
search: z.string().min(1).optional(),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Email validation
|
|
71
|
+
export const emailSchema = z.string().email('Invalid email address');
|
|
72
|
+
|
|
73
|
+
// Password validation with strength requirements
|
|
74
|
+
export const passwordSchema = z
|
|
75
|
+
.string()
|
|
76
|
+
.min(8, 'Password must be at least 8 characters')
|
|
77
|
+
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
|
78
|
+
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
|
79
|
+
.regex(/[0-9]/, 'Password must contain at least one number')
|
|
80
|
+
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character');
|
|
81
|
+
|
|
82
|
+
// URL validation
|
|
83
|
+
export const urlSchema = z.string().url('Invalid URL format');
|
|
84
|
+
|
|
85
|
+
// Phone validation (basic international format)
|
|
86
|
+
export const phoneSchema = z.string().regex(
|
|
87
|
+
/^\+?[1-9]\d{1,14}$/,
|
|
88
|
+
'Invalid phone number format'
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Date validation
|
|
92
|
+
export const dateSchema = z.coerce.date();
|
|
93
|
+
export const futureDateSchema = z.coerce.date().refine(
|
|
94
|
+
(date) => date > new Date(),
|
|
95
|
+
'Date must be in the future'
|
|
96
|
+
);
|
|
97
|
+
export const pastDateSchema = z.coerce.date().refine(
|
|
98
|
+
(date) => date < new Date(),
|
|
99
|
+
'Date must be in the past'
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Type exports
|
|
103
|
+
export type IdParam = z.infer<typeof idParamSchema>;
|
|
104
|
+
export type PaginationInput = z.infer<typeof paginationSchema>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { FastifyRequest, FastifyReply } from 'fastify';
|
|
2
|
+
|
|
3
|
+
// Base entity with common fields
|
|
4
|
+
export interface BaseEntity {
|
|
5
|
+
id: string;
|
|
6
|
+
createdAt: Date;
|
|
7
|
+
updatedAt: Date;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Pagination
|
|
11
|
+
export interface PaginationParams {
|
|
12
|
+
page: number;
|
|
13
|
+
limit: number;
|
|
14
|
+
sortBy?: string;
|
|
15
|
+
sortOrder?: 'asc' | 'desc';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PaginatedResult<T> {
|
|
19
|
+
data: T[];
|
|
20
|
+
meta: {
|
|
21
|
+
total: number;
|
|
22
|
+
page: number;
|
|
23
|
+
limit: number;
|
|
24
|
+
totalPages: number;
|
|
25
|
+
hasNextPage: boolean;
|
|
26
|
+
hasPrevPage: boolean;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// API Response
|
|
31
|
+
export interface ApiResponse<T = unknown> {
|
|
32
|
+
success: boolean;
|
|
33
|
+
data?: T;
|
|
34
|
+
message?: string;
|
|
35
|
+
errors?: Record<string, string[]>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Service Result
|
|
39
|
+
export type ServiceResult<T, E = Error> =
|
|
40
|
+
| { success: true; data: T }
|
|
41
|
+
| { success: false; error: E; message: string };
|
|
42
|
+
|
|
43
|
+
// Repository interface
|
|
44
|
+
export interface Repository<T extends BaseEntity> {
|
|
45
|
+
findById(id: string): Promise<T | null>;
|
|
46
|
+
findMany(params?: PaginationParams): Promise<PaginatedResult<T>>;
|
|
47
|
+
create(data: Omit<T, keyof BaseEntity>): Promise<T>;
|
|
48
|
+
update(id: string, data: Partial<Omit<T, keyof BaseEntity>>): Promise<T | null>;
|
|
49
|
+
delete(id: string): Promise<boolean>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Controller handler type
|
|
53
|
+
export type ControllerHandler = (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
|
54
|
+
|
|
55
|
+
// Module interface
|
|
56
|
+
export interface Module {
|
|
57
|
+
name: string;
|
|
58
|
+
register(app: FastifyInstance): Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
import type { FastifyInstance } from 'fastify';
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export class AppError extends Error {
|
|
2
|
+
public readonly statusCode: number;
|
|
3
|
+
public readonly isOperational: boolean;
|
|
4
|
+
public readonly errors?: Record<string, string[]>;
|
|
5
|
+
|
|
6
|
+
constructor(
|
|
7
|
+
message: string,
|
|
8
|
+
statusCode = 500,
|
|
9
|
+
isOperational = true,
|
|
10
|
+
errors?: Record<string, string[]>
|
|
11
|
+
) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.statusCode = statusCode;
|
|
14
|
+
this.isOperational = isOperational;
|
|
15
|
+
this.errors = errors;
|
|
16
|
+
|
|
17
|
+
Object.setPrototypeOf(this, AppError.prototype);
|
|
18
|
+
Error.captureStackTrace(this, this.constructor);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class NotFoundError extends AppError {
|
|
23
|
+
constructor(resource = 'Resource') {
|
|
24
|
+
super(`${resource} not found`, 404);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class UnauthorizedError extends AppError {
|
|
29
|
+
constructor(message = 'Unauthorized') {
|
|
30
|
+
super(message, 401);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class ForbiddenError extends AppError {
|
|
35
|
+
constructor(message = 'Forbidden') {
|
|
36
|
+
super(message, 403);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class BadRequestError extends AppError {
|
|
41
|
+
constructor(message = 'Bad request', errors?: Record<string, string[]>) {
|
|
42
|
+
super(message, 400, true, errors);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class ConflictError extends AppError {
|
|
47
|
+
constructor(message = 'Resource already exists') {
|
|
48
|
+
super(message, 409);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class ValidationError extends AppError {
|
|
53
|
+
constructor(errors: Record<string, string[]>) {
|
|
54
|
+
super('Validation failed', 422, true, errors);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class TooManyRequestsError extends AppError {
|
|
59
|
+
constructor(message = 'Too many requests') {
|
|
60
|
+
super(message, 429);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isAppError(error: unknown): error is AppError {
|
|
65
|
+
return error instanceof AppError;
|
|
66
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export {
|
|
2
|
+
success,
|
|
3
|
+
created,
|
|
4
|
+
noContent,
|
|
5
|
+
error,
|
|
6
|
+
notFound,
|
|
7
|
+
unauthorized,
|
|
8
|
+
forbidden,
|
|
9
|
+
badRequest,
|
|
10
|
+
conflict,
|
|
11
|
+
internalError,
|
|
12
|
+
} from './response.js';
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
parsePaginationParams,
|
|
16
|
+
createPaginatedResult,
|
|
17
|
+
getSkip,
|
|
18
|
+
DEFAULT_PAGE,
|
|
19
|
+
DEFAULT_LIMIT,
|
|
20
|
+
MAX_LIMIT,
|
|
21
|
+
} from './pagination.js';
|
|
22
|
+
|
|
23
|
+
export {
|
|
24
|
+
AppError,
|
|
25
|
+
NotFoundError,
|
|
26
|
+
UnauthorizedError,
|
|
27
|
+
ForbiddenError,
|
|
28
|
+
BadRequestError,
|
|
29
|
+
ConflictError,
|
|
30
|
+
ValidationError,
|
|
31
|
+
TooManyRequestsError,
|
|
32
|
+
isAppError,
|
|
33
|
+
} from './errors.js';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { PaginationParams, PaginatedResult } from '../types/index.js';
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_PAGE = 1;
|
|
4
|
+
export const DEFAULT_LIMIT = 20;
|
|
5
|
+
export const MAX_LIMIT = 100;
|
|
6
|
+
|
|
7
|
+
export function parsePaginationParams(query: Record<string, unknown>): PaginationParams {
|
|
8
|
+
const page = Math.max(1, parseInt(String(query.page || DEFAULT_PAGE), 10));
|
|
9
|
+
const limit = Math.min(MAX_LIMIT, Math.max(1, parseInt(String(query.limit || DEFAULT_LIMIT), 10)));
|
|
10
|
+
const sortBy = typeof query.sortBy === 'string' ? query.sortBy : undefined;
|
|
11
|
+
const sortOrder = query.sortOrder === 'desc' ? 'desc' : 'asc';
|
|
12
|
+
|
|
13
|
+
return { page, limit, sortBy, sortOrder };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createPaginatedResult<T>(
|
|
17
|
+
data: T[],
|
|
18
|
+
total: number,
|
|
19
|
+
params: PaginationParams
|
|
20
|
+
): PaginatedResult<T> {
|
|
21
|
+
const totalPages = Math.ceil(total / params.limit);
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
data,
|
|
25
|
+
meta: {
|
|
26
|
+
total,
|
|
27
|
+
page: params.page,
|
|
28
|
+
limit: params.limit,
|
|
29
|
+
totalPages,
|
|
30
|
+
hasNextPage: params.page < totalPages,
|
|
31
|
+
hasPrevPage: params.page > 1,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getSkip(params: PaginationParams): number {
|
|
37
|
+
return (params.page - 1) * params.limit;
|
|
38
|
+
}
|