create-blitzpack 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.
Files changed (259) hide show
  1. package/dist/index.js +452 -0
  2. package/package.json +57 -0
  3. package/template/.dockerignore +59 -0
  4. package/template/.github/workflows/ci.yml +157 -0
  5. package/template/.husky/pre-commit +1 -0
  6. package/template/.husky/pre-push +1 -0
  7. package/template/.lintstagedrc.cjs +4 -0
  8. package/template/.nvmrc +1 -0
  9. package/template/.prettierrc +9 -0
  10. package/template/.vscode/settings.json +13 -0
  11. package/template/CLAUDE.md +175 -0
  12. package/template/CONTRIBUTING.md +32 -0
  13. package/template/Dockerfile +90 -0
  14. package/template/GETTING_STARTED.md +35 -0
  15. package/template/LICENSE +21 -0
  16. package/template/README.md +116 -0
  17. package/template/apps/api/.dockerignore +51 -0
  18. package/template/apps/api/.env.local.example +62 -0
  19. package/template/apps/api/emails/account-deleted-email.tsx +69 -0
  20. package/template/apps/api/emails/components/email-layout.tsx +154 -0
  21. package/template/apps/api/emails/config.ts +22 -0
  22. package/template/apps/api/emails/password-changed-email.tsx +88 -0
  23. package/template/apps/api/emails/password-reset-email.tsx +86 -0
  24. package/template/apps/api/emails/verification-email.tsx +85 -0
  25. package/template/apps/api/emails/welcome-email.tsx +70 -0
  26. package/template/apps/api/package.json +84 -0
  27. package/template/apps/api/prisma/migrations/20251012111439_init/migration.sql +13 -0
  28. package/template/apps/api/prisma/migrations/20251018162629_add_better_auth_fields/migration.sql +67 -0
  29. package/template/apps/api/prisma/migrations/20251019142208_add_user_role_enum/migration.sql +5 -0
  30. package/template/apps/api/prisma/migrations/20251019182151_user_auth/migration.sql +7 -0
  31. package/template/apps/api/prisma/migrations/20251019211416_faster_session_lookup/migration.sql +2 -0
  32. package/template/apps/api/prisma/migrations/20251119124337_add_upload_model/migration.sql +26 -0
  33. package/template/apps/api/prisma/migrations/20251120071241_add_scope_to_account/migration.sql +2 -0
  34. package/template/apps/api/prisma/migrations/20251120072608_add_oauth_token_expiration_fields/migration.sql +10 -0
  35. package/template/apps/api/prisma/migrations/20251120144705_add_audit_logs/migration.sql +29 -0
  36. package/template/apps/api/prisma/migrations/20251127123614_remove_impersonated_by/migration.sql +8 -0
  37. package/template/apps/api/prisma/migrations/20251127125630_remove_audit_logs/migration.sql +11 -0
  38. package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
  39. package/template/apps/api/prisma/schema.prisma +116 -0
  40. package/template/apps/api/prisma/seed.ts +159 -0
  41. package/template/apps/api/prisma.config.ts +14 -0
  42. package/template/apps/api/src/app.ts +377 -0
  43. package/template/apps/api/src/common/logger.service.ts +227 -0
  44. package/template/apps/api/src/config/env.ts +60 -0
  45. package/template/apps/api/src/config/rate-limit.ts +29 -0
  46. package/template/apps/api/src/hooks/auth.ts +122 -0
  47. package/template/apps/api/src/plugins/auth.ts +198 -0
  48. package/template/apps/api/src/plugins/database.ts +45 -0
  49. package/template/apps/api/src/plugins/logger.ts +33 -0
  50. package/template/apps/api/src/plugins/multipart.ts +16 -0
  51. package/template/apps/api/src/plugins/scalar.ts +20 -0
  52. package/template/apps/api/src/plugins/schedule.ts +52 -0
  53. package/template/apps/api/src/plugins/services.ts +66 -0
  54. package/template/apps/api/src/plugins/swagger.ts +56 -0
  55. package/template/apps/api/src/routes/accounts.ts +91 -0
  56. package/template/apps/api/src/routes/admin-sessions.ts +92 -0
  57. package/template/apps/api/src/routes/metrics.ts +71 -0
  58. package/template/apps/api/src/routes/password.ts +46 -0
  59. package/template/apps/api/src/routes/sessions.ts +53 -0
  60. package/template/apps/api/src/routes/stats.ts +38 -0
  61. package/template/apps/api/src/routes/uploads-serve.ts +27 -0
  62. package/template/apps/api/src/routes/uploads.ts +154 -0
  63. package/template/apps/api/src/routes/users.ts +114 -0
  64. package/template/apps/api/src/routes/verification.ts +90 -0
  65. package/template/apps/api/src/server.ts +34 -0
  66. package/template/apps/api/src/services/accounts.service.ts +125 -0
  67. package/template/apps/api/src/services/authorization.service.ts +162 -0
  68. package/template/apps/api/src/services/email.service.ts +170 -0
  69. package/template/apps/api/src/services/file-storage.service.ts +267 -0
  70. package/template/apps/api/src/services/metrics.service.ts +175 -0
  71. package/template/apps/api/src/services/password.service.ts +56 -0
  72. package/template/apps/api/src/services/sessions.service.spec.ts +134 -0
  73. package/template/apps/api/src/services/sessions.service.ts +276 -0
  74. package/template/apps/api/src/services/stats.service.ts +273 -0
  75. package/template/apps/api/src/services/uploads.service.ts +163 -0
  76. package/template/apps/api/src/services/users.service.spec.ts +249 -0
  77. package/template/apps/api/src/services/users.service.ts +198 -0
  78. package/template/apps/api/src/utils/file-validation.ts +108 -0
  79. package/template/apps/api/start.sh +33 -0
  80. package/template/apps/api/test/helpers/fastify-app.ts +24 -0
  81. package/template/apps/api/test/helpers/mock-authorization.ts +16 -0
  82. package/template/apps/api/test/helpers/mock-logger.ts +28 -0
  83. package/template/apps/api/test/helpers/mock-prisma.ts +30 -0
  84. package/template/apps/api/test/helpers/test-db.ts +125 -0
  85. package/template/apps/api/test/integration/auth-flow.integration.spec.ts +449 -0
  86. package/template/apps/api/test/integration/password.integration.spec.ts +427 -0
  87. package/template/apps/api/test/integration/rate-limit.integration.spec.ts +51 -0
  88. package/template/apps/api/test/integration/sessions.integration.spec.ts +445 -0
  89. package/template/apps/api/test/integration/users.integration.spec.ts +211 -0
  90. package/template/apps/api/test/setup.ts +31 -0
  91. package/template/apps/api/tsconfig.json +26 -0
  92. package/template/apps/api/vitest.config.ts +35 -0
  93. package/template/apps/web/.env.local.example +11 -0
  94. package/template/apps/web/components.json +24 -0
  95. package/template/apps/web/next.config.ts +22 -0
  96. package/template/apps/web/package.json +56 -0
  97. package/template/apps/web/postcss.config.js +5 -0
  98. package/template/apps/web/public/apple-icon.png +0 -0
  99. package/template/apps/web/public/icon.png +0 -0
  100. package/template/apps/web/public/robots.txt +3 -0
  101. package/template/apps/web/src/app/(admin)/admin/layout.tsx +222 -0
  102. package/template/apps/web/src/app/(admin)/admin/page.tsx +157 -0
  103. package/template/apps/web/src/app/(admin)/admin/sessions/page.tsx +18 -0
  104. package/template/apps/web/src/app/(admin)/admin/users/page.tsx +20 -0
  105. package/template/apps/web/src/app/(auth)/forgot-password/page.tsx +177 -0
  106. package/template/apps/web/src/app/(auth)/login/page.tsx +159 -0
  107. package/template/apps/web/src/app/(auth)/reset-password/page.tsx +245 -0
  108. package/template/apps/web/src/app/(auth)/signup/page.tsx +153 -0
  109. package/template/apps/web/src/app/dashboard/change-password/page.tsx +255 -0
  110. package/template/apps/web/src/app/dashboard/page.tsx +296 -0
  111. package/template/apps/web/src/app/error.tsx +32 -0
  112. package/template/apps/web/src/app/examples/file-upload/page.tsx +200 -0
  113. package/template/apps/web/src/app/favicon.ico +0 -0
  114. package/template/apps/web/src/app/global-error.tsx +96 -0
  115. package/template/apps/web/src/app/globals.css +22 -0
  116. package/template/apps/web/src/app/icon.png +0 -0
  117. package/template/apps/web/src/app/layout.tsx +34 -0
  118. package/template/apps/web/src/app/not-found.tsx +28 -0
  119. package/template/apps/web/src/app/page.tsx +192 -0
  120. package/template/apps/web/src/components/admin/activity-feed.tsx +101 -0
  121. package/template/apps/web/src/components/admin/charts/auth-breakdown-chart.tsx +114 -0
  122. package/template/apps/web/src/components/admin/charts/chart-tooltip.tsx +124 -0
  123. package/template/apps/web/src/components/admin/charts/realtime-metrics-chart.tsx +511 -0
  124. package/template/apps/web/src/components/admin/charts/role-distribution-chart.tsx +102 -0
  125. package/template/apps/web/src/components/admin/charts/session-activity-chart.tsx +90 -0
  126. package/template/apps/web/src/components/admin/charts/user-growth-chart.tsx +108 -0
  127. package/template/apps/web/src/components/admin/health-indicator.tsx +175 -0
  128. package/template/apps/web/src/components/admin/refresh-control.tsx +90 -0
  129. package/template/apps/web/src/components/admin/session-revoke-all-dialog.tsx +79 -0
  130. package/template/apps/web/src/components/admin/session-revoke-dialog.tsx +74 -0
  131. package/template/apps/web/src/components/admin/sessions-management-table.tsx +372 -0
  132. package/template/apps/web/src/components/admin/stat-card.tsx +137 -0
  133. package/template/apps/web/src/components/admin/user-create-dialog.tsx +152 -0
  134. package/template/apps/web/src/components/admin/user-delete-dialog.tsx +73 -0
  135. package/template/apps/web/src/components/admin/user-edit-dialog.tsx +170 -0
  136. package/template/apps/web/src/components/admin/users-management-table.tsx +285 -0
  137. package/template/apps/web/src/components/auth/email-verification-banner.tsx +85 -0
  138. package/template/apps/web/src/components/auth/github-button.tsx +40 -0
  139. package/template/apps/web/src/components/auth/google-button.tsx +54 -0
  140. package/template/apps/web/src/components/auth/protected-route.tsx +66 -0
  141. package/template/apps/web/src/components/auth/redirect-if-authenticated.tsx +31 -0
  142. package/template/apps/web/src/components/auth/with-auth.tsx +30 -0
  143. package/template/apps/web/src/components/error/error-card.tsx +47 -0
  144. package/template/apps/web/src/components/error/forbidden.tsx +25 -0
  145. package/template/apps/web/src/components/landing/command-block.tsx +64 -0
  146. package/template/apps/web/src/components/landing/feature-card.tsx +60 -0
  147. package/template/apps/web/src/components/landing/included-feature-card.tsx +63 -0
  148. package/template/apps/web/src/components/landing/logo.tsx +41 -0
  149. package/template/apps/web/src/components/landing/tech-badge.tsx +11 -0
  150. package/template/apps/web/src/components/layout/auth-nav.tsx +58 -0
  151. package/template/apps/web/src/components/layout/footer.tsx +3 -0
  152. package/template/apps/web/src/config/landing-data.ts +152 -0
  153. package/template/apps/web/src/config/site.ts +5 -0
  154. package/template/apps/web/src/hooks/api/__tests__/use-users.test.tsx +181 -0
  155. package/template/apps/web/src/hooks/api/use-admin-sessions.ts +75 -0
  156. package/template/apps/web/src/hooks/api/use-admin-stats.ts +33 -0
  157. package/template/apps/web/src/hooks/api/use-sessions.ts +52 -0
  158. package/template/apps/web/src/hooks/api/use-uploads.ts +156 -0
  159. package/template/apps/web/src/hooks/api/use-users.ts +149 -0
  160. package/template/apps/web/src/hooks/use-mobile.ts +21 -0
  161. package/template/apps/web/src/hooks/use-realtime-metrics.ts +120 -0
  162. package/template/apps/web/src/lib/__tests__/utils.test.ts +29 -0
  163. package/template/apps/web/src/lib/api.ts +151 -0
  164. package/template/apps/web/src/lib/auth.ts +13 -0
  165. package/template/apps/web/src/lib/env.ts +52 -0
  166. package/template/apps/web/src/lib/form-utils.ts +11 -0
  167. package/template/apps/web/src/lib/utils.ts +1 -0
  168. package/template/apps/web/src/providers.tsx +34 -0
  169. package/template/apps/web/src/store/atoms.ts +15 -0
  170. package/template/apps/web/src/test/helpers/test-utils.tsx +44 -0
  171. package/template/apps/web/src/test/setup.ts +8 -0
  172. package/template/apps/web/tailwind.config.ts +5 -0
  173. package/template/apps/web/tsconfig.json +26 -0
  174. package/template/apps/web/vitest.config.ts +32 -0
  175. package/template/assets/logo-512.png +0 -0
  176. package/template/assets/logo.svg +4 -0
  177. package/template/docker-compose.prod.yml +66 -0
  178. package/template/docker-compose.yml +36 -0
  179. package/template/eslint.config.ts +119 -0
  180. package/template/package.json +77 -0
  181. package/template/packages/tailwind-config/package.json +9 -0
  182. package/template/packages/tailwind-config/theme.css +179 -0
  183. package/template/packages/types/package.json +29 -0
  184. package/template/packages/types/src/__tests__/schemas.test.ts +255 -0
  185. package/template/packages/types/src/api-response.ts +53 -0
  186. package/template/packages/types/src/health-check.ts +11 -0
  187. package/template/packages/types/src/pagination.ts +41 -0
  188. package/template/packages/types/src/role.ts +5 -0
  189. package/template/packages/types/src/session.ts +48 -0
  190. package/template/packages/types/src/stats.ts +113 -0
  191. package/template/packages/types/src/upload.ts +51 -0
  192. package/template/packages/types/src/user.ts +36 -0
  193. package/template/packages/types/tsconfig.json +5 -0
  194. package/template/packages/types/vitest.config.ts +21 -0
  195. package/template/packages/ui/components.json +21 -0
  196. package/template/packages/ui/package.json +108 -0
  197. package/template/packages/ui/src/__tests__/button.test.tsx +70 -0
  198. package/template/packages/ui/src/alert-dialog.tsx +141 -0
  199. package/template/packages/ui/src/alert.tsx +66 -0
  200. package/template/packages/ui/src/animated-theme-toggler.tsx +167 -0
  201. package/template/packages/ui/src/avatar.tsx +53 -0
  202. package/template/packages/ui/src/badge.tsx +36 -0
  203. package/template/packages/ui/src/button.tsx +84 -0
  204. package/template/packages/ui/src/card.tsx +92 -0
  205. package/template/packages/ui/src/checkbox.tsx +32 -0
  206. package/template/packages/ui/src/data-table/data-table-column-header.tsx +68 -0
  207. package/template/packages/ui/src/data-table/data-table-pagination.tsx +99 -0
  208. package/template/packages/ui/src/data-table/data-table-toolbar.tsx +55 -0
  209. package/template/packages/ui/src/data-table/data-table-view-options.tsx +63 -0
  210. package/template/packages/ui/src/data-table/data-table.tsx +167 -0
  211. package/template/packages/ui/src/dialog.tsx +143 -0
  212. package/template/packages/ui/src/dropdown-menu.tsx +257 -0
  213. package/template/packages/ui/src/empty-state.tsx +52 -0
  214. package/template/packages/ui/src/file-upload-input.tsx +202 -0
  215. package/template/packages/ui/src/form.tsx +168 -0
  216. package/template/packages/ui/src/hooks/use-mobile.ts +19 -0
  217. package/template/packages/ui/src/icons/brand-icons.tsx +16 -0
  218. package/template/packages/ui/src/input.tsx +21 -0
  219. package/template/packages/ui/src/label.tsx +24 -0
  220. package/template/packages/ui/src/lib/utils.ts +6 -0
  221. package/template/packages/ui/src/password-input.tsx +102 -0
  222. package/template/packages/ui/src/popover.tsx +48 -0
  223. package/template/packages/ui/src/radio-group.tsx +45 -0
  224. package/template/packages/ui/src/scroll-area.tsx +58 -0
  225. package/template/packages/ui/src/select.tsx +187 -0
  226. package/template/packages/ui/src/separator.tsx +28 -0
  227. package/template/packages/ui/src/sheet.tsx +139 -0
  228. package/template/packages/ui/src/sidebar.tsx +726 -0
  229. package/template/packages/ui/src/skeleton-variants.tsx +87 -0
  230. package/template/packages/ui/src/skeleton.tsx +13 -0
  231. package/template/packages/ui/src/slider.tsx +63 -0
  232. package/template/packages/ui/src/sonner.tsx +25 -0
  233. package/template/packages/ui/src/spinner.tsx +16 -0
  234. package/template/packages/ui/src/switch.tsx +31 -0
  235. package/template/packages/ui/src/table.tsx +116 -0
  236. package/template/packages/ui/src/tabs.tsx +66 -0
  237. package/template/packages/ui/src/textarea.tsx +18 -0
  238. package/template/packages/ui/src/tooltip.tsx +61 -0
  239. package/template/packages/ui/src/user-avatar.tsx +97 -0
  240. package/template/packages/ui/test-config.js +3 -0
  241. package/template/packages/ui/tsconfig.json +12 -0
  242. package/template/packages/ui/turbo.json +18 -0
  243. package/template/packages/ui/vitest.config.ts +17 -0
  244. package/template/packages/ui/vitest.setup.ts +1 -0
  245. package/template/packages/utils/package.json +23 -0
  246. package/template/packages/utils/src/__tests__/utils.test.ts +223 -0
  247. package/template/packages/utils/src/array.ts +18 -0
  248. package/template/packages/utils/src/async.ts +3 -0
  249. package/template/packages/utils/src/date.ts +77 -0
  250. package/template/packages/utils/src/errors.ts +73 -0
  251. package/template/packages/utils/src/number.ts +11 -0
  252. package/template/packages/utils/src/string.ts +13 -0
  253. package/template/packages/utils/tsconfig.json +5 -0
  254. package/template/packages/utils/vitest.config.ts +21 -0
  255. package/template/pnpm-workspace.yaml +4 -0
  256. package/template/tsconfig.base.json +32 -0
  257. package/template/turbo.json +133 -0
  258. package/template/vitest.shared.ts +26 -0
  259. package/template/vitest.workspace.ts +9 -0
@@ -0,0 +1,276 @@
1
+ import type { PaginatedResponse } from '@repo/packages-types/pagination';
2
+ import type { Role } from '@repo/packages-types/role';
3
+ import type {
4
+ AdminSession,
5
+ QuerySessions,
6
+ SessionStats,
7
+ } from '@repo/packages-types/session';
8
+ import { ForbiddenError, NotFoundError } from '@repo/packages-utils/errors';
9
+
10
+ import type { Prisma, PrismaClient } from '@/generated/client/client.js';
11
+ import type { AuthorizationService } from '@/services/authorization.service';
12
+
13
+ export interface SessionInfo {
14
+ id: string;
15
+ ipAddress: string | null;
16
+ userAgent: string | null;
17
+ createdAt: Date;
18
+ updatedAt: Date;
19
+ expiresAt: Date;
20
+ isCurrent?: boolean;
21
+ }
22
+
23
+ export class SessionsService {
24
+ constructor(
25
+ private readonly prisma: PrismaClient,
26
+ private readonly authorizationService?: AuthorizationService
27
+ ) {}
28
+
29
+ async getUserSessions(userId: string): Promise<SessionInfo[]> {
30
+ const sessions = await this.prisma.session.findMany({
31
+ where: {
32
+ userId,
33
+ expiresAt: {
34
+ gt: new Date(),
35
+ },
36
+ },
37
+ select: {
38
+ id: true,
39
+ ipAddress: true,
40
+ userAgent: true,
41
+ createdAt: true,
42
+ updatedAt: true,
43
+ expiresAt: true,
44
+ },
45
+ orderBy: {
46
+ updatedAt: 'desc',
47
+ },
48
+ });
49
+
50
+ return sessions;
51
+ }
52
+
53
+ async revokeSession(userId: string, sessionId: string): Promise<void> {
54
+ const session = await this.prisma.session.findFirst({
55
+ where: {
56
+ id: sessionId,
57
+ userId,
58
+ },
59
+ });
60
+
61
+ if (!session) {
62
+ throw new NotFoundError('Session not found', {
63
+ sessionId,
64
+ userId,
65
+ });
66
+ }
67
+
68
+ await this.prisma.session.delete({
69
+ where: {
70
+ id: sessionId,
71
+ },
72
+ });
73
+ }
74
+
75
+ async revokeAllSessions(
76
+ userId: string,
77
+ currentSessionId?: string
78
+ ): Promise<void> {
79
+ await this.prisma.session.deleteMany({
80
+ where: {
81
+ userId,
82
+ ...(currentSessionId && { id: { not: currentSessionId } }),
83
+ },
84
+ });
85
+ }
86
+
87
+ async revokeAllUserSessions(userId: string): Promise<void> {
88
+ await this.prisma.session.deleteMany({
89
+ where: {
90
+ userId,
91
+ },
92
+ });
93
+ }
94
+
95
+ async getAdminSessions(
96
+ query: QuerySessions
97
+ ): Promise<PaginatedResponse<AdminSession>> {
98
+ const { page, limit, search, status, userId, sortBy, sortOrder } = query;
99
+ const skip = (page - 1) * limit;
100
+ const now = new Date();
101
+
102
+ const where: Prisma.SessionWhereInput = {};
103
+
104
+ if (status === 'active') {
105
+ where.expiresAt = { gt: now };
106
+ } else if (status === 'expired') {
107
+ where.expiresAt = { lte: now };
108
+ }
109
+
110
+ if (userId) {
111
+ where.userId = userId;
112
+ }
113
+
114
+ if (search) {
115
+ where.user = {
116
+ OR: [
117
+ { email: { contains: search, mode: 'insensitive' } },
118
+ { name: { contains: search, mode: 'insensitive' } },
119
+ ],
120
+ };
121
+ }
122
+
123
+ const [sessions, total] = await Promise.all([
124
+ this.prisma.session.findMany({
125
+ where,
126
+ include: {
127
+ user: {
128
+ select: {
129
+ id: true,
130
+ name: true,
131
+ email: true,
132
+ image: true,
133
+ },
134
+ },
135
+ },
136
+ orderBy: { [sortBy]: sortOrder },
137
+ skip,
138
+ take: limit,
139
+ }),
140
+ this.prisma.session.count({ where }),
141
+ ]);
142
+
143
+ return {
144
+ data: sessions,
145
+ pagination: {
146
+ page,
147
+ limit,
148
+ total,
149
+ totalPages: Math.ceil(total / limit),
150
+ },
151
+ };
152
+ }
153
+
154
+ async getSessionStats(): Promise<SessionStats> {
155
+ const now = new Date();
156
+ const todayStart = new Date(now);
157
+ todayStart.setHours(0, 0, 0, 0);
158
+
159
+ const oneDayFromNow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
160
+
161
+ const [activeSessions, uniqueUsers, sessionsToday, expiringSoon] =
162
+ await Promise.all([
163
+ this.prisma.session.count({
164
+ where: { expiresAt: { gt: now } },
165
+ }),
166
+ this.prisma.session
167
+ .groupBy({
168
+ by: ['userId'],
169
+ where: { expiresAt: { gt: now } },
170
+ })
171
+ .then((r) => r.length),
172
+ this.prisma.session.count({
173
+ where: { createdAt: { gte: todayStart } },
174
+ }),
175
+ this.prisma.session.count({
176
+ where: {
177
+ expiresAt: { gt: now, lte: oneDayFromNow },
178
+ },
179
+ }),
180
+ ]);
181
+
182
+ return { activeSessions, uniqueUsers, sessionsToday, expiringSoon };
183
+ }
184
+
185
+ async adminRevokeSession(
186
+ actorId: string,
187
+ actorRole: Role,
188
+ sessionId: string
189
+ ): Promise<void> {
190
+ const session = await this.prisma.session.findUnique({
191
+ where: { id: sessionId },
192
+ include: {
193
+ user: {
194
+ select: {
195
+ id: true,
196
+ role: true,
197
+ },
198
+ },
199
+ },
200
+ });
201
+
202
+ if (!session) {
203
+ throw new NotFoundError('Session not found', { sessionId });
204
+ }
205
+
206
+ // Check if actor can revoke this user's session (role hierarchy check)
207
+ if (this.authorizationService) {
208
+ if (actorId === session.userId) {
209
+ // Allow revoking own sessions
210
+ } else if (
211
+ !this.authorizationService.canModifyUser(
212
+ actorRole,
213
+ session.user.role as Role
214
+ )
215
+ ) {
216
+ throw new ForbiddenError(
217
+ `Insufficient permissions to revoke session for user with role: ${session.user.role}`,
218
+ {
219
+ requiredLevel: 'higher than target',
220
+ actorRole,
221
+ targetRole: session.user.role,
222
+ }
223
+ );
224
+ }
225
+ }
226
+
227
+ await this.prisma.session.delete({
228
+ where: { id: sessionId },
229
+ });
230
+ }
231
+
232
+ async adminRevokeUserSessions(
233
+ actorId: string,
234
+ actorRole: Role,
235
+ targetUserId: string
236
+ ): Promise<number> {
237
+ // Fetch target user to check their role
238
+ const targetUser = await this.prisma.user.findUnique({
239
+ where: { id: targetUserId },
240
+ select: {
241
+ id: true,
242
+ role: true,
243
+ },
244
+ });
245
+
246
+ if (!targetUser) {
247
+ throw new NotFoundError('User not found', { userId: targetUserId });
248
+ }
249
+
250
+ // Check if actor can revoke this user's sessions (role hierarchy check)
251
+ if (this.authorizationService) {
252
+ if (actorId === targetUserId) {
253
+ // Allow revoking own sessions
254
+ } else if (
255
+ !this.authorizationService.canModifyUser(
256
+ actorRole,
257
+ targetUser.role as Role
258
+ )
259
+ ) {
260
+ throw new ForbiddenError(
261
+ `Insufficient permissions to revoke sessions for user with role: ${targetUser.role}`,
262
+ {
263
+ requiredLevel: 'higher than target',
264
+ actorRole,
265
+ targetRole: targetUser.role,
266
+ }
267
+ );
268
+ }
269
+ }
270
+
271
+ const result = await this.prisma.session.deleteMany({
272
+ where: { userId: targetUserId },
273
+ });
274
+ return result.count;
275
+ }
276
+ }
@@ -0,0 +1,273 @@
1
+ import type {
2
+ AuthBreakdown,
3
+ HealthCheckResponse,
4
+ RecentSignup,
5
+ RoleDistributionItem,
6
+ SessionActivityPoint,
7
+ StatsOverview,
8
+ SystemHealth,
9
+ SystemStats,
10
+ UserGrowthPoint,
11
+ } from '@repo/packages-types/stats';
12
+
13
+ import type { LoggerService } from '@/common/logger.service';
14
+ import type { PrismaClient } from '@/generated/client/client.js';
15
+
16
+ const SERVER_START_TIME = Date.now();
17
+
18
+ export class StatsService {
19
+ constructor(
20
+ private readonly prisma: PrismaClient,
21
+ private readonly logger: LoggerService
22
+ ) {
23
+ this.logger.setContext('StatsService');
24
+ }
25
+
26
+ async getSystemStats(): Promise<SystemStats> {
27
+ const [
28
+ overview,
29
+ userGrowth,
30
+ sessionActivity,
31
+ authBreakdown,
32
+ roleDistribution,
33
+ recentSignups,
34
+ systemHealth,
35
+ ] = await Promise.all([
36
+ this.getOverview(),
37
+ this.getUserGrowth(),
38
+ this.getSessionActivity(),
39
+ this.getAuthBreakdown(),
40
+ this.getRoleDistribution(),
41
+ this.getRecentSignups(),
42
+ this.getSystemHealth(),
43
+ ]);
44
+
45
+ return {
46
+ overview,
47
+ userGrowth,
48
+ sessionActivity,
49
+ authBreakdown,
50
+ roleDistribution,
51
+ recentSignups,
52
+ systemHealth,
53
+ };
54
+ }
55
+
56
+ private async getOverview(): Promise<StatsOverview> {
57
+ const now = new Date();
58
+ const todayStart = new Date(
59
+ now.getFullYear(),
60
+ now.getMonth(),
61
+ now.getDate()
62
+ );
63
+ const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
64
+ const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
65
+
66
+ const [
67
+ totalUsers,
68
+ activeSessionsLast24h,
69
+ totalUploads,
70
+ storageResult,
71
+ newUsersToday,
72
+ newUsersThisWeek,
73
+ ] = await Promise.all([
74
+ this.prisma.user.count(),
75
+ this.prisma.session.count({
76
+ where: {
77
+ createdAt: { gte: dayAgo },
78
+ expiresAt: { gt: now },
79
+ },
80
+ }),
81
+ this.prisma.upload.count(),
82
+ this.prisma.upload.aggregate({
83
+ _sum: { size: true },
84
+ }),
85
+ this.prisma.user.count({
86
+ where: { createdAt: { gte: todayStart } },
87
+ }),
88
+ this.prisma.user.count({
89
+ where: { createdAt: { gte: weekAgo } },
90
+ }),
91
+ ]);
92
+
93
+ return {
94
+ totalUsers,
95
+ activeSessionsLast24h,
96
+ totalUploads,
97
+ storageUsedBytes: storageResult._sum.size ?? 0,
98
+ newUsersToday,
99
+ newUsersThisWeek,
100
+ };
101
+ }
102
+
103
+ private async getUserGrowth(): Promise<UserGrowthPoint[]> {
104
+ const thirtyDaysAgo = new Date();
105
+ thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
106
+
107
+ const users = await this.prisma.user.findMany({
108
+ where: { createdAt: { gte: thirtyDaysAgo } },
109
+ select: { createdAt: true },
110
+ orderBy: { createdAt: 'asc' },
111
+ });
112
+
113
+ const totalBeforeRange = await this.prisma.user.count({
114
+ where: { createdAt: { lt: thirtyDaysAgo } },
115
+ });
116
+
117
+ const dailyCounts: Record<string, number> = {};
118
+ const dates: string[] = [];
119
+
120
+ for (let i = 29; i >= 0; i--) {
121
+ const date = new Date();
122
+ date.setDate(date.getDate() - i);
123
+ const dateStr = date.toISOString().split('T')[0];
124
+ dates.push(dateStr);
125
+ dailyCounts[dateStr] = 0;
126
+ }
127
+
128
+ users.forEach((user) => {
129
+ const dateStr = user.createdAt.toISOString().split('T')[0];
130
+ if (dailyCounts[dateStr] !== undefined) {
131
+ dailyCounts[dateStr]++;
132
+ }
133
+ });
134
+
135
+ let cumulative = totalBeforeRange;
136
+ return dates.map((date) => {
137
+ cumulative += dailyCounts[date];
138
+ return {
139
+ date,
140
+ count: dailyCounts[date],
141
+ cumulative,
142
+ };
143
+ });
144
+ }
145
+
146
+ private async getSessionActivity(): Promise<SessionActivityPoint[]> {
147
+ const thirtyDaysAgo = new Date();
148
+ thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
149
+
150
+ const sessions = await this.prisma.session.findMany({
151
+ where: { createdAt: { gte: thirtyDaysAgo } },
152
+ select: { createdAt: true },
153
+ });
154
+
155
+ const dailyCounts: Record<string, number> = {};
156
+ const dates: string[] = [];
157
+
158
+ for (let i = 29; i >= 0; i--) {
159
+ const date = new Date();
160
+ date.setDate(date.getDate() - i);
161
+ const dateStr = date.toISOString().split('T')[0];
162
+ dates.push(dateStr);
163
+ dailyCounts[dateStr] = 0;
164
+ }
165
+
166
+ sessions.forEach((session) => {
167
+ const dateStr = session.createdAt.toISOString().split('T')[0];
168
+ if (dailyCounts[dateStr] !== undefined) {
169
+ dailyCounts[dateStr]++;
170
+ }
171
+ });
172
+
173
+ return dates.map((date) => ({
174
+ date,
175
+ count: dailyCounts[date],
176
+ }));
177
+ }
178
+
179
+ private async getAuthBreakdown(): Promise<AuthBreakdown> {
180
+ const [verified, unverified, banned] = await Promise.all([
181
+ this.prisma.user.count({ where: { emailVerified: true, banned: false } }),
182
+ this.prisma.user.count({
183
+ where: { emailVerified: false, banned: false },
184
+ }),
185
+ this.prisma.user.count({ where: { banned: true } }),
186
+ ]);
187
+
188
+ return { verified, unverified, banned };
189
+ }
190
+
191
+ private async getRoleDistribution(): Promise<RoleDistributionItem[]> {
192
+ const roles = await this.prisma.user.groupBy({
193
+ by: ['role'],
194
+ _count: { role: true },
195
+ orderBy: { _count: { role: 'desc' } },
196
+ });
197
+
198
+ return roles.map((r) => ({
199
+ role: r.role,
200
+ count: r._count.role,
201
+ }));
202
+ }
203
+
204
+ private async getRecentSignups(): Promise<RecentSignup[]> {
205
+ const users = await this.prisma.user.findMany({
206
+ orderBy: { createdAt: 'desc' },
207
+ take: 10,
208
+ select: {
209
+ id: true,
210
+ email: true,
211
+ name: true,
212
+ image: true,
213
+ createdAt: true,
214
+ },
215
+ });
216
+
217
+ return users.map((u) => ({
218
+ id: u.id,
219
+ email: u.email,
220
+ name: u.name,
221
+ image: u.image,
222
+ createdAt: u.createdAt.toISOString(),
223
+ }));
224
+ }
225
+
226
+ private async getSystemHealth(): Promise<SystemHealth> {
227
+ const start = Date.now();
228
+ let database: 'connected' | 'degraded' | 'disconnected' = 'connected';
229
+
230
+ try {
231
+ await this.prisma.$queryRaw`SELECT 1`;
232
+ const latency = Date.now() - start;
233
+ if (latency > 500) {
234
+ database = 'degraded';
235
+ }
236
+ } catch {
237
+ database = 'disconnected';
238
+ }
239
+
240
+ return {
241
+ database,
242
+ uptime: Math.floor((Date.now() - SERVER_START_TIME) / 1000),
243
+ lastChecked: new Date().toISOString(),
244
+ dbLatencyMs: Date.now() - start,
245
+ };
246
+ }
247
+
248
+ async getHealthCheck(): Promise<HealthCheckResponse> {
249
+ const start = Date.now();
250
+ let database: 'connected' | 'degraded' | 'disconnected' = 'connected';
251
+ let status: 'ok' | 'degraded' | 'error' = 'ok';
252
+
253
+ try {
254
+ await this.prisma.$queryRaw`SELECT 1`;
255
+ const latency = Date.now() - start;
256
+ if (latency > 500) {
257
+ database = 'degraded';
258
+ status = 'degraded';
259
+ }
260
+ } catch {
261
+ database = 'disconnected';
262
+ status = 'error';
263
+ }
264
+
265
+ return {
266
+ status,
267
+ database,
268
+ dbLatencyMs: Date.now() - start,
269
+ uptime: Math.floor((Date.now() - SERVER_START_TIME) / 1000),
270
+ timestamp: new Date().toISOString(),
271
+ };
272
+ }
273
+ }
@@ -0,0 +1,163 @@
1
+ import type { MultipartFile } from '@fastify/multipart';
2
+ import {
3
+ ForbiddenError,
4
+ NotFoundError,
5
+ ValidationError,
6
+ } from '@repo/packages-utils/errors';
7
+
8
+ import type { LoggerService } from '@/common/logger.service';
9
+ import type { PrismaClient, Upload } from '@/generated/client/client.js';
10
+ import {
11
+ generateUniqueFilename,
12
+ MAX_FILE_SIZE,
13
+ validateFile,
14
+ } from '@/utils/file-validation';
15
+
16
+ import type { FileStorageService } from './file-storage.service';
17
+
18
+ export class UploadsService {
19
+ constructor(
20
+ private readonly prisma: PrismaClient,
21
+ private readonly fileStorage: FileStorageService,
22
+ private readonly logger: LoggerService
23
+ ) {
24
+ this.logger.setContext('UploadsService');
25
+ }
26
+
27
+ async uploadFile(
28
+ file: MultipartFile,
29
+ userId: string
30
+ ): Promise<Upload & { user: { id: string; name: string | null } }> {
31
+ // Validate file type and extension
32
+ const validation = validateFile(file);
33
+ if (!validation.valid && validation.error) {
34
+ throw new ValidationError(validation.error.message);
35
+ }
36
+
37
+ // Buffer file and check size
38
+ const buffer = await file.toBuffer();
39
+
40
+ if (buffer.length > MAX_FILE_SIZE) {
41
+ throw new ValidationError(
42
+ `File size exceeds maximum allowed size of ${MAX_FILE_SIZE / 1024 / 1024}MB`
43
+ );
44
+ }
45
+
46
+ this.logger.info('Processing file upload', {
47
+ originalName: file.filename,
48
+ mimeType: file.mimetype,
49
+ size: buffer.length,
50
+ userId,
51
+ });
52
+
53
+ // Generate unique filename
54
+ const uniqueFilename = generateUniqueFilename(file.filename);
55
+
56
+ // Upload to storage (local or S3)
57
+ const uploadResult = await this.fileStorage.uploadFile({
58
+ buffer,
59
+ originalFilename: uniqueFilename,
60
+ mimeType: file.mimetype,
61
+ optimizeImage: true,
62
+ });
63
+
64
+ // Save metadata to database
65
+ const upload = await this.prisma.upload.create({
66
+ data: {
67
+ filename: uploadResult.filename,
68
+ originalName: file.filename,
69
+ mimeType: uploadResult.mimeType,
70
+ size: uploadResult.size,
71
+ url: uploadResult.url,
72
+ userId,
73
+ },
74
+ include: {
75
+ user: {
76
+ select: {
77
+ id: true,
78
+ name: true,
79
+ },
80
+ },
81
+ },
82
+ });
83
+
84
+ this.logger.info('File upload completed', {
85
+ uploadId: upload.id,
86
+ filename: upload.filename,
87
+ size: upload.size,
88
+ });
89
+
90
+ return upload;
91
+ }
92
+
93
+ async getUserUploads(
94
+ userId: string,
95
+ options?: { limit?: number; offset?: number }
96
+ ): Promise<Upload[]> {
97
+ const { limit = 50, offset = 0 } = options || {};
98
+
99
+ return this.prisma.upload.findMany({
100
+ where: { userId },
101
+ orderBy: { createdAt: 'desc' },
102
+ take: limit,
103
+ skip: offset,
104
+ });
105
+ }
106
+
107
+ async getUploadById(
108
+ uploadId: string,
109
+ userId: string
110
+ ): Promise<Upload | null> {
111
+ const upload = await this.prisma.upload.findUnique({
112
+ where: { id: uploadId },
113
+ });
114
+
115
+ if (!upload) {
116
+ return null;
117
+ }
118
+
119
+ // Ensure user owns the file
120
+ if (upload.userId !== userId) {
121
+ throw new ForbiddenError('Unauthorized to access this file');
122
+ }
123
+
124
+ return upload;
125
+ }
126
+
127
+ async deleteUpload(uploadId: string, userId: string): Promise<void> {
128
+ const upload = await this.getUploadById(uploadId, userId);
129
+
130
+ if (!upload) {
131
+ throw new NotFoundError('Upload not found');
132
+ }
133
+
134
+ // Delete from storage
135
+ await this.fileStorage.deleteFile(upload.filename);
136
+
137
+ // Delete from database
138
+ await this.prisma.upload.delete({
139
+ where: { id: uploadId },
140
+ });
141
+
142
+ this.logger.info('Upload deleted', {
143
+ uploadId,
144
+ filename: upload.filename,
145
+ userId,
146
+ });
147
+ }
148
+
149
+ async getUploadStats(userId: string): Promise<{
150
+ totalFiles: number;
151
+ totalSize: number;
152
+ }> {
153
+ const uploads = await this.prisma.upload.findMany({
154
+ where: { userId },
155
+ select: { size: true },
156
+ });
157
+
158
+ return {
159
+ totalFiles: uploads.length,
160
+ totalSize: uploads.reduce((acc, upload) => acc + upload.size, 0),
161
+ };
162
+ }
163
+ }