create-blitzpack 0.1.0 → 0.1.1

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 +35 -77
  2. package/package.json +5 -6
  3. package/template/.dockerignore +0 -59
  4. package/template/.github/workflows/ci.yml +0 -157
  5. package/template/.husky/pre-commit +0 -1
  6. package/template/.husky/pre-push +0 -1
  7. package/template/.lintstagedrc.cjs +0 -4
  8. package/template/.nvmrc +0 -1
  9. package/template/.prettierrc +0 -9
  10. package/template/.vscode/settings.json +0 -13
  11. package/template/CLAUDE.md +0 -175
  12. package/template/CONTRIBUTING.md +0 -32
  13. package/template/Dockerfile +0 -90
  14. package/template/GETTING_STARTED.md +0 -35
  15. package/template/LICENSE +0 -21
  16. package/template/README.md +0 -116
  17. package/template/apps/api/.dockerignore +0 -51
  18. package/template/apps/api/.env.local.example +0 -62
  19. package/template/apps/api/emails/account-deleted-email.tsx +0 -69
  20. package/template/apps/api/emails/components/email-layout.tsx +0 -154
  21. package/template/apps/api/emails/config.ts +0 -22
  22. package/template/apps/api/emails/password-changed-email.tsx +0 -88
  23. package/template/apps/api/emails/password-reset-email.tsx +0 -86
  24. package/template/apps/api/emails/verification-email.tsx +0 -85
  25. package/template/apps/api/emails/welcome-email.tsx +0 -70
  26. package/template/apps/api/package.json +0 -84
  27. package/template/apps/api/prisma/migrations/20251012111439_init/migration.sql +0 -13
  28. package/template/apps/api/prisma/migrations/20251018162629_add_better_auth_fields/migration.sql +0 -67
  29. package/template/apps/api/prisma/migrations/20251019142208_add_user_role_enum/migration.sql +0 -5
  30. package/template/apps/api/prisma/migrations/20251019182151_user_auth/migration.sql +0 -7
  31. package/template/apps/api/prisma/migrations/20251019211416_faster_session_lookup/migration.sql +0 -2
  32. package/template/apps/api/prisma/migrations/20251119124337_add_upload_model/migration.sql +0 -26
  33. package/template/apps/api/prisma/migrations/20251120071241_add_scope_to_account/migration.sql +0 -2
  34. package/template/apps/api/prisma/migrations/20251120072608_add_oauth_token_expiration_fields/migration.sql +0 -10
  35. package/template/apps/api/prisma/migrations/20251120144705_add_audit_logs/migration.sql +0 -29
  36. package/template/apps/api/prisma/migrations/20251127123614_remove_impersonated_by/migration.sql +0 -8
  37. package/template/apps/api/prisma/migrations/20251127125630_remove_audit_logs/migration.sql +0 -11
  38. package/template/apps/api/prisma/migrations/migration_lock.toml +0 -3
  39. package/template/apps/api/prisma/schema.prisma +0 -116
  40. package/template/apps/api/prisma/seed.ts +0 -159
  41. package/template/apps/api/prisma.config.ts +0 -14
  42. package/template/apps/api/src/app.ts +0 -377
  43. package/template/apps/api/src/common/logger.service.ts +0 -227
  44. package/template/apps/api/src/config/env.ts +0 -60
  45. package/template/apps/api/src/config/rate-limit.ts +0 -29
  46. package/template/apps/api/src/hooks/auth.ts +0 -122
  47. package/template/apps/api/src/plugins/auth.ts +0 -198
  48. package/template/apps/api/src/plugins/database.ts +0 -45
  49. package/template/apps/api/src/plugins/logger.ts +0 -33
  50. package/template/apps/api/src/plugins/multipart.ts +0 -16
  51. package/template/apps/api/src/plugins/scalar.ts +0 -20
  52. package/template/apps/api/src/plugins/schedule.ts +0 -52
  53. package/template/apps/api/src/plugins/services.ts +0 -66
  54. package/template/apps/api/src/plugins/swagger.ts +0 -56
  55. package/template/apps/api/src/routes/accounts.ts +0 -91
  56. package/template/apps/api/src/routes/admin-sessions.ts +0 -92
  57. package/template/apps/api/src/routes/metrics.ts +0 -71
  58. package/template/apps/api/src/routes/password.ts +0 -46
  59. package/template/apps/api/src/routes/sessions.ts +0 -53
  60. package/template/apps/api/src/routes/stats.ts +0 -38
  61. package/template/apps/api/src/routes/uploads-serve.ts +0 -27
  62. package/template/apps/api/src/routes/uploads.ts +0 -154
  63. package/template/apps/api/src/routes/users.ts +0 -114
  64. package/template/apps/api/src/routes/verification.ts +0 -90
  65. package/template/apps/api/src/server.ts +0 -34
  66. package/template/apps/api/src/services/accounts.service.ts +0 -125
  67. package/template/apps/api/src/services/authorization.service.ts +0 -162
  68. package/template/apps/api/src/services/email.service.ts +0 -170
  69. package/template/apps/api/src/services/file-storage.service.ts +0 -267
  70. package/template/apps/api/src/services/metrics.service.ts +0 -175
  71. package/template/apps/api/src/services/password.service.ts +0 -56
  72. package/template/apps/api/src/services/sessions.service.spec.ts +0 -134
  73. package/template/apps/api/src/services/sessions.service.ts +0 -276
  74. package/template/apps/api/src/services/stats.service.ts +0 -273
  75. package/template/apps/api/src/services/uploads.service.ts +0 -163
  76. package/template/apps/api/src/services/users.service.spec.ts +0 -249
  77. package/template/apps/api/src/services/users.service.ts +0 -198
  78. package/template/apps/api/src/utils/file-validation.ts +0 -108
  79. package/template/apps/api/start.sh +0 -33
  80. package/template/apps/api/test/helpers/fastify-app.ts +0 -24
  81. package/template/apps/api/test/helpers/mock-authorization.ts +0 -16
  82. package/template/apps/api/test/helpers/mock-logger.ts +0 -28
  83. package/template/apps/api/test/helpers/mock-prisma.ts +0 -30
  84. package/template/apps/api/test/helpers/test-db.ts +0 -125
  85. package/template/apps/api/test/integration/auth-flow.integration.spec.ts +0 -449
  86. package/template/apps/api/test/integration/password.integration.spec.ts +0 -427
  87. package/template/apps/api/test/integration/rate-limit.integration.spec.ts +0 -51
  88. package/template/apps/api/test/integration/sessions.integration.spec.ts +0 -445
  89. package/template/apps/api/test/integration/users.integration.spec.ts +0 -211
  90. package/template/apps/api/test/setup.ts +0 -31
  91. package/template/apps/api/tsconfig.json +0 -26
  92. package/template/apps/api/vitest.config.ts +0 -35
  93. package/template/apps/web/.env.local.example +0 -11
  94. package/template/apps/web/components.json +0 -24
  95. package/template/apps/web/next.config.ts +0 -22
  96. package/template/apps/web/package.json +0 -56
  97. package/template/apps/web/postcss.config.js +0 -5
  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 +0 -3
  101. package/template/apps/web/src/app/(admin)/admin/layout.tsx +0 -222
  102. package/template/apps/web/src/app/(admin)/admin/page.tsx +0 -157
  103. package/template/apps/web/src/app/(admin)/admin/sessions/page.tsx +0 -18
  104. package/template/apps/web/src/app/(admin)/admin/users/page.tsx +0 -20
  105. package/template/apps/web/src/app/(auth)/forgot-password/page.tsx +0 -177
  106. package/template/apps/web/src/app/(auth)/login/page.tsx +0 -159
  107. package/template/apps/web/src/app/(auth)/reset-password/page.tsx +0 -245
  108. package/template/apps/web/src/app/(auth)/signup/page.tsx +0 -153
  109. package/template/apps/web/src/app/dashboard/change-password/page.tsx +0 -255
  110. package/template/apps/web/src/app/dashboard/page.tsx +0 -296
  111. package/template/apps/web/src/app/error.tsx +0 -32
  112. package/template/apps/web/src/app/examples/file-upload/page.tsx +0 -200
  113. package/template/apps/web/src/app/favicon.ico +0 -0
  114. package/template/apps/web/src/app/global-error.tsx +0 -96
  115. package/template/apps/web/src/app/globals.css +0 -22
  116. package/template/apps/web/src/app/icon.png +0 -0
  117. package/template/apps/web/src/app/layout.tsx +0 -34
  118. package/template/apps/web/src/app/not-found.tsx +0 -28
  119. package/template/apps/web/src/app/page.tsx +0 -192
  120. package/template/apps/web/src/components/admin/activity-feed.tsx +0 -101
  121. package/template/apps/web/src/components/admin/charts/auth-breakdown-chart.tsx +0 -114
  122. package/template/apps/web/src/components/admin/charts/chart-tooltip.tsx +0 -124
  123. package/template/apps/web/src/components/admin/charts/realtime-metrics-chart.tsx +0 -511
  124. package/template/apps/web/src/components/admin/charts/role-distribution-chart.tsx +0 -102
  125. package/template/apps/web/src/components/admin/charts/session-activity-chart.tsx +0 -90
  126. package/template/apps/web/src/components/admin/charts/user-growth-chart.tsx +0 -108
  127. package/template/apps/web/src/components/admin/health-indicator.tsx +0 -175
  128. package/template/apps/web/src/components/admin/refresh-control.tsx +0 -90
  129. package/template/apps/web/src/components/admin/session-revoke-all-dialog.tsx +0 -79
  130. package/template/apps/web/src/components/admin/session-revoke-dialog.tsx +0 -74
  131. package/template/apps/web/src/components/admin/sessions-management-table.tsx +0 -372
  132. package/template/apps/web/src/components/admin/stat-card.tsx +0 -137
  133. package/template/apps/web/src/components/admin/user-create-dialog.tsx +0 -152
  134. package/template/apps/web/src/components/admin/user-delete-dialog.tsx +0 -73
  135. package/template/apps/web/src/components/admin/user-edit-dialog.tsx +0 -170
  136. package/template/apps/web/src/components/admin/users-management-table.tsx +0 -285
  137. package/template/apps/web/src/components/auth/email-verification-banner.tsx +0 -85
  138. package/template/apps/web/src/components/auth/github-button.tsx +0 -40
  139. package/template/apps/web/src/components/auth/google-button.tsx +0 -54
  140. package/template/apps/web/src/components/auth/protected-route.tsx +0 -66
  141. package/template/apps/web/src/components/auth/redirect-if-authenticated.tsx +0 -31
  142. package/template/apps/web/src/components/auth/with-auth.tsx +0 -30
  143. package/template/apps/web/src/components/error/error-card.tsx +0 -47
  144. package/template/apps/web/src/components/error/forbidden.tsx +0 -25
  145. package/template/apps/web/src/components/landing/command-block.tsx +0 -64
  146. package/template/apps/web/src/components/landing/feature-card.tsx +0 -60
  147. package/template/apps/web/src/components/landing/included-feature-card.tsx +0 -63
  148. package/template/apps/web/src/components/landing/logo.tsx +0 -41
  149. package/template/apps/web/src/components/landing/tech-badge.tsx +0 -11
  150. package/template/apps/web/src/components/layout/auth-nav.tsx +0 -58
  151. package/template/apps/web/src/components/layout/footer.tsx +0 -3
  152. package/template/apps/web/src/config/landing-data.ts +0 -152
  153. package/template/apps/web/src/config/site.ts +0 -5
  154. package/template/apps/web/src/hooks/api/__tests__/use-users.test.tsx +0 -181
  155. package/template/apps/web/src/hooks/api/use-admin-sessions.ts +0 -75
  156. package/template/apps/web/src/hooks/api/use-admin-stats.ts +0 -33
  157. package/template/apps/web/src/hooks/api/use-sessions.ts +0 -52
  158. package/template/apps/web/src/hooks/api/use-uploads.ts +0 -156
  159. package/template/apps/web/src/hooks/api/use-users.ts +0 -149
  160. package/template/apps/web/src/hooks/use-mobile.ts +0 -21
  161. package/template/apps/web/src/hooks/use-realtime-metrics.ts +0 -120
  162. package/template/apps/web/src/lib/__tests__/utils.test.ts +0 -29
  163. package/template/apps/web/src/lib/api.ts +0 -151
  164. package/template/apps/web/src/lib/auth.ts +0 -13
  165. package/template/apps/web/src/lib/env.ts +0 -52
  166. package/template/apps/web/src/lib/form-utils.ts +0 -11
  167. package/template/apps/web/src/lib/utils.ts +0 -1
  168. package/template/apps/web/src/providers.tsx +0 -34
  169. package/template/apps/web/src/store/atoms.ts +0 -15
  170. package/template/apps/web/src/test/helpers/test-utils.tsx +0 -44
  171. package/template/apps/web/src/test/setup.ts +0 -8
  172. package/template/apps/web/tailwind.config.ts +0 -5
  173. package/template/apps/web/tsconfig.json +0 -26
  174. package/template/apps/web/vitest.config.ts +0 -32
  175. package/template/assets/logo-512.png +0 -0
  176. package/template/assets/logo.svg +0 -4
  177. package/template/docker-compose.prod.yml +0 -66
  178. package/template/docker-compose.yml +0 -36
  179. package/template/eslint.config.ts +0 -119
  180. package/template/package.json +0 -77
  181. package/template/packages/tailwind-config/package.json +0 -9
  182. package/template/packages/tailwind-config/theme.css +0 -179
  183. package/template/packages/types/package.json +0 -29
  184. package/template/packages/types/src/__tests__/schemas.test.ts +0 -255
  185. package/template/packages/types/src/api-response.ts +0 -53
  186. package/template/packages/types/src/health-check.ts +0 -11
  187. package/template/packages/types/src/pagination.ts +0 -41
  188. package/template/packages/types/src/role.ts +0 -5
  189. package/template/packages/types/src/session.ts +0 -48
  190. package/template/packages/types/src/stats.ts +0 -113
  191. package/template/packages/types/src/upload.ts +0 -51
  192. package/template/packages/types/src/user.ts +0 -36
  193. package/template/packages/types/tsconfig.json +0 -5
  194. package/template/packages/types/vitest.config.ts +0 -21
  195. package/template/packages/ui/components.json +0 -21
  196. package/template/packages/ui/package.json +0 -108
  197. package/template/packages/ui/src/__tests__/button.test.tsx +0 -70
  198. package/template/packages/ui/src/alert-dialog.tsx +0 -141
  199. package/template/packages/ui/src/alert.tsx +0 -66
  200. package/template/packages/ui/src/animated-theme-toggler.tsx +0 -167
  201. package/template/packages/ui/src/avatar.tsx +0 -53
  202. package/template/packages/ui/src/badge.tsx +0 -36
  203. package/template/packages/ui/src/button.tsx +0 -84
  204. package/template/packages/ui/src/card.tsx +0 -92
  205. package/template/packages/ui/src/checkbox.tsx +0 -32
  206. package/template/packages/ui/src/data-table/data-table-column-header.tsx +0 -68
  207. package/template/packages/ui/src/data-table/data-table-pagination.tsx +0 -99
  208. package/template/packages/ui/src/data-table/data-table-toolbar.tsx +0 -55
  209. package/template/packages/ui/src/data-table/data-table-view-options.tsx +0 -63
  210. package/template/packages/ui/src/data-table/data-table.tsx +0 -167
  211. package/template/packages/ui/src/dialog.tsx +0 -143
  212. package/template/packages/ui/src/dropdown-menu.tsx +0 -257
  213. package/template/packages/ui/src/empty-state.tsx +0 -52
  214. package/template/packages/ui/src/file-upload-input.tsx +0 -202
  215. package/template/packages/ui/src/form.tsx +0 -168
  216. package/template/packages/ui/src/hooks/use-mobile.ts +0 -19
  217. package/template/packages/ui/src/icons/brand-icons.tsx +0 -16
  218. package/template/packages/ui/src/input.tsx +0 -21
  219. package/template/packages/ui/src/label.tsx +0 -24
  220. package/template/packages/ui/src/lib/utils.ts +0 -6
  221. package/template/packages/ui/src/password-input.tsx +0 -102
  222. package/template/packages/ui/src/popover.tsx +0 -48
  223. package/template/packages/ui/src/radio-group.tsx +0 -45
  224. package/template/packages/ui/src/scroll-area.tsx +0 -58
  225. package/template/packages/ui/src/select.tsx +0 -187
  226. package/template/packages/ui/src/separator.tsx +0 -28
  227. package/template/packages/ui/src/sheet.tsx +0 -139
  228. package/template/packages/ui/src/sidebar.tsx +0 -726
  229. package/template/packages/ui/src/skeleton-variants.tsx +0 -87
  230. package/template/packages/ui/src/skeleton.tsx +0 -13
  231. package/template/packages/ui/src/slider.tsx +0 -63
  232. package/template/packages/ui/src/sonner.tsx +0 -25
  233. package/template/packages/ui/src/spinner.tsx +0 -16
  234. package/template/packages/ui/src/switch.tsx +0 -31
  235. package/template/packages/ui/src/table.tsx +0 -116
  236. package/template/packages/ui/src/tabs.tsx +0 -66
  237. package/template/packages/ui/src/textarea.tsx +0 -18
  238. package/template/packages/ui/src/tooltip.tsx +0 -61
  239. package/template/packages/ui/src/user-avatar.tsx +0 -97
  240. package/template/packages/ui/test-config.js +0 -3
  241. package/template/packages/ui/tsconfig.json +0 -12
  242. package/template/packages/ui/turbo.json +0 -18
  243. package/template/packages/ui/vitest.config.ts +0 -17
  244. package/template/packages/ui/vitest.setup.ts +0 -1
  245. package/template/packages/utils/package.json +0 -23
  246. package/template/packages/utils/src/__tests__/utils.test.ts +0 -223
  247. package/template/packages/utils/src/array.ts +0 -18
  248. package/template/packages/utils/src/async.ts +0 -3
  249. package/template/packages/utils/src/date.ts +0 -77
  250. package/template/packages/utils/src/errors.ts +0 -73
  251. package/template/packages/utils/src/number.ts +0 -11
  252. package/template/packages/utils/src/string.ts +0 -13
  253. package/template/packages/utils/tsconfig.json +0 -5
  254. package/template/packages/utils/vitest.config.ts +0 -21
  255. package/template/pnpm-workspace.yaml +0 -4
  256. package/template/tsconfig.base.json +0 -32
  257. package/template/turbo.json +0 -133
  258. package/template/vitest.shared.ts +0 -26
  259. package/template/vitest.workspace.ts +0 -9
@@ -1,163 +0,0 @@
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
- }
@@ -1,249 +0,0 @@
1
- import { createMockAuthorizationService } from '@test/helpers/mock-authorization';
2
- import { createMockLogger } from '@test/helpers/mock-logger';
3
- import { createMockPrisma } from '@test/helpers/mock-prisma';
4
- import { beforeEach, describe, expect, it, vi } from 'vitest';
5
-
6
- import type { LoggerService } from '@/common/logger.service';
7
- import type { PrismaClient } from '@/generated/client/client.js';
8
- import type { AuthorizationService } from '@/services/authorization.service';
9
-
10
- import { UsersService } from './users.service';
11
-
12
- describe('UsersService', () => {
13
- let service: UsersService;
14
- let prisma: PrismaClient;
15
- let logger: LoggerService;
16
- let authorizationService: AuthorizationService;
17
-
18
- beforeEach(() => {
19
- logger = createMockLogger();
20
- prisma = createMockPrisma();
21
- authorizationService = createMockAuthorizationService();
22
- service = new UsersService(prisma, logger, authorizationService);
23
- });
24
-
25
- describe('getUsers', () => {
26
- const defaultQuery = {
27
- page: 1,
28
- limit: 10,
29
- sortBy: 'createdAt' as const,
30
- sortOrder: 'desc' as const,
31
- };
32
-
33
- const mockUsers = [
34
- {
35
- id: '1',
36
- email: 'user1@test.com',
37
- name: 'User 1',
38
- emailVerified: false,
39
- image: null,
40
- role: 'user' as const,
41
- banned: false,
42
- banReason: null,
43
- banExpires: null,
44
- createdAt: new Date(),
45
- updatedAt: new Date(),
46
- },
47
- {
48
- id: '2',
49
- email: 'user2@test.com',
50
- name: 'User 2',
51
- emailVerified: false,
52
- image: null,
53
- role: 'user' as const,
54
- banned: false,
55
- banReason: null,
56
- banExpires: null,
57
- createdAt: new Date(),
58
- updatedAt: new Date(),
59
- },
60
- ];
61
-
62
- it('should return empty paginated response initially', async () => {
63
- vi.mocked(prisma.user.findMany).mockResolvedValue([]);
64
- vi.mocked(prisma.user.count).mockResolvedValue(0);
65
-
66
- const result = await service.getUsers(defaultQuery);
67
- expect(result.data).toEqual([]);
68
- expect(result.pagination.total).toBe(0);
69
- expect(result.pagination.totalPages).toBe(0);
70
- });
71
-
72
- it('should return paginated users', async () => {
73
- vi.mocked(prisma.user.findMany).mockResolvedValue(mockUsers);
74
- vi.mocked(prisma.user.count).mockResolvedValue(2);
75
-
76
- const result = await service.getUsers(defaultQuery);
77
- expect(result.data).toHaveLength(2);
78
- expect(result.pagination.total).toBe(2);
79
- expect(result.pagination.totalPages).toBe(1);
80
- });
81
-
82
- it('should handle pagination correctly', async () => {
83
- vi.mocked(prisma.user.findMany).mockResolvedValue([mockUsers[0]]);
84
- vi.mocked(prisma.user.count).mockResolvedValue(3);
85
-
86
- const result = await service.getUsers({
87
- ...defaultQuery,
88
- page: 1,
89
- limit: 2,
90
- });
91
- expect(result.data).toHaveLength(1);
92
- expect(result.pagination.total).toBe(3);
93
- expect(result.pagination.totalPages).toBe(2);
94
- });
95
-
96
- it('should filter users by search query', async () => {
97
- vi.mocked(prisma.user.findMany).mockResolvedValue([mockUsers[0]]);
98
- vi.mocked(prisma.user.count).mockResolvedValue(1);
99
-
100
- const result = await service.getUsers({
101
- ...defaultQuery,
102
- search: 'user1',
103
- });
104
- expect(result.data).toHaveLength(1);
105
- expect(result.data[0].email).toBe('user1@test.com');
106
- });
107
- });
108
-
109
- describe('getUserById', () => {
110
- const mockUser = {
111
- id: '1',
112
- email: 'test@example.com',
113
- name: 'Test User',
114
- emailVerified: false,
115
- image: null,
116
- role: 'user' as const,
117
- banned: false,
118
- banReason: null,
119
- banExpires: null,
120
- createdAt: new Date(),
121
- updatedAt: new Date(),
122
- };
123
-
124
- it('should return user by ID', async () => {
125
- vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser);
126
-
127
- const user = await service.getUserById('1');
128
-
129
- expect(user).toEqual(mockUser);
130
- expect(user?.id).toBe('1');
131
- });
132
-
133
- it('should throw NotFoundError when user not found', async () => {
134
- vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
135
-
136
- await expect(service.getUserById('non-existent-id')).rejects.toThrow(
137
- 'User not found'
138
- );
139
- });
140
- });
141
-
142
- describe('createUser', () => {
143
- const mockUser = {
144
- id: '1',
145
- email: 'test@example.com',
146
- name: 'Test User',
147
- emailVerified: false,
148
- image: null,
149
- role: 'user' as const,
150
- banned: false,
151
- banReason: null,
152
- banExpires: null,
153
- createdAt: new Date(),
154
- updatedAt: new Date(),
155
- };
156
-
157
- it('should create a new user', async () => {
158
- vi.mocked(prisma.user.create).mockResolvedValue(mockUser);
159
-
160
- const user = await service.createUser({
161
- email: 'test@example.com',
162
- name: 'Test User',
163
- role: 'user',
164
- });
165
-
166
- expect(user.id).toBe('1');
167
- expect(user.email).toBe('test@example.com');
168
- expect(user.name).toBe('Test User');
169
- });
170
- });
171
-
172
- describe('updateUser', () => {
173
- const mockUser = {
174
- id: '1',
175
- email: 'test@example.com',
176
- name: 'Test User',
177
- emailVerified: false,
178
- image: null,
179
- role: 'user' as const,
180
- banned: false,
181
- banReason: null,
182
- banExpires: null,
183
- createdAt: new Date(),
184
- updatedAt: new Date(),
185
- };
186
-
187
- const updatedUser = {
188
- ...mockUser,
189
- name: 'Updated Name',
190
- };
191
-
192
- it('should update user successfully', async () => {
193
- vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser);
194
- vi.mocked(prisma.user.update).mockResolvedValue(updatedUser);
195
-
196
- const user = await service.updateUser('actor-id', 'super_admin', '1', {
197
- name: 'Updated Name',
198
- });
199
-
200
- expect(user?.name).toBe('Updated Name');
201
- expect(user?.id).toBe('1');
202
- });
203
-
204
- it('should throw NotFoundError when user not found', async () => {
205
- vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
206
-
207
- await expect(
208
- service.updateUser('actor-id', 'super_admin', 'non-existent-id', {
209
- name: 'New Name',
210
- })
211
- ).rejects.toThrow('User not found');
212
- });
213
- });
214
-
215
- describe('deleteUser', () => {
216
- const mockUser = {
217
- id: '1',
218
- email: 'test@example.com',
219
- name: 'Test User',
220
- emailVerified: false,
221
- image: null,
222
- role: 'user' as const,
223
- banned: false,
224
- banReason: null,
225
- banExpires: null,
226
- createdAt: new Date(),
227
- updatedAt: new Date(),
228
- };
229
-
230
- it('should delete user successfully', async () => {
231
- vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser);
232
- vi.mocked(prisma.user.delete).mockResolvedValue(mockUser);
233
-
234
- await service.deleteUser('actor-id', 'super_admin', '1');
235
-
236
- expect(prisma.user.delete).toHaveBeenCalledWith({
237
- where: { id: '1' },
238
- });
239
- });
240
-
241
- it('should throw NotFoundError when user not found', async () => {
242
- vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
243
-
244
- await expect(
245
- service.deleteUser('actor-id', 'super_admin', 'non-existent-id')
246
- ).rejects.toThrow('User not found');
247
- });
248
- });
249
- });
@@ -1,198 +0,0 @@
1
- import {
2
- type PaginatedResponse,
3
- type QueryUsers,
4
- } from '@repo/packages-types/pagination';
5
- import { type Role } from '@repo/packages-types/role';
6
- import {
7
- type CreateUser,
8
- type UpdateUser,
9
- type User,
10
- } from '@repo/packages-types/user';
11
- import { ForbiddenError, NotFoundError } from '@repo/packages-utils/errors';
12
-
13
- import type { LoggerService } from '@/common/logger.service';
14
- import type { PrismaClient } from '@/generated/client/client.js';
15
- import type { AuthorizationService } from '@/services/authorization.service';
16
-
17
- export class UsersService {
18
- constructor(
19
- private readonly prisma: PrismaClient,
20
- private readonly logger: LoggerService,
21
- private readonly authorizationService: AuthorizationService
22
- ) {
23
- this.logger.setContext('UsersService');
24
- }
25
-
26
- async getUsers(query: QueryUsers): Promise<PaginatedResponse<User>> {
27
- const where = query.search
28
- ? {
29
- OR: [
30
- { name: { contains: query.search, mode: 'insensitive' as const } },
31
- { email: { contains: query.search, mode: 'insensitive' as const } },
32
- ],
33
- }
34
- : undefined;
35
-
36
- const [users, total] = await Promise.all([
37
- this.prisma.user.findMany({
38
- where,
39
- orderBy: { [query.sortBy]: query.sortOrder },
40
- skip: (query.page - 1) * query.limit,
41
- take: query.limit,
42
- }),
43
- this.prisma.user.count({ where }),
44
- ]);
45
-
46
- const totalPages = Math.ceil(total / query.limit);
47
-
48
- return {
49
- data: users as User[],
50
- pagination: {
51
- page: query.page,
52
- limit: query.limit,
53
- total,
54
- totalPages,
55
- },
56
- };
57
- }
58
-
59
- async getUserById(id: string): Promise<User> {
60
- const user = await this.prisma.user.findUnique({
61
- where: { id },
62
- });
63
-
64
- if (!user) {
65
- this.logger.warn('User not found', { userId: id });
66
- throw new NotFoundError('User not found', { userId: id });
67
- }
68
-
69
- return user as User;
70
- }
71
-
72
- async createUser(createUser: CreateUser): Promise<User> {
73
- this.logger.info('Creating user', { email: createUser.email });
74
-
75
- const user = await this.prisma.user.create({
76
- data: {
77
- email: createUser.email,
78
- name: createUser.name,
79
- },
80
- });
81
-
82
- this.logger.info('User created successfully', { userId: user.id });
83
- return user as User;
84
- }
85
-
86
- async updateUser(
87
- actorId: string,
88
- actorRole: Role,
89
- targetId: string,
90
- updateUser: UpdateUser
91
- ): Promise<User> {
92
- this.logger.info('Updating user', {
93
- actorId,
94
- actorRole,
95
- targetId,
96
- });
97
-
98
- const targetUser = await this.prisma.user.findUnique({
99
- where: { id: targetId },
100
- });
101
-
102
- if (!targetUser) {
103
- this.logger.warn('User not found for update', { userId: targetId });
104
- throw new NotFoundError('User not found', { userId: targetId });
105
- }
106
-
107
- // Check if actor can modify target user
108
- this.authorizationService.assertCanModifyUser(
109
- actorId,
110
- actorRole,
111
- targetId,
112
- targetUser.role as Role
113
- );
114
-
115
- // Check role change permissions
116
- if (updateUser.role && updateUser.role !== targetUser.role) {
117
- this.authorizationService.assertCanChangeRole(
118
- actorId,
119
- actorRole,
120
- targetId,
121
- targetUser.role as Role,
122
- updateUser.role
123
- );
124
- }
125
-
126
- // Check email change permissions
127
- if (updateUser.email && updateUser.email !== targetUser.email) {
128
- this.authorizationService.assertCanChangeEmail(actorRole);
129
- }
130
-
131
- const updatedUser = await this.prisma.user.update({
132
- where: { id: targetId },
133
- data: updateUser,
134
- });
135
-
136
- this.logger.info('User updated successfully', {
137
- actorId,
138
- targetId,
139
- changes: Object.keys(updateUser),
140
- });
141
- return updatedUser as User;
142
- }
143
-
144
- async deleteUser(
145
- actorId: string,
146
- actorRole: Role,
147
- targetId: string
148
- ): Promise<void> {
149
- this.logger.info('Deleting user', {
150
- actorId,
151
- actorRole,
152
- targetId,
153
- });
154
-
155
- const targetUser = await this.prisma.user.findUnique({
156
- where: { id: targetId },
157
- });
158
-
159
- if (!targetUser) {
160
- this.logger.warn('User not found for deletion', { userId: targetId });
161
- throw new NotFoundError('User not found', { userId: targetId });
162
- }
163
-
164
- // Check if actor can delete target user (includes self-deletion check)
165
- this.authorizationService.assertCanDeleteUser(
166
- actorId,
167
- actorRole,
168
- targetId,
169
- targetUser.role as Role
170
- );
171
-
172
- // Prevent deleting the last super_admin
173
- if (targetUser.role === 'super_admin') {
174
- const superAdminCount = await this.prisma.user.count({
175
- where: { role: 'super_admin' },
176
- });
177
-
178
- if (superAdminCount <= 1) {
179
- this.logger.warn('Attempt to delete last super admin', {
180
- actorId,
181
- targetId,
182
- });
183
- throw new ForbiddenError(
184
- 'Cannot delete the last super admin. Please promote another user to super admin first.'
185
- );
186
- }
187
- }
188
-
189
- await this.prisma.user.delete({
190
- where: { id: targetId },
191
- });
192
-
193
- this.logger.info('User deleted successfully', {
194
- actorId,
195
- targetId,
196
- });
197
- }
198
- }
@@ -1,108 +0,0 @@
1
- import type { MultipartFile } from '@fastify/multipart';
2
-
3
- export const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB in bytes
4
-
5
- export const ALLOWED_MIME_TYPES = {
6
- // Images
7
- 'image/jpeg': ['.jpg', '.jpeg'],
8
- 'image/png': ['.png'],
9
- 'image/gif': ['.gif'],
10
- 'image/webp': ['.webp'],
11
- 'image/svg+xml': ['.svg'],
12
- // Documents
13
- 'application/pdf': ['.pdf'],
14
- 'application/msword': ['.doc'],
15
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': [
16
- '.docx',
17
- ],
18
- 'application/vnd.ms-excel': ['.xls'],
19
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': [
20
- '.xlsx',
21
- ],
22
- 'text/plain': ['.txt'],
23
- 'text/csv': ['.csv'],
24
- } as const;
25
-
26
- export type AllowedMimeType = keyof typeof ALLOWED_MIME_TYPES;
27
-
28
- export interface FileValidationError {
29
- field: string;
30
- message: string;
31
- }
32
-
33
- export interface FileValidationResult {
34
- valid: boolean;
35
- error?: FileValidationError;
36
- }
37
-
38
- export function validateFile(file: MultipartFile): FileValidationResult {
39
- const { mimetype, filename } = file;
40
-
41
- // Check if MIME type is allowed
42
- if (!isAllowedMimeType(mimetype)) {
43
- return {
44
- valid: false,
45
- error: {
46
- field: 'file',
47
- message: `File type '${mimetype}' is not allowed. Allowed types: images (JPEG, PNG, GIF, WebP, SVG), documents (PDF, DOC, DOCX, XLS, XLSX, TXT, CSV)`,
48
- },
49
- };
50
- }
51
-
52
- // Validate file extension matches MIME type
53
- const extension = getFileExtension(filename);
54
- const allowedExtensions = ALLOWED_MIME_TYPES[mimetype as AllowedMimeType];
55
-
56
- if (!(allowedExtensions as readonly string[]).includes(extension)) {
57
- return {
58
- valid: false,
59
- error: {
60
- field: 'file',
61
- message: `File extension '${extension}' does not match MIME type '${mimetype}'`,
62
- },
63
- };
64
- }
65
-
66
- return { valid: true };
67
- }
68
-
69
- export async function validateFileSize(
70
- file: MultipartFile
71
- ): Promise<FileValidationResult> {
72
- // Note: @fastify/multipart doesn't provide file size directly
73
- // We need to check size during streaming or after buffering
74
- // This is handled in the upload handler
75
- return { valid: true };
76
- }
77
-
78
- export function isAllowedMimeType(mimetype: string): boolean {
79
- return mimetype in ALLOWED_MIME_TYPES;
80
- }
81
-
82
- export function getFileExtension(filename: string): string {
83
- const lastDot = filename.lastIndexOf('.');
84
- return lastDot === -1 ? '' : filename.slice(lastDot).toLowerCase();
85
- }
86
-
87
- export function isImageFile(mimetype: string): boolean {
88
- return mimetype.startsWith('image/');
89
- }
90
-
91
- export function sanitizeFilename(filename: string): string {
92
- // Remove path separators and special characters
93
- return filename
94
- .replace(/[/\\]/g, '')
95
- .replace(/[^a-zA-Z0-9._-]/g, '_')
96
- .slice(0, 255); // Limit filename length
97
- }
98
-
99
- export function generateUniqueFilename(originalFilename: string): string {
100
- const extension = getFileExtension(originalFilename);
101
- const timestamp = Date.now();
102
- const random = Math.random().toString(36).substring(2, 10);
103
- const sanitized = sanitizeFilename(
104
- originalFilename.replace(extension, '')
105
- ).slice(0, 50);
106
-
107
- return `${timestamp}-${random}-${sanitized}${extension}`;
108
- }