create-tigra 1.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/LICENSE +21 -0
- package/README.md +87 -0
- package/bin/create-tigra.js +292 -0
- package/package.json +41 -0
- package/template/.agent/rules/client/01-project-structure.md +326 -0
- package/template/.agent/rules/client/02-component-patterns.md +249 -0
- package/template/.agent/rules/client/03-typescript-rules.md +226 -0
- package/template/.agent/rules/client/04-state-management.md +474 -0
- package/template/.agent/rules/client/05-api-integration.md +129 -0
- package/template/.agent/rules/client/06-forms-validation.md +129 -0
- package/template/.agent/rules/client/07-common-patterns.md +150 -0
- package/template/.agent/rules/client/08-color-system.md +93 -0
- package/template/.agent/rules/client/09-security-rules.md +97 -0
- package/template/.agent/rules/client/10-testing-strategy.md +370 -0
- package/template/.agent/rules/global/ai-edit-safety.md +38 -0
- package/template/.agent/rules/server/01-db-and-migrations.md +242 -0
- package/template/.agent/rules/server/02-general-rules.md +111 -0
- package/template/.agent/rules/server/03-migrations.md +20 -0
- package/template/.agent/rules/server/04-pagination.md +130 -0
- package/template/.agent/rules/server/05-project-conventions.md +71 -0
- package/template/.agent/rules/server/06-response-handling.md +173 -0
- package/template/.agent/rules/server/07-testing-strategy.md +506 -0
- package/template/.agent/rules/server/08-observability.md +180 -0
- package/template/.agent/rules/server/09-api-documentation-v2.md +168 -0
- package/template/.agent/rules/server/10-background-jobs-v2.md +185 -0
- package/template/.agent/rules/server/11-rate-limiting-v2.md +210 -0
- package/template/.agent/rules/server/12-performance-optimization.md +567 -0
- package/template/.claude/rules/client-01-project-structure.md +327 -0
- package/template/.claude/rules/client-02-component-patterns.md +250 -0
- package/template/.claude/rules/client-03-typescript-rules.md +227 -0
- package/template/.claude/rules/client-04-state-management.md +475 -0
- package/template/.claude/rules/client-05-api-integration.md +130 -0
- package/template/.claude/rules/client-06-forms-validation.md +130 -0
- package/template/.claude/rules/client-07-common-patterns.md +151 -0
- package/template/.claude/rules/client-08-color-system.md +94 -0
- package/template/.claude/rules/client-09-security-rules.md +98 -0
- package/template/.claude/rules/client-10-testing-strategy.md +371 -0
- package/template/.claude/rules/global-ai-edit-safety.md +39 -0
- package/template/.claude/rules/server-01-db-and-migrations.md +243 -0
- package/template/.claude/rules/server-02-general-rules.md +112 -0
- package/template/.claude/rules/server-03-migrations.md +21 -0
- package/template/.claude/rules/server-04-pagination.md +131 -0
- package/template/.claude/rules/server-05-project-conventions.md +72 -0
- package/template/.claude/rules/server-06-response-handling.md +174 -0
- package/template/.claude/rules/server-07-testing-strategy.md +507 -0
- package/template/.claude/rules/server-08-observability.md +181 -0
- package/template/.claude/rules/server-09-api-documentation-v2.md +169 -0
- package/template/.claude/rules/server-10-background-jobs-v2.md +186 -0
- package/template/.claude/rules/server-11-rate-limiting-v2.md +211 -0
- package/template/.claude/rules/server-12-performance-optimization.md +568 -0
- package/template/.cursor/rules/client-01-project-structure.mdc +327 -0
- package/template/.cursor/rules/client-02-component-patterns.mdc +250 -0
- package/template/.cursor/rules/client-03-typescript-rules.mdc +227 -0
- package/template/.cursor/rules/client-04-state-management.mdc +475 -0
- package/template/.cursor/rules/client-05-api-integration.mdc +130 -0
- package/template/.cursor/rules/client-06-forms-validation.mdc +130 -0
- package/template/.cursor/rules/client-07-common-patterns.mdc +151 -0
- package/template/.cursor/rules/client-08-color-system.mdc +94 -0
- package/template/.cursor/rules/client-09-security-rules.mdc +98 -0
- package/template/.cursor/rules/client-10-testing-strategy.mdc +371 -0
- package/template/.cursor/rules/global-ai-edit-safety.mdc +39 -0
- package/template/.cursor/rules/server-01-db-and-migrations.mdc +243 -0
- package/template/.cursor/rules/server-02-general-rules.mdc +112 -0
- package/template/.cursor/rules/server-03-migrations.mdc +21 -0
- package/template/.cursor/rules/server-04-pagination.mdc +131 -0
- package/template/.cursor/rules/server-05-project-conventions.mdc +72 -0
- package/template/.cursor/rules/server-06-response-handling.mdc +174 -0
- package/template/.cursor/rules/server-07-testing-strategy.mdc +507 -0
- package/template/.cursor/rules/server-08-observability.mdc +181 -0
- package/template/.cursor/rules/server-09-api-documentation-v2.mdc +169 -0
- package/template/.cursor/rules/server-10-background-jobs-v2.mdc +186 -0
- package/template/.cursor/rules/server-11-rate-limiting-v2.mdc +211 -0
- package/template/.cursor/rules/server-12-performance-optimization.mdc +568 -0
- package/template/CLAUDE.md +207 -0
- package/template/server/.env.example +148 -0
- package/template/server/.tsc-aliasrc.json +12 -0
- package/template/server/README.md +175 -0
- package/template/server/SECURITY.md +190 -0
- package/template/server/biome.json +42 -0
- package/template/server/docker-compose.yml +111 -0
- package/template/server/package.json +83 -0
- package/template/server/postman_collection.json +733 -0
- package/template/server/prisma/schema.prisma +92 -0
- package/template/server/prisma/seed.ts +142 -0
- package/template/server/scripts/wait-for-db.js +60 -0
- package/template/server/src/app.ts +74 -0
- package/template/server/src/config/env.ts +101 -0
- package/template/server/src/hooks/request-timing.hook.ts +26 -0
- package/template/server/src/libs/auth/authenticate.middleware.ts +22 -0
- package/template/server/src/libs/auth/rbac.middleware.test.ts +134 -0
- package/template/server/src/libs/auth/rbac.middleware.ts +147 -0
- package/template/server/src/libs/db.ts +76 -0
- package/template/server/src/libs/error-handler.ts +89 -0
- package/template/server/src/libs/logger.ts +60 -0
- package/template/server/src/libs/queue.ts +79 -0
- package/template/server/src/libs/redis.ts +79 -0
- package/template/server/src/libs/swagger-schemas.ts +16 -0
- package/template/server/src/modules/admin/admin.controller.ts +122 -0
- package/template/server/src/modules/admin/admin.routes.ts +100 -0
- package/template/server/src/modules/admin/admin.schemas.ts +35 -0
- package/template/server/src/modules/admin/admin.service.ts +167 -0
- package/template/server/src/modules/auth/auth.controller.ts +141 -0
- package/template/server/src/modules/auth/auth.integration.test.ts +150 -0
- package/template/server/src/modules/auth/auth.repo.ts +218 -0
- package/template/server/src/modules/auth/auth.routes.ts +204 -0
- package/template/server/src/modules/auth/auth.schemas.ts +137 -0
- package/template/server/src/modules/auth/auth.service.test.ts +119 -0
- package/template/server/src/modules/auth/auth.service.ts +329 -0
- package/template/server/src/modules/auth/auth.types.ts +97 -0
- package/template/server/src/modules/resources/resources.controller.ts +218 -0
- package/template/server/src/modules/resources/resources.repo.ts +253 -0
- package/template/server/src/modules/resources/resources.routes.ts +355 -0
- package/template/server/src/modules/resources/resources.schemas.ts +146 -0
- package/template/server/src/modules/resources/resources.service.ts +218 -0
- package/template/server/src/modules/resources/resources.types.ts +73 -0
- package/template/server/src/plugins/rate-limit.plugin.ts +21 -0
- package/template/server/src/plugins/security.plugin.ts +21 -0
- package/template/server/src/plugins/swagger.plugin.ts +41 -0
- package/template/server/src/routes/health.routes.ts +31 -0
- package/template/server/src/server.ts +142 -0
- package/template/server/src/test/setup.ts +38 -0
- package/template/server/src/types/fastify.d.ts +36 -0
- package/template/server/src/utils/errors.ts +108 -0
- package/template/server/src/utils/pagination.ts +120 -0
- package/template/server/src/utils/response.ts +110 -0
- package/template/server/src/workers/file.worker.ts +106 -0
- package/template/server/tsconfig.build.json +30 -0
- package/template/server/tsconfig.build.tsbuildinfo +1 -0
- package/template/server/tsconfig.json +89 -0
- package/template/server/tsconfig.test.json +22 -0
- package/template/server/vitest.config.ts +98 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication Service
|
|
3
|
+
*
|
|
4
|
+
* Business logic for authentication operations.
|
|
5
|
+
* Handles registration, login, token refresh, and logout.
|
|
6
|
+
*
|
|
7
|
+
* @see /mnt/project/02-general-rules.md
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import bcrypt from 'bcryptjs';
|
|
11
|
+
import jwt from 'jsonwebtoken';
|
|
12
|
+
import { env } from '@/config/env';
|
|
13
|
+
import logger from '@/libs/logger';
|
|
14
|
+
import {
|
|
15
|
+
ConflictError,
|
|
16
|
+
UnauthorizedError,
|
|
17
|
+
} from '@/utils/errors';
|
|
18
|
+
import * as authRepo from './auth.repo';
|
|
19
|
+
import type {
|
|
20
|
+
AuthResponse,
|
|
21
|
+
TokenResponse,
|
|
22
|
+
JwtPayload,
|
|
23
|
+
JwtRefreshPayload,
|
|
24
|
+
} from './auth.types';
|
|
25
|
+
import type { RegisterInput, LoginInput } from './auth.schemas';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Password hashing rounds
|
|
29
|
+
*/
|
|
30
|
+
const BCRYPT_ROUNDS = 10;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse JWT expiration string to seconds
|
|
34
|
+
*
|
|
35
|
+
* @param expiration - Expiration string (e.g., '15m', '7d')
|
|
36
|
+
* @returns Expiration in seconds
|
|
37
|
+
*/
|
|
38
|
+
function parseExpiration(expiration: string): number {
|
|
39
|
+
const match = expiration.match(/^(\d+)([smhd])$/);
|
|
40
|
+
if (!match) {
|
|
41
|
+
throw new Error(`Invalid expiration format: ${expiration}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const value = parseInt(match[1]!);
|
|
45
|
+
const unit = match[2]!;
|
|
46
|
+
|
|
47
|
+
const multipliers: Record<string, number> = {
|
|
48
|
+
s: 1,
|
|
49
|
+
m: 60,
|
|
50
|
+
h: 3600,
|
|
51
|
+
d: 86400,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return value * multipliers[unit]!;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Generate access token
|
|
59
|
+
*
|
|
60
|
+
* @param userId - User ID
|
|
61
|
+
* @param email - User email
|
|
62
|
+
* @param role - User role
|
|
63
|
+
* @returns JWT access token
|
|
64
|
+
*/
|
|
65
|
+
function generateAccessToken(
|
|
66
|
+
userId: string,
|
|
67
|
+
email: string,
|
|
68
|
+
role: string
|
|
69
|
+
): string {
|
|
70
|
+
const payload: JwtPayload = {
|
|
71
|
+
userId,
|
|
72
|
+
email,
|
|
73
|
+
role: role as 'USER' | 'ADMIN',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return jwt.sign(payload, env.JWT_SECRET, {
|
|
77
|
+
expiresIn: env.JWT_ACCESS_EXPIRATION,
|
|
78
|
+
issuer: env.JWT_ISSUER,
|
|
79
|
+
} as jwt.SignOptions);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Generate refresh token
|
|
84
|
+
*
|
|
85
|
+
* @param userId - User ID
|
|
86
|
+
* @param sessionId - Session ID
|
|
87
|
+
* @returns JWT refresh token
|
|
88
|
+
*/
|
|
89
|
+
function generateRefreshToken(userId: string, sessionId: string): string {
|
|
90
|
+
const payload: JwtRefreshPayload = {
|
|
91
|
+
userId,
|
|
92
|
+
sessionId,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return jwt.sign(payload, env.JWT_SECRET, {
|
|
96
|
+
expiresIn: env.JWT_REFRESH_EXPIRATION,
|
|
97
|
+
issuer: env.JWT_ISSUER,
|
|
98
|
+
} as jwt.SignOptions);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Register a new user
|
|
103
|
+
*
|
|
104
|
+
* @param data - Registration data
|
|
105
|
+
* @returns User and tokens
|
|
106
|
+
* @throws ConflictError if email already exists
|
|
107
|
+
*/
|
|
108
|
+
export async function register(data: RegisterInput): Promise<AuthResponse> {
|
|
109
|
+
try {
|
|
110
|
+
// Check if user already exists
|
|
111
|
+
const existingUser = await authRepo.findUserByEmail(data.email);
|
|
112
|
+
if (existingUser) {
|
|
113
|
+
throw new ConflictError('Email already registered');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Hash password
|
|
117
|
+
const hashedPassword = await bcrypt.hash(data.password, BCRYPT_ROUNDS);
|
|
118
|
+
|
|
119
|
+
// Create user
|
|
120
|
+
const user = await authRepo.createUser({
|
|
121
|
+
email: data.email,
|
|
122
|
+
password: hashedPassword,
|
|
123
|
+
name: data.name,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Generate tokens
|
|
127
|
+
const accessToken = generateAccessToken(user.id, user.email, user.role);
|
|
128
|
+
const sessionId = `session_${Date.now()}_${user.id}`;
|
|
129
|
+
const refreshToken = generateRefreshToken(user.id, sessionId);
|
|
130
|
+
|
|
131
|
+
// Calculate refresh token expiration
|
|
132
|
+
const expiresInSeconds = parseExpiration(env.JWT_REFRESH_EXPIRATION);
|
|
133
|
+
const expiresAt = new Date(Date.now() + expiresInSeconds * 1000);
|
|
134
|
+
|
|
135
|
+
// Create session
|
|
136
|
+
await authRepo.createSession({
|
|
137
|
+
userId: user.id,
|
|
138
|
+
refreshToken,
|
|
139
|
+
expiresAt,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
logger.info({ userId: user.id, email: user.email }, 'User registered');
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
user,
|
|
146
|
+
tokens: {
|
|
147
|
+
accessToken,
|
|
148
|
+
refreshToken,
|
|
149
|
+
expiresIn: parseExpiration(env.JWT_ACCESS_EXPIRATION),
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
} catch (error) {
|
|
153
|
+
logger.error({ error, email: data.email }, 'Registration failed');
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Login user
|
|
160
|
+
*
|
|
161
|
+
* @param data - Login credentials
|
|
162
|
+
* @returns User and tokens
|
|
163
|
+
* @throws UnauthorizedError if credentials are invalid
|
|
164
|
+
*/
|
|
165
|
+
export async function login(data: LoginInput): Promise<AuthResponse> {
|
|
166
|
+
try {
|
|
167
|
+
// Find user with password
|
|
168
|
+
const userWithPassword = await authRepo.findUserByEmailWithPassword(
|
|
169
|
+
data.email
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
if (!userWithPassword) {
|
|
173
|
+
throw new UnauthorizedError('Invalid email or password');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Verify password
|
|
177
|
+
const isPasswordValid = await bcrypt.compare(
|
|
178
|
+
data.password,
|
|
179
|
+
userWithPassword.password
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
if (!isPasswordValid) {
|
|
183
|
+
throw new UnauthorizedError('Invalid email or password');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Get user without password
|
|
187
|
+
const user = await authRepo.findUserById(userWithPassword.id);
|
|
188
|
+
if (!user) {
|
|
189
|
+
throw new UnauthorizedError('User not found');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Generate tokens
|
|
193
|
+
const accessToken = generateAccessToken(user.id, user.email, user.role);
|
|
194
|
+
const sessionId = `session_${Date.now()}_${user.id}`;
|
|
195
|
+
const refreshToken = generateRefreshToken(user.id, sessionId);
|
|
196
|
+
|
|
197
|
+
// Calculate refresh token expiration
|
|
198
|
+
const expiresInSeconds = parseExpiration(env.JWT_REFRESH_EXPIRATION);
|
|
199
|
+
const expiresAt = new Date(Date.now() + expiresInSeconds * 1000);
|
|
200
|
+
|
|
201
|
+
// Create session
|
|
202
|
+
await authRepo.createSession({
|
|
203
|
+
userId: user.id,
|
|
204
|
+
refreshToken,
|
|
205
|
+
expiresAt,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
logger.info({ userId: user.id, email: user.email }, 'User logged in');
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
user,
|
|
212
|
+
tokens: {
|
|
213
|
+
accessToken,
|
|
214
|
+
refreshToken,
|
|
215
|
+
expiresIn: parseExpiration(env.JWT_ACCESS_EXPIRATION),
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
} catch (error) {
|
|
219
|
+
logger.error({ error, email: data.email }, 'Login failed');
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Refresh access token
|
|
226
|
+
*
|
|
227
|
+
* @param refreshToken - Refresh token
|
|
228
|
+
* @returns New access and refresh tokens
|
|
229
|
+
* @throws UnauthorizedError if token is invalid or expired
|
|
230
|
+
*/
|
|
231
|
+
export async function refreshTokens(
|
|
232
|
+
refreshToken: string
|
|
233
|
+
): Promise<TokenResponse> {
|
|
234
|
+
try {
|
|
235
|
+
// Find session
|
|
236
|
+
const session = await authRepo.findSessionByToken(refreshToken);
|
|
237
|
+
if (!session) {
|
|
238
|
+
throw new UnauthorizedError('Invalid refresh token');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Check if session expired
|
|
242
|
+
if (session.expiresAt < new Date()) {
|
|
243
|
+
await authRepo.deleteSession(refreshToken);
|
|
244
|
+
throw new UnauthorizedError('Refresh token expired');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Verify JWT token
|
|
248
|
+
let payload: JwtRefreshPayload;
|
|
249
|
+
try {
|
|
250
|
+
payload = jwt.verify(refreshToken, env.JWT_SECRET, {
|
|
251
|
+
issuer: env.JWT_ISSUER,
|
|
252
|
+
}) as JwtRefreshPayload;
|
|
253
|
+
} catch (error) {
|
|
254
|
+
await authRepo.deleteSession(refreshToken);
|
|
255
|
+
throw new UnauthorizedError('Invalid refresh token');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Generate new tokens
|
|
259
|
+
const newAccessToken = generateAccessToken(
|
|
260
|
+
session.user.id,
|
|
261
|
+
session.user.email,
|
|
262
|
+
session.user.role
|
|
263
|
+
);
|
|
264
|
+
const sessionId = `session_${Date.now()}_${session.user.id}`;
|
|
265
|
+
const newRefreshToken = generateRefreshToken(session.user.id, sessionId);
|
|
266
|
+
|
|
267
|
+
// Calculate new expiration
|
|
268
|
+
const expiresInSeconds = parseExpiration(env.JWT_REFRESH_EXPIRATION);
|
|
269
|
+
const expiresAt = new Date(Date.now() + expiresInSeconds * 1000);
|
|
270
|
+
|
|
271
|
+
// Update session with new refresh token
|
|
272
|
+
await authRepo.updateSession(refreshToken, newRefreshToken, expiresAt);
|
|
273
|
+
|
|
274
|
+
logger.info({ userId: session.user.id }, 'Tokens refreshed');
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
accessToken: newAccessToken,
|
|
278
|
+
refreshToken: newRefreshToken,
|
|
279
|
+
expiresIn: parseExpiration(env.JWT_ACCESS_EXPIRATION),
|
|
280
|
+
};
|
|
281
|
+
} catch (error) {
|
|
282
|
+
logger.error({ error }, 'Token refresh failed');
|
|
283
|
+
throw error;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Logout user
|
|
289
|
+
*
|
|
290
|
+
* Deletes the session associated with the refresh token.
|
|
291
|
+
*
|
|
292
|
+
* @param refreshToken - Refresh token
|
|
293
|
+
*/
|
|
294
|
+
export async function logout(refreshToken: string): Promise<void> {
|
|
295
|
+
try {
|
|
296
|
+
await authRepo.deleteSession(refreshToken);
|
|
297
|
+
logger.info('User logged out');
|
|
298
|
+
} catch (error) {
|
|
299
|
+
// Ignore errors if session doesn't exist
|
|
300
|
+
logger.warn({ error }, 'Logout failed - session may not exist');
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Verify access token
|
|
306
|
+
*
|
|
307
|
+
* @param token - Access token
|
|
308
|
+
* @returns JWT payload
|
|
309
|
+
* @throws UnauthorizedError if token is invalid
|
|
310
|
+
*/
|
|
311
|
+
export async function verifyAccessToken(token: string): Promise<JwtPayload> {
|
|
312
|
+
try {
|
|
313
|
+
const payload = jwt.verify(token, env.JWT_SECRET, {
|
|
314
|
+
issuer: env.JWT_ISSUER,
|
|
315
|
+
}) as JwtPayload;
|
|
316
|
+
|
|
317
|
+
return payload;
|
|
318
|
+
} catch (error) {
|
|
319
|
+
if (error instanceof jwt.TokenExpiredError) {
|
|
320
|
+
throw new UnauthorizedError('Access token expired');
|
|
321
|
+
}
|
|
322
|
+
if (error instanceof jwt.JsonWebTokenError) {
|
|
323
|
+
throw new UnauthorizedError('Invalid access token');
|
|
324
|
+
}
|
|
325
|
+
throw new UnauthorizedError('Token verification failed');
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication Types
|
|
3
|
+
*
|
|
4
|
+
* TypeScript types and interfaces for authentication module.
|
|
5
|
+
* These types are used throughout the auth flow.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* User Role Enum
|
|
10
|
+
*/
|
|
11
|
+
export type UserRole = 'USER' | 'ADMIN';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* User object (without sensitive fields like password)
|
|
15
|
+
*
|
|
16
|
+
* This is the safe user representation returned to clients.
|
|
17
|
+
*/
|
|
18
|
+
export interface User {
|
|
19
|
+
id: string;
|
|
20
|
+
email: string;
|
|
21
|
+
name: string | null;
|
|
22
|
+
role: UserRole;
|
|
23
|
+
emailVerified: boolean;
|
|
24
|
+
createdAt: Date;
|
|
25
|
+
updatedAt: Date;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Login Request Payload
|
|
30
|
+
*/
|
|
31
|
+
export interface LoginRequest {
|
|
32
|
+
email: string;
|
|
33
|
+
password: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Register Request Payload
|
|
38
|
+
*/
|
|
39
|
+
export interface RegisterRequest {
|
|
40
|
+
email: string;
|
|
41
|
+
password: string;
|
|
42
|
+
name: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Token Response
|
|
47
|
+
*
|
|
48
|
+
* Contains JWT tokens and expiration info.
|
|
49
|
+
*/
|
|
50
|
+
export interface TokenResponse {
|
|
51
|
+
accessToken: string;
|
|
52
|
+
refreshToken: string;
|
|
53
|
+
expiresIn: number; // Seconds until access token expires
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Authentication Response
|
|
58
|
+
*
|
|
59
|
+
* Complete response after successful login/register.
|
|
60
|
+
* Contains user info and tokens.
|
|
61
|
+
*/
|
|
62
|
+
export interface AuthResponse {
|
|
63
|
+
user: User;
|
|
64
|
+
tokens: TokenResponse;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Refresh Token Request Payload
|
|
69
|
+
*/
|
|
70
|
+
export interface RefreshTokenRequest {
|
|
71
|
+
refreshToken: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* JWT Payload
|
|
76
|
+
*
|
|
77
|
+
* Data stored inside the JWT access token.
|
|
78
|
+
*/
|
|
79
|
+
export interface JwtPayload {
|
|
80
|
+
userId: string;
|
|
81
|
+
email: string;
|
|
82
|
+
role: UserRole;
|
|
83
|
+
iat?: number; // Issued at
|
|
84
|
+
exp?: number; // Expiration
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* JWT Refresh Payload
|
|
89
|
+
*
|
|
90
|
+
* Data stored inside the JWT refresh token.
|
|
91
|
+
*/
|
|
92
|
+
export interface JwtRefreshPayload {
|
|
93
|
+
userId: string;
|
|
94
|
+
sessionId: string;
|
|
95
|
+
iat?: number;
|
|
96
|
+
exp?: number;
|
|
97
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resources Controller
|
|
3
|
+
*
|
|
4
|
+
* HTTP request handlers for resources endpoints.
|
|
5
|
+
* Controllers should ONLY handle HTTP concerns, no business logic.
|
|
6
|
+
*
|
|
7
|
+
* @see /mnt/project/02-general-rules.md
|
|
8
|
+
* @see /mnt/project/04-pagination.md
|
|
9
|
+
* @see /mnt/project/06-response-handling.md
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { FastifyRequest, FastifyReply } from 'fastify';
|
|
13
|
+
import { successResponse } from '@/utils/response';
|
|
14
|
+
import { paginatedResponse } from '@/utils/pagination';
|
|
15
|
+
import * as resourceService from './resources.service';
|
|
16
|
+
import {
|
|
17
|
+
CreateResourceSchema,
|
|
18
|
+
UpdateResourceSchema,
|
|
19
|
+
ResourceFiltersSchema,
|
|
20
|
+
PaginationSchema,
|
|
21
|
+
} from './resources.schemas';
|
|
22
|
+
import type {
|
|
23
|
+
CreateResourceInput,
|
|
24
|
+
UpdateResourceInput,
|
|
25
|
+
ResourceFiltersInput,
|
|
26
|
+
PaginationInput,
|
|
27
|
+
} from './resources.schemas';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* List resources with filters and pagination
|
|
31
|
+
*
|
|
32
|
+
* @route GET /resources
|
|
33
|
+
* @access Public
|
|
34
|
+
*/
|
|
35
|
+
export async function listResources(
|
|
36
|
+
request: FastifyRequest<{
|
|
37
|
+
Querystring: ResourceFiltersInput & PaginationInput;
|
|
38
|
+
}>,
|
|
39
|
+
reply: FastifyReply
|
|
40
|
+
): Promise<FastifyReply> {
|
|
41
|
+
// Parse and validate query parameters
|
|
42
|
+
const filters = ResourceFiltersSchema.parse({
|
|
43
|
+
status: request.query.status,
|
|
44
|
+
minPrice: request.query.minPrice,
|
|
45
|
+
maxPrice: request.query.maxPrice,
|
|
46
|
+
ownerId: request.query.ownerId,
|
|
47
|
+
search: request.query.search,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const pagination = PaginationSchema.parse({
|
|
51
|
+
page: request.query.page,
|
|
52
|
+
limit: request.query.limit,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Call service
|
|
56
|
+
const { items, totalItems } = await resourceService.getResources(
|
|
57
|
+
filters,
|
|
58
|
+
pagination.page,
|
|
59
|
+
pagination.limit
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// Return paginated response (EXACT format from 04-pagination.md)
|
|
63
|
+
return reply.status(200).send(
|
|
64
|
+
paginatedResponse(
|
|
65
|
+
'Resources retrieved successfully',
|
|
66
|
+
items,
|
|
67
|
+
pagination.page,
|
|
68
|
+
pagination.limit,
|
|
69
|
+
totalItems
|
|
70
|
+
)
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get resource by ID
|
|
76
|
+
*
|
|
77
|
+
* @route GET /resources/:id
|
|
78
|
+
* @access Public
|
|
79
|
+
*/
|
|
80
|
+
export async function getResource(
|
|
81
|
+
request: FastifyRequest<{
|
|
82
|
+
Params: { id: string };
|
|
83
|
+
}>,
|
|
84
|
+
reply: FastifyReply
|
|
85
|
+
): Promise<FastifyReply> {
|
|
86
|
+
const { id } = request.params;
|
|
87
|
+
|
|
88
|
+
// Call service
|
|
89
|
+
const resource = await resourceService.getResource(id);
|
|
90
|
+
|
|
91
|
+
// Return success response
|
|
92
|
+
return reply.status(200).send(
|
|
93
|
+
successResponse('Resource retrieved successfully', resource)
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Create a new resource
|
|
99
|
+
*
|
|
100
|
+
* @route POST /resources
|
|
101
|
+
* @access Private (requires authentication)
|
|
102
|
+
*/
|
|
103
|
+
export async function createResource(
|
|
104
|
+
request: FastifyRequest<{
|
|
105
|
+
Body: CreateResourceInput;
|
|
106
|
+
}>,
|
|
107
|
+
reply: FastifyReply
|
|
108
|
+
): Promise<FastifyReply> {
|
|
109
|
+
// Validate request body
|
|
110
|
+
const body = CreateResourceSchema.parse(request.body);
|
|
111
|
+
|
|
112
|
+
// Get user ID from authenticated request
|
|
113
|
+
const userId = (request.user as any)?.userId;
|
|
114
|
+
|
|
115
|
+
// Call service
|
|
116
|
+
const resource = await resourceService.createResource(body, userId);
|
|
117
|
+
|
|
118
|
+
// Return success response
|
|
119
|
+
return reply.status(201).send(
|
|
120
|
+
successResponse('Resource created successfully', resource)
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Update a resource
|
|
126
|
+
*
|
|
127
|
+
* @route PATCH /resources/:id
|
|
128
|
+
* @access Private (requires authentication and ownership)
|
|
129
|
+
*/
|
|
130
|
+
export async function updateResource(
|
|
131
|
+
request: FastifyRequest<{
|
|
132
|
+
Params: { id: string };
|
|
133
|
+
Body: UpdateResourceInput;
|
|
134
|
+
}>,
|
|
135
|
+
reply: FastifyReply
|
|
136
|
+
): Promise<FastifyReply> {
|
|
137
|
+
const { id } = request.params;
|
|
138
|
+
|
|
139
|
+
// Validate request body
|
|
140
|
+
const body = UpdateResourceSchema.parse(request.body);
|
|
141
|
+
|
|
142
|
+
// Get user ID from authenticated request
|
|
143
|
+
const userId = (request.user as any)?.userId;
|
|
144
|
+
|
|
145
|
+
// Call service (includes ownership verification)
|
|
146
|
+
const resource = await resourceService.updateResource(id, userId, body);
|
|
147
|
+
|
|
148
|
+
// Return success response
|
|
149
|
+
return reply.status(200).send(
|
|
150
|
+
successResponse('Resource updated successfully', resource)
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Delete a resource
|
|
156
|
+
*
|
|
157
|
+
* @route DELETE /resources/:id
|
|
158
|
+
* @access Private (requires authentication and ownership)
|
|
159
|
+
*/
|
|
160
|
+
export async function deleteResource(
|
|
161
|
+
request: FastifyRequest<{
|
|
162
|
+
Params: { id: string };
|
|
163
|
+
}>,
|
|
164
|
+
reply: FastifyReply
|
|
165
|
+
): Promise<FastifyReply> {
|
|
166
|
+
const { id } = request.params;
|
|
167
|
+
|
|
168
|
+
// Get user ID from authenticated request
|
|
169
|
+
const userId = (request.user as any)?.userId;
|
|
170
|
+
|
|
171
|
+
// Call service (includes ownership verification)
|
|
172
|
+
const resource = await resourceService.deleteResource(id, userId);
|
|
173
|
+
|
|
174
|
+
// Return success response
|
|
175
|
+
return reply.status(200).send(
|
|
176
|
+
successResponse('Resource deleted successfully', resource)
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Get current user's resources
|
|
182
|
+
*
|
|
183
|
+
* @route GET /resources/my
|
|
184
|
+
* @access Private (requires authentication)
|
|
185
|
+
*/
|
|
186
|
+
export async function getMyResources(
|
|
187
|
+
request: FastifyRequest<{
|
|
188
|
+
Querystring: PaginationInput;
|
|
189
|
+
}>,
|
|
190
|
+
reply: FastifyReply
|
|
191
|
+
): Promise<FastifyReply> {
|
|
192
|
+
// Parse pagination parameters
|
|
193
|
+
const pagination = PaginationSchema.parse({
|
|
194
|
+
page: request.query.page,
|
|
195
|
+
limit: request.query.limit,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Get user ID from authenticated request
|
|
199
|
+
const userId = (request.user as any)?.userId;
|
|
200
|
+
|
|
201
|
+
// Call service
|
|
202
|
+
const { items, totalItems } = await resourceService.getMyResources(
|
|
203
|
+
userId,
|
|
204
|
+
pagination.page,
|
|
205
|
+
pagination.limit
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
// Return paginated response (EXACT format from 04-pagination.md)
|
|
209
|
+
return reply.status(200).send(
|
|
210
|
+
paginatedResponse(
|
|
211
|
+
'Your resources retrieved successfully',
|
|
212
|
+
items,
|
|
213
|
+
pagination.page,
|
|
214
|
+
pagination.limit,
|
|
215
|
+
totalItems
|
|
216
|
+
)
|
|
217
|
+
);
|
|
218
|
+
}
|