create-tigra 2.6.0 → 2.6.8

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.
Files changed (42) hide show
  1. package/package.json +1 -1
  2. package/template/_claude/commands/create-client.md +1 -1
  3. package/template/_claude/rules/client/01-project-structure.md +4 -5
  4. package/template/_claude/rules/client/04-design-system.md +143 -44
  5. package/template/_claude/rules/client/core.md +8 -7
  6. package/template/client/README.md +1 -1
  7. package/template/client/src/app/globals.css +27 -14
  8. package/template/client/src/app/layout.tsx +7 -7
  9. package/template/client/src/app/page.tsx +5 -5
  10. package/template/client/src/app/providers.tsx +1 -1
  11. package/template/client/src/components/common/ThemeToggle.tsx +59 -0
  12. package/template/client/src/features/admin/hooks/useAdminSessions.ts +68 -0
  13. package/template/client/src/features/admin/hooks/useAdminStats.ts +27 -0
  14. package/template/client/src/features/admin/hooks/useAdminUsers.ts +132 -0
  15. package/template/client/src/features/admin/services/admin.service.ts +94 -0
  16. package/template/client/src/features/admin/types/admin.types.ts +65 -0
  17. package/template/client/src/features/auth/components/AuthInitializer.tsx +18 -1
  18. package/template/client/src/lib/api/axios.config.ts +20 -1
  19. package/template/client/src/lib/constants/api-endpoints.ts +9 -0
  20. package/template/client/src/lib/constants/app.constants.ts +3 -1
  21. package/template/client/src/lib/constants/routes.ts +6 -0
  22. package/template/client/src/lib/env.ts +35 -0
  23. package/template/client/src/styles/fonts/inter-jetbrains.css +16 -0
  24. package/template/client/src/styles/themes/default.css +92 -0
  25. package/template/server/package.json +1 -0
  26. package/template/server/postman/collection.json +168 -50
  27. package/template/server/prisma/schema.prisma +2 -0
  28. package/template/server/src/jobs/cleanup-deleted-accounts.job.ts +14 -4
  29. package/template/server/src/libs/prisma.ts +13 -0
  30. package/template/server/src/modules/admin/admin.controller.ts +130 -1
  31. package/template/server/src/modules/admin/admin.repo.ts +289 -0
  32. package/template/server/src/modules/admin/admin.routes.ts +113 -7
  33. package/template/server/src/modules/admin/admin.schemas.ts +49 -0
  34. package/template/server/src/modules/admin/admin.service.ts +154 -0
  35. package/template/server/src/modules/auth/auth.repo.ts +5 -18
  36. package/template/server/src/modules/auth/auth.service.ts +20 -28
  37. package/template/server/src/modules/auth/session.repo.ts +10 -5
  38. package/template/client/src/components/common/ThemeSwitcher.tsx +0 -112
  39. package/template/client/src/styles/themes/electric-indigo.css +0 -90
  40. package/template/client/src/styles/themes/ocean-teal.css +0 -90
  41. package/template/client/src/styles/themes/rose-pink.css +0 -90
  42. package/template/client/src/styles/themes/warm-orange.css +0 -90
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Admin Repository
3
+ *
4
+ * Data access layer for admin-specific queries.
5
+ */
6
+
7
+ import { prisma, isPrismaNotFound } from '@libs/prisma.js';
8
+
9
+ import type { UserRole } from '@shared/types/index.js';
10
+
11
+ // ─── Select Shapes ──────────────────────────────────────────────────────────
12
+
13
+ const adminUserSelect = {
14
+ id: true,
15
+ email: true,
16
+ firstName: true,
17
+ lastName: true,
18
+ role: true,
19
+ avatarUrl: true,
20
+ isActive: true,
21
+ deletedAt: true,
22
+ failedLoginAttempts: true,
23
+ lockedUntil: true,
24
+ createdAt: true,
25
+ updatedAt: true,
26
+ } as const;
27
+
28
+ const sessionUserSelect = {
29
+ id: true,
30
+ email: true,
31
+ firstName: true,
32
+ lastName: true,
33
+ } as const;
34
+
35
+ // ─── Types ──────────────────────────────────────────────────────────────────
36
+
37
+ export type AdminUser = {
38
+ id: string;
39
+ email: string;
40
+ firstName: string;
41
+ lastName: string;
42
+ role: UserRole;
43
+ avatarUrl: string | null;
44
+ isActive: boolean;
45
+ deletedAt: Date | null;
46
+ failedLoginAttempts: number;
47
+ lockedUntil: Date | null;
48
+ createdAt: Date;
49
+ updatedAt: Date;
50
+ };
51
+
52
+ export type AdminUserDetail = AdminUser & {
53
+ sessionCount: number;
54
+ lastLogin: Date | null;
55
+ };
56
+
57
+ export type AdminSession = {
58
+ id: string;
59
+ userId: string;
60
+ deviceInfo: string | null;
61
+ ipAddress: string | null;
62
+ lastActiveAt: Date;
63
+ expiresAt: Date;
64
+ createdAt: Date;
65
+ user: {
66
+ id: string;
67
+ email: string;
68
+ firstName: string;
69
+ lastName: string;
70
+ };
71
+ };
72
+
73
+ export type DashboardStats = {
74
+ totalUsers: number;
75
+ activeUsers: number;
76
+ adminCount: number;
77
+ recentSignups: number;
78
+ activeSessions: number;
79
+ };
80
+
81
+ // ─── Repository ─────────────────────────────────────────────────────────────
82
+
83
+ class AdminRepository {
84
+ /**
85
+ * Get paginated list of users with optional filters
86
+ */
87
+ async getUsers(params: {
88
+ page: number;
89
+ limit: number;
90
+ search?: string;
91
+ role?: UserRole;
92
+ isActive?: boolean;
93
+ sortBy: 'createdAt' | 'email' | 'firstName';
94
+ sortOrder: 'asc' | 'desc';
95
+ }): Promise<{ items: AdminUser[]; totalItems: number }> {
96
+ const { page, limit, search, role, isActive, sortBy, sortOrder } = params;
97
+ const offset = (page - 1) * limit;
98
+
99
+ const where: Record<string, unknown> = {
100
+ deletedAt: null,
101
+ };
102
+
103
+ if (search) {
104
+ where.OR = [
105
+ { email: { contains: search } },
106
+ { firstName: { contains: search } },
107
+ { lastName: { contains: search } },
108
+ ];
109
+ }
110
+
111
+ if (role) {
112
+ where.role = role;
113
+ }
114
+
115
+ if (isActive !== undefined) {
116
+ where.isActive = isActive;
117
+ }
118
+
119
+ const [items, totalItems] = await Promise.all([
120
+ prisma.user.findMany({
121
+ where,
122
+ select: adminUserSelect,
123
+ orderBy: { [sortBy]: sortOrder },
124
+ skip: offset,
125
+ take: limit,
126
+ }),
127
+ prisma.user.count({ where }),
128
+ ]);
129
+
130
+ return { items, totalItems };
131
+ }
132
+
133
+ /**
134
+ * Lightweight user lookup for write operations (existence + role check).
135
+ * Use getUserDetail() when you need session count and last login.
136
+ */
137
+ async findUserById(userId: string): Promise<AdminUser | null> {
138
+ return prisma.user.findUnique({
139
+ where: { id: userId, deletedAt: null },
140
+ select: adminUserSelect,
141
+ });
142
+ }
143
+
144
+ /**
145
+ * Get detailed user info including session count and last login
146
+ */
147
+ async getUserDetail(userId: string): Promise<AdminUserDetail | null> {
148
+ const now = new Date();
149
+
150
+ const user = await prisma.user.findUnique({
151
+ where: { id: userId, deletedAt: null },
152
+ select: {
153
+ ...adminUserSelect,
154
+ _count: {
155
+ select: {
156
+ sessions: {
157
+ where: { expiresAt: { gt: now } },
158
+ },
159
+ },
160
+ },
161
+ },
162
+ });
163
+
164
+ if (!user) return null;
165
+
166
+ // Get last login from most recent session
167
+ const latestSession = await prisma.session.findFirst({
168
+ where: { userId },
169
+ orderBy: { lastActiveAt: 'desc' },
170
+ select: { lastActiveAt: true },
171
+ });
172
+
173
+ const { _count, ...userData } = user;
174
+
175
+ return {
176
+ ...userData,
177
+ sessionCount: _count.sessions,
178
+ lastLogin: latestSession?.lastActiveAt ?? null,
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Update user active status
184
+ */
185
+ async updateUserStatus(userId: string, isActive: boolean): Promise<AdminUser> {
186
+ return prisma.user.update({
187
+ where: { id: userId },
188
+ data: { isActive },
189
+ select: adminUserSelect,
190
+ });
191
+ }
192
+
193
+ /**
194
+ * Update user role
195
+ */
196
+ async updateUserRole(userId: string, role: UserRole): Promise<AdminUser> {
197
+ return prisma.user.update({
198
+ where: { id: userId },
199
+ data: { role },
200
+ select: adminUserSelect,
201
+ });
202
+ }
203
+
204
+ /**
205
+ * Get dashboard statistics
206
+ */
207
+ async getDashboardStats(): Promise<DashboardStats> {
208
+ const now = new Date();
209
+ const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
210
+
211
+ const [totalUsers, activeUsers, adminCount, recentSignups, activeSessions] =
212
+ await Promise.all([
213
+ prisma.user.count({ where: { deletedAt: null } }),
214
+ prisma.user.count({ where: { deletedAt: null, isActive: true } }),
215
+ prisma.user.count({ where: { deletedAt: null, role: 'ADMIN' } }),
216
+ prisma.user.count({
217
+ where: { deletedAt: null, createdAt: { gte: sevenDaysAgo } },
218
+ }),
219
+ prisma.session.count({ where: { expiresAt: { gt: now } } }),
220
+ ]);
221
+
222
+ return { totalUsers, activeUsers, adminCount, recentSignups, activeSessions };
223
+ }
224
+
225
+ /**
226
+ * Get paginated list of active sessions with user info
227
+ */
228
+ async getAllSessions(params: {
229
+ page: number;
230
+ limit: number;
231
+ userId?: string;
232
+ }): Promise<{ items: AdminSession[]; totalItems: number }> {
233
+ const { page, limit, userId } = params;
234
+ const offset = (page - 1) * limit;
235
+ const now = new Date();
236
+
237
+ const where: Record<string, unknown> = {
238
+ expiresAt: { gt: now },
239
+ };
240
+
241
+ if (userId) {
242
+ where.userId = userId;
243
+ }
244
+
245
+ const [items, totalItems] = await Promise.all([
246
+ prisma.session.findMany({
247
+ where,
248
+ include: { user: { select: sessionUserSelect } },
249
+ orderBy: { lastActiveAt: 'desc' },
250
+ skip: offset,
251
+ take: limit,
252
+ }),
253
+ prisma.session.count({ where }),
254
+ ]);
255
+
256
+ return { items, totalItems };
257
+ }
258
+
259
+ /**
260
+ * Get session by ID with user info
261
+ */
262
+ async getSessionById(sessionId: string): Promise<AdminSession | null> {
263
+ return prisma.session.findUnique({
264
+ where: { id: sessionId },
265
+ include: { user: { select: sessionUserSelect } },
266
+ });
267
+ }
268
+
269
+ /**
270
+ * Delete a session (no-op if already deleted)
271
+ */
272
+ async deleteSession(sessionId: string): Promise<void> {
273
+ try {
274
+ await prisma.session.delete({ where: { id: sessionId } });
275
+ } catch (error) {
276
+ if (isPrismaNotFound(error)) return;
277
+ throw error;
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Delete refresh tokens linked to a specific session
283
+ */
284
+ async deleteRefreshTokensBySessionId(sessionId: string): Promise<void> {
285
+ await prisma.refreshToken.deleteMany({ where: { sessionId } });
286
+ }
287
+ }
288
+
289
+ export const adminRepository = new AdminRepository();
@@ -2,13 +2,24 @@ import type { FastifyInstance } from 'fastify';
2
2
  import { authenticate, authorize } from '@libs/auth.js';
3
3
  import { RATE_LIMITS } from '@config/rate-limit.config.js';
4
4
  import * as adminController from './admin.controller.js';
5
- import { blockIpSchema, unblockIpParamsSchema } from './admin.schemas.js';
5
+ import {
6
+ blockIpSchema,
7
+ unblockIpParamsSchema,
8
+ getUsersQuerySchema,
9
+ userIdParamsSchema,
10
+ updateUserStatusSchema,
11
+ updateUserRoleSchema,
12
+ getSessionsQuerySchema,
13
+ sessionIdParamsSchema,
14
+ } from './admin.schemas.js';
6
15
 
7
16
  export async function adminRoutes(fastify: FastifyInstance): Promise<void> {
8
17
  // All admin routes require authentication + ADMIN role
9
18
  fastify.addHook('preValidation', authenticate);
10
19
  fastify.addHook('preValidation', authorize('ADMIN'));
11
20
 
21
+ // ─── IP Blocking ────────────────────────────────────────────────────────
22
+
12
23
  /**
13
24
  * List blocked IPs
14
25
  *
@@ -29,9 +40,7 @@ export async function adminRoutes(fastify: FastifyInstance): Promise<void> {
29
40
  * Body: { ip: string }
30
41
  */
31
42
  fastify.post('/admin/blocked-ips', {
32
- schema: {
33
- body: blockIpSchema,
34
- },
43
+ schema: { body: blockIpSchema },
35
44
  config: { rateLimit: RATE_LIMITS.ADMIN_DEFAULT },
36
45
  handler: adminController.blockIpHandler,
37
46
  });
@@ -43,10 +52,107 @@ export async function adminRoutes(fastify: FastifyInstance): Promise<void> {
43
52
  * Auth: Required (ADMIN)
44
53
  */
45
54
  fastify.delete('/admin/blocked-ips/:ip', {
46
- schema: {
47
- params: unblockIpParamsSchema,
48
- },
55
+ schema: { params: unblockIpParamsSchema },
49
56
  config: { rateLimit: RATE_LIMITS.ADMIN_DEFAULT },
50
57
  handler: adminController.unblockIpHandler,
51
58
  });
59
+
60
+ // ─── Dashboard Stats ──────────────────────────────────────────────────
61
+
62
+ /**
63
+ * Get dashboard statistics
64
+ *
65
+ * GET /api/v1/admin/stats
66
+ * Auth: Required (ADMIN)
67
+ * Returns: { totalUsers, activeUsers, adminCount, recentSignups, activeSessions }
68
+ */
69
+ fastify.get('/admin/stats', {
70
+ config: { rateLimit: RATE_LIMITS.ADMIN_DEFAULT },
71
+ handler: adminController.getDashboardStats,
72
+ });
73
+
74
+ // ─── User Management ─────────────────────────────────────────────────
75
+
76
+ /**
77
+ * List users (paginated, filterable)
78
+ *
79
+ * GET /api/v1/admin/users
80
+ * Auth: Required (ADMIN)
81
+ * Query: ?page=1&limit=10&search=&role=&isActive=&sortBy=createdAt&sortOrder=desc
82
+ */
83
+ fastify.get('/admin/users', {
84
+ schema: { querystring: getUsersQuerySchema },
85
+ config: { rateLimit: RATE_LIMITS.ADMIN_DEFAULT },
86
+ handler: adminController.getUsers,
87
+ });
88
+
89
+ /**
90
+ * Get user detail
91
+ *
92
+ * GET /api/v1/admin/users/:userId
93
+ * Auth: Required (ADMIN)
94
+ * Returns: User with sessionCount and lastLogin
95
+ */
96
+ fastify.get('/admin/users/:userId', {
97
+ schema: { params: userIdParamsSchema },
98
+ config: { rateLimit: RATE_LIMITS.ADMIN_DEFAULT },
99
+ handler: adminController.getUserDetail,
100
+ });
101
+
102
+ /**
103
+ * Toggle user active status
104
+ *
105
+ * PATCH /api/v1/admin/users/:userId/status
106
+ * Auth: Required (ADMIN)
107
+ * Body: { isActive: boolean }
108
+ * Side effect: deactivating also invalidates all sessions
109
+ */
110
+ fastify.patch('/admin/users/:userId/status', {
111
+ schema: { params: userIdParamsSchema, body: updateUserStatusSchema },
112
+ config: { rateLimit: RATE_LIMITS.ADMIN_DEFAULT },
113
+ handler: adminController.updateUserStatus,
114
+ });
115
+
116
+ /**
117
+ * Change user role
118
+ *
119
+ * PATCH /api/v1/admin/users/:userId/role
120
+ * Auth: Required (ADMIN)
121
+ * Body: { role: 'USER' | 'ADMIN' }
122
+ * Side effect: demoting ADMIN also invalidates their sessions
123
+ * Protection: cannot change your own role
124
+ */
125
+ fastify.patch('/admin/users/:userId/role', {
126
+ schema: { params: userIdParamsSchema, body: updateUserRoleSchema },
127
+ config: { rateLimit: RATE_LIMITS.ADMIN_DEFAULT },
128
+ handler: adminController.updateUserRole,
129
+ });
130
+
131
+ // ─── Session Management ───────────────────────────────────────────────
132
+
133
+ /**
134
+ * List active sessions (paginated)
135
+ *
136
+ * GET /api/v1/admin/sessions
137
+ * Auth: Required (ADMIN)
138
+ * Query: ?page=1&limit=10&userId=
139
+ */
140
+ fastify.get('/admin/sessions', {
141
+ schema: { querystring: getSessionsQuerySchema },
142
+ config: { rateLimit: RATE_LIMITS.ADMIN_DEFAULT },
143
+ handler: adminController.getAllSessions,
144
+ });
145
+
146
+ /**
147
+ * Force-expire a session
148
+ *
149
+ * DELETE /api/v1/admin/sessions/:sessionId
150
+ * Auth: Required (ADMIN)
151
+ * Side effect: also deletes associated refresh tokens
152
+ */
153
+ fastify.delete('/admin/sessions/:sessionId', {
154
+ schema: { params: sessionIdParamsSchema },
155
+ config: { rateLimit: RATE_LIMITS.ADMIN_DEFAULT },
156
+ handler: adminController.forceExpireSession,
157
+ });
52
158
  }
@@ -1,5 +1,7 @@
1
1
  import { z } from 'zod';
2
2
 
3
+ // ─── IP Blocking ────────────────────────────────────────────────────────────
4
+
3
5
  export const blockIpSchema = z.object({
4
6
  ip: z.union([z.ipv4(), z.ipv6()], { message: 'Invalid IP address' }),
5
7
  reason: z.string().max(500).optional(),
@@ -11,3 +13,50 @@ export const unblockIpParamsSchema = z.object({
11
13
 
12
14
  export type BlockIpInput = z.infer<typeof blockIpSchema>;
13
15
  export type UnblockIpParams = z.infer<typeof unblockIpParamsSchema>;
16
+
17
+ // ─── User Management ────────────────────────────────────────────────────────
18
+
19
+ export const getUsersQuerySchema = z.object({
20
+ page: z.coerce.number().int().min(1).default(1),
21
+ limit: z.coerce.number().int().min(1).max(100).default(10),
22
+ search: z.string().max(100).optional(),
23
+ role: z.enum(['USER', 'ADMIN']).optional(),
24
+ isActive: z
25
+ .enum(['true', 'false'])
26
+ .transform((v) => v === 'true')
27
+ .optional(),
28
+ sortBy: z.enum(['createdAt', 'email', 'firstName']).default('createdAt'),
29
+ sortOrder: z.enum(['asc', 'desc']).default('desc'),
30
+ });
31
+
32
+ export const userIdParamsSchema = z.object({
33
+ userId: z.string().uuid('Invalid user ID'),
34
+ });
35
+
36
+ export const updateUserStatusSchema = z.object({
37
+ isActive: z.boolean(),
38
+ });
39
+
40
+ export const updateUserRoleSchema = z.object({
41
+ role: z.enum(['USER', 'ADMIN']),
42
+ });
43
+
44
+ export type GetUsersQuery = z.infer<typeof getUsersQuerySchema>;
45
+ export type UserIdParams = z.infer<typeof userIdParamsSchema>;
46
+ export type UpdateUserStatusInput = z.infer<typeof updateUserStatusSchema>;
47
+ export type UpdateUserRoleInput = z.infer<typeof updateUserRoleSchema>;
48
+
49
+ // ─── Session Management ─────────────────────────────────────────────────────
50
+
51
+ export const getSessionsQuerySchema = z.object({
52
+ page: z.coerce.number().int().min(1).default(1),
53
+ limit: z.coerce.number().int().min(1).max(100).default(10),
54
+ userId: z.string().uuid('Invalid user ID').optional(),
55
+ });
56
+
57
+ export const sessionIdParamsSchema = z.object({
58
+ sessionId: z.string().uuid('Invalid session ID'),
59
+ });
60
+
61
+ export type GetSessionsQuery = z.infer<typeof getSessionsQuerySchema>;
62
+ export type SessionIdParams = z.infer<typeof sessionIdParamsSchema>;
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Admin Service
3
+ *
4
+ * Business logic for admin operations.
5
+ */
6
+
7
+ import { logger } from '@libs/logger.js';
8
+ import { NotFoundError, ForbiddenError } from '@shared/errors/errors.js';
9
+ import { adminRepository } from './admin.repo.js';
10
+ import { sessionRepository } from '@modules/auth/session.repo.js';
11
+ import { deleteRefreshTokensByUserId } from '@modules/auth/auth.repo.js';
12
+
13
+ import type { UserRole } from '@shared/types/index.js';
14
+ import type { AdminUser, AdminUserDetail, AdminSession, DashboardStats } from './admin.repo.js';
15
+
16
+ class AdminService {
17
+ /**
18
+ * Get paginated list of users with optional filters
19
+ */
20
+ async getUsers(params: {
21
+ page: number;
22
+ limit: number;
23
+ search?: string;
24
+ role?: UserRole;
25
+ isActive?: boolean;
26
+ sortBy: 'createdAt' | 'email' | 'firstName';
27
+ sortOrder: 'asc' | 'desc';
28
+ }): Promise<{ items: AdminUser[]; totalItems: number }> {
29
+ return adminRepository.getUsers(params);
30
+ }
31
+
32
+ /**
33
+ * Get detailed user info
34
+ */
35
+ async getUserDetail(userId: string): Promise<AdminUserDetail> {
36
+ const user = await adminRepository.getUserDetail(userId);
37
+
38
+ if (!user) {
39
+ throw new NotFoundError('User not found');
40
+ }
41
+
42
+ return user;
43
+ }
44
+
45
+ /**
46
+ * Toggle user active status.
47
+ * When deactivating, invalidates all sessions and refresh tokens.
48
+ */
49
+ async toggleUserStatus(userId: string, isActive: boolean): Promise<AdminUser> {
50
+ const user = await adminRepository.findUserById(userId);
51
+
52
+ if (!user) {
53
+ throw new NotFoundError('User not found');
54
+ }
55
+
56
+ const updatedUser = await adminRepository.updateUserStatus(userId, isActive);
57
+
58
+ if (!isActive) {
59
+ // Force-logout: invalidate all sessions and refresh tokens
60
+ const deletedSessions = await sessionRepository.deleteAllUserSessions(userId);
61
+ await deleteRefreshTokensByUserId(userId);
62
+
63
+ logger.info({
64
+ msg: 'User deactivated and sessions invalidated',
65
+ userId,
66
+ deletedSessions,
67
+ });
68
+ } else {
69
+ logger.info({ msg: 'User activated', userId });
70
+ }
71
+
72
+ return updatedUser;
73
+ }
74
+
75
+ /**
76
+ * Change user role.
77
+ * Prevents self-demotion. On demotion from ADMIN, invalidates sessions.
78
+ */
79
+ async changeUserRole(
80
+ userId: string,
81
+ role: UserRole,
82
+ adminUserId: string,
83
+ ): Promise<AdminUser> {
84
+ if (userId === adminUserId) {
85
+ throw new ForbiddenError('Cannot change your own role');
86
+ }
87
+
88
+ const user = await adminRepository.findUserById(userId);
89
+
90
+ if (!user) {
91
+ throw new NotFoundError('User not found');
92
+ }
93
+
94
+ const updatedUser = await adminRepository.updateUserRole(userId, role);
95
+
96
+ // If demoting from ADMIN to USER, invalidate sessions so new role takes effect
97
+ if (user.role === 'ADMIN' && role === 'USER') {
98
+ const deletedSessions = await sessionRepository.deleteAllUserSessions(userId);
99
+ await deleteRefreshTokensByUserId(userId);
100
+
101
+ logger.info({
102
+ msg: 'User demoted from ADMIN and sessions invalidated',
103
+ userId,
104
+ deletedSessions,
105
+ });
106
+ } else {
107
+ logger.info({ msg: 'User role updated', userId, role });
108
+ }
109
+
110
+ return updatedUser;
111
+ }
112
+
113
+ /**
114
+ * Get dashboard statistics
115
+ */
116
+ async getDashboardStats(): Promise<DashboardStats> {
117
+ return adminRepository.getDashboardStats();
118
+ }
119
+
120
+ /**
121
+ * Get paginated list of active sessions
122
+ */
123
+ async getAllSessions(params: {
124
+ page: number;
125
+ limit: number;
126
+ userId?: string;
127
+ }): Promise<{ items: AdminSession[]; totalItems: number }> {
128
+ return adminRepository.getAllSessions(params);
129
+ }
130
+
131
+ /**
132
+ * Force-expire a specific session.
133
+ * Deletes the session and any associated refresh tokens.
134
+ */
135
+ async forceExpireSession(sessionId: string): Promise<void> {
136
+ const session = await adminRepository.getSessionById(sessionId);
137
+
138
+ if (!session) {
139
+ throw new NotFoundError('Session not found');
140
+ }
141
+
142
+ // Delete refresh tokens linked to this session first
143
+ await adminRepository.deleteRefreshTokensBySessionId(sessionId);
144
+ await adminRepository.deleteSession(sessionId);
145
+
146
+ logger.info({
147
+ msg: 'Session force-expired by admin',
148
+ sessionId,
149
+ userId: session.userId,
150
+ });
151
+ }
152
+ }
153
+
154
+ export const adminService = new AdminService();
@@ -1,4 +1,4 @@
1
- import { prisma } from '@libs/prisma.js';
1
+ import { prisma, isPrismaNotFound } from '@libs/prisma.js';
2
2
  import type { User, RefreshToken } from '@prisma/client';
3
3
 
4
4
  export async function findUserByEmail(email: string): Promise<User | null> {
@@ -45,6 +45,7 @@ export async function createUser(data: {
45
45
  export async function createRefreshToken(data: {
46
46
  token: string;
47
47
  userId: string;
48
+ sessionId?: string;
48
49
  expiresAt: Date;
49
50
  }): Promise<RefreshToken> {
50
51
  return prisma.refreshToken.create({
@@ -64,21 +65,14 @@ export async function deleteRefreshToken(token: string): Promise<void> {
64
65
  where: { token },
65
66
  });
66
67
  } catch (error) {
67
- // P2025: Record not found — token was already deleted (e.g. concurrent request)
68
- if (
69
- error instanceof Error &&
70
- 'code' in error &&
71
- (error as { code: string }).code === 'P2025'
72
- ) {
73
- return;
74
- }
68
+ if (isPrismaNotFound(error)) return;
75
69
  throw error;
76
70
  }
77
71
  }
78
72
 
79
73
  export async function rotateRefreshToken(
80
74
  oldToken: string,
81
- newData: { token: string; userId: string; expiresAt: Date },
75
+ newData: { token: string; userId: string; sessionId?: string; expiresAt: Date },
82
76
  ): Promise<boolean> {
83
77
  try {
84
78
  await prisma.$transaction([
@@ -87,14 +81,7 @@ export async function rotateRefreshToken(
87
81
  ]);
88
82
  return true;
89
83
  } catch (error) {
90
- // P2025: Old token not found — already consumed by a concurrent request
91
- if (
92
- error instanceof Error &&
93
- 'code' in error &&
94
- (error as { code: string }).code === 'P2025'
95
- ) {
96
- return false;
97
- }
84
+ if (isPrismaNotFound(error)) return false;
98
85
  throw error;
99
86
  }
100
87
  }