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.
- package/dist/index.js +452 -0
- package/package.json +57 -0
- package/template/.dockerignore +59 -0
- package/template/.github/workflows/ci.yml +157 -0
- package/template/.husky/pre-commit +1 -0
- package/template/.husky/pre-push +1 -0
- package/template/.lintstagedrc.cjs +4 -0
- package/template/.nvmrc +1 -0
- package/template/.prettierrc +9 -0
- package/template/.vscode/settings.json +13 -0
- package/template/CLAUDE.md +175 -0
- package/template/CONTRIBUTING.md +32 -0
- package/template/Dockerfile +90 -0
- package/template/GETTING_STARTED.md +35 -0
- package/template/LICENSE +21 -0
- package/template/README.md +116 -0
- package/template/apps/api/.dockerignore +51 -0
- package/template/apps/api/.env.local.example +62 -0
- package/template/apps/api/emails/account-deleted-email.tsx +69 -0
- package/template/apps/api/emails/components/email-layout.tsx +154 -0
- package/template/apps/api/emails/config.ts +22 -0
- package/template/apps/api/emails/password-changed-email.tsx +88 -0
- package/template/apps/api/emails/password-reset-email.tsx +86 -0
- package/template/apps/api/emails/verification-email.tsx +85 -0
- package/template/apps/api/emails/welcome-email.tsx +70 -0
- package/template/apps/api/package.json +84 -0
- package/template/apps/api/prisma/migrations/20251012111439_init/migration.sql +13 -0
- package/template/apps/api/prisma/migrations/20251018162629_add_better_auth_fields/migration.sql +67 -0
- package/template/apps/api/prisma/migrations/20251019142208_add_user_role_enum/migration.sql +5 -0
- package/template/apps/api/prisma/migrations/20251019182151_user_auth/migration.sql +7 -0
- package/template/apps/api/prisma/migrations/20251019211416_faster_session_lookup/migration.sql +2 -0
- package/template/apps/api/prisma/migrations/20251119124337_add_upload_model/migration.sql +26 -0
- package/template/apps/api/prisma/migrations/20251120071241_add_scope_to_account/migration.sql +2 -0
- package/template/apps/api/prisma/migrations/20251120072608_add_oauth_token_expiration_fields/migration.sql +10 -0
- package/template/apps/api/prisma/migrations/20251120144705_add_audit_logs/migration.sql +29 -0
- package/template/apps/api/prisma/migrations/20251127123614_remove_impersonated_by/migration.sql +8 -0
- package/template/apps/api/prisma/migrations/20251127125630_remove_audit_logs/migration.sql +11 -0
- package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
- package/template/apps/api/prisma/schema.prisma +116 -0
- package/template/apps/api/prisma/seed.ts +159 -0
- package/template/apps/api/prisma.config.ts +14 -0
- package/template/apps/api/src/app.ts +377 -0
- package/template/apps/api/src/common/logger.service.ts +227 -0
- package/template/apps/api/src/config/env.ts +60 -0
- package/template/apps/api/src/config/rate-limit.ts +29 -0
- package/template/apps/api/src/hooks/auth.ts +122 -0
- package/template/apps/api/src/plugins/auth.ts +198 -0
- package/template/apps/api/src/plugins/database.ts +45 -0
- package/template/apps/api/src/plugins/logger.ts +33 -0
- package/template/apps/api/src/plugins/multipart.ts +16 -0
- package/template/apps/api/src/plugins/scalar.ts +20 -0
- package/template/apps/api/src/plugins/schedule.ts +52 -0
- package/template/apps/api/src/plugins/services.ts +66 -0
- package/template/apps/api/src/plugins/swagger.ts +56 -0
- package/template/apps/api/src/routes/accounts.ts +91 -0
- package/template/apps/api/src/routes/admin-sessions.ts +92 -0
- package/template/apps/api/src/routes/metrics.ts +71 -0
- package/template/apps/api/src/routes/password.ts +46 -0
- package/template/apps/api/src/routes/sessions.ts +53 -0
- package/template/apps/api/src/routes/stats.ts +38 -0
- package/template/apps/api/src/routes/uploads-serve.ts +27 -0
- package/template/apps/api/src/routes/uploads.ts +154 -0
- package/template/apps/api/src/routes/users.ts +114 -0
- package/template/apps/api/src/routes/verification.ts +90 -0
- package/template/apps/api/src/server.ts +34 -0
- package/template/apps/api/src/services/accounts.service.ts +125 -0
- package/template/apps/api/src/services/authorization.service.ts +162 -0
- package/template/apps/api/src/services/email.service.ts +170 -0
- package/template/apps/api/src/services/file-storage.service.ts +267 -0
- package/template/apps/api/src/services/metrics.service.ts +175 -0
- package/template/apps/api/src/services/password.service.ts +56 -0
- package/template/apps/api/src/services/sessions.service.spec.ts +134 -0
- package/template/apps/api/src/services/sessions.service.ts +276 -0
- package/template/apps/api/src/services/stats.service.ts +273 -0
- package/template/apps/api/src/services/uploads.service.ts +163 -0
- package/template/apps/api/src/services/users.service.spec.ts +249 -0
- package/template/apps/api/src/services/users.service.ts +198 -0
- package/template/apps/api/src/utils/file-validation.ts +108 -0
- package/template/apps/api/start.sh +33 -0
- package/template/apps/api/test/helpers/fastify-app.ts +24 -0
- package/template/apps/api/test/helpers/mock-authorization.ts +16 -0
- package/template/apps/api/test/helpers/mock-logger.ts +28 -0
- package/template/apps/api/test/helpers/mock-prisma.ts +30 -0
- package/template/apps/api/test/helpers/test-db.ts +125 -0
- package/template/apps/api/test/integration/auth-flow.integration.spec.ts +449 -0
- package/template/apps/api/test/integration/password.integration.spec.ts +427 -0
- package/template/apps/api/test/integration/rate-limit.integration.spec.ts +51 -0
- package/template/apps/api/test/integration/sessions.integration.spec.ts +445 -0
- package/template/apps/api/test/integration/users.integration.spec.ts +211 -0
- package/template/apps/api/test/setup.ts +31 -0
- package/template/apps/api/tsconfig.json +26 -0
- package/template/apps/api/vitest.config.ts +35 -0
- package/template/apps/web/.env.local.example +11 -0
- package/template/apps/web/components.json +24 -0
- package/template/apps/web/next.config.ts +22 -0
- package/template/apps/web/package.json +56 -0
- package/template/apps/web/postcss.config.js +5 -0
- package/template/apps/web/public/apple-icon.png +0 -0
- package/template/apps/web/public/icon.png +0 -0
- package/template/apps/web/public/robots.txt +3 -0
- package/template/apps/web/src/app/(admin)/admin/layout.tsx +222 -0
- package/template/apps/web/src/app/(admin)/admin/page.tsx +157 -0
- package/template/apps/web/src/app/(admin)/admin/sessions/page.tsx +18 -0
- package/template/apps/web/src/app/(admin)/admin/users/page.tsx +20 -0
- package/template/apps/web/src/app/(auth)/forgot-password/page.tsx +177 -0
- package/template/apps/web/src/app/(auth)/login/page.tsx +159 -0
- package/template/apps/web/src/app/(auth)/reset-password/page.tsx +245 -0
- package/template/apps/web/src/app/(auth)/signup/page.tsx +153 -0
- package/template/apps/web/src/app/dashboard/change-password/page.tsx +255 -0
- package/template/apps/web/src/app/dashboard/page.tsx +296 -0
- package/template/apps/web/src/app/error.tsx +32 -0
- package/template/apps/web/src/app/examples/file-upload/page.tsx +200 -0
- package/template/apps/web/src/app/favicon.ico +0 -0
- package/template/apps/web/src/app/global-error.tsx +96 -0
- package/template/apps/web/src/app/globals.css +22 -0
- package/template/apps/web/src/app/icon.png +0 -0
- package/template/apps/web/src/app/layout.tsx +34 -0
- package/template/apps/web/src/app/not-found.tsx +28 -0
- package/template/apps/web/src/app/page.tsx +192 -0
- package/template/apps/web/src/components/admin/activity-feed.tsx +101 -0
- package/template/apps/web/src/components/admin/charts/auth-breakdown-chart.tsx +114 -0
- package/template/apps/web/src/components/admin/charts/chart-tooltip.tsx +124 -0
- package/template/apps/web/src/components/admin/charts/realtime-metrics-chart.tsx +511 -0
- package/template/apps/web/src/components/admin/charts/role-distribution-chart.tsx +102 -0
- package/template/apps/web/src/components/admin/charts/session-activity-chart.tsx +90 -0
- package/template/apps/web/src/components/admin/charts/user-growth-chart.tsx +108 -0
- package/template/apps/web/src/components/admin/health-indicator.tsx +175 -0
- package/template/apps/web/src/components/admin/refresh-control.tsx +90 -0
- package/template/apps/web/src/components/admin/session-revoke-all-dialog.tsx +79 -0
- package/template/apps/web/src/components/admin/session-revoke-dialog.tsx +74 -0
- package/template/apps/web/src/components/admin/sessions-management-table.tsx +372 -0
- package/template/apps/web/src/components/admin/stat-card.tsx +137 -0
- package/template/apps/web/src/components/admin/user-create-dialog.tsx +152 -0
- package/template/apps/web/src/components/admin/user-delete-dialog.tsx +73 -0
- package/template/apps/web/src/components/admin/user-edit-dialog.tsx +170 -0
- package/template/apps/web/src/components/admin/users-management-table.tsx +285 -0
- package/template/apps/web/src/components/auth/email-verification-banner.tsx +85 -0
- package/template/apps/web/src/components/auth/github-button.tsx +40 -0
- package/template/apps/web/src/components/auth/google-button.tsx +54 -0
- package/template/apps/web/src/components/auth/protected-route.tsx +66 -0
- package/template/apps/web/src/components/auth/redirect-if-authenticated.tsx +31 -0
- package/template/apps/web/src/components/auth/with-auth.tsx +30 -0
- package/template/apps/web/src/components/error/error-card.tsx +47 -0
- package/template/apps/web/src/components/error/forbidden.tsx +25 -0
- package/template/apps/web/src/components/landing/command-block.tsx +64 -0
- package/template/apps/web/src/components/landing/feature-card.tsx +60 -0
- package/template/apps/web/src/components/landing/included-feature-card.tsx +63 -0
- package/template/apps/web/src/components/landing/logo.tsx +41 -0
- package/template/apps/web/src/components/landing/tech-badge.tsx +11 -0
- package/template/apps/web/src/components/layout/auth-nav.tsx +58 -0
- package/template/apps/web/src/components/layout/footer.tsx +3 -0
- package/template/apps/web/src/config/landing-data.ts +152 -0
- package/template/apps/web/src/config/site.ts +5 -0
- package/template/apps/web/src/hooks/api/__tests__/use-users.test.tsx +181 -0
- package/template/apps/web/src/hooks/api/use-admin-sessions.ts +75 -0
- package/template/apps/web/src/hooks/api/use-admin-stats.ts +33 -0
- package/template/apps/web/src/hooks/api/use-sessions.ts +52 -0
- package/template/apps/web/src/hooks/api/use-uploads.ts +156 -0
- package/template/apps/web/src/hooks/api/use-users.ts +149 -0
- package/template/apps/web/src/hooks/use-mobile.ts +21 -0
- package/template/apps/web/src/hooks/use-realtime-metrics.ts +120 -0
- package/template/apps/web/src/lib/__tests__/utils.test.ts +29 -0
- package/template/apps/web/src/lib/api.ts +151 -0
- package/template/apps/web/src/lib/auth.ts +13 -0
- package/template/apps/web/src/lib/env.ts +52 -0
- package/template/apps/web/src/lib/form-utils.ts +11 -0
- package/template/apps/web/src/lib/utils.ts +1 -0
- package/template/apps/web/src/providers.tsx +34 -0
- package/template/apps/web/src/store/atoms.ts +15 -0
- package/template/apps/web/src/test/helpers/test-utils.tsx +44 -0
- package/template/apps/web/src/test/setup.ts +8 -0
- package/template/apps/web/tailwind.config.ts +5 -0
- package/template/apps/web/tsconfig.json +26 -0
- package/template/apps/web/vitest.config.ts +32 -0
- package/template/assets/logo-512.png +0 -0
- package/template/assets/logo.svg +4 -0
- package/template/docker-compose.prod.yml +66 -0
- package/template/docker-compose.yml +36 -0
- package/template/eslint.config.ts +119 -0
- package/template/package.json +77 -0
- package/template/packages/tailwind-config/package.json +9 -0
- package/template/packages/tailwind-config/theme.css +179 -0
- package/template/packages/types/package.json +29 -0
- package/template/packages/types/src/__tests__/schemas.test.ts +255 -0
- package/template/packages/types/src/api-response.ts +53 -0
- package/template/packages/types/src/health-check.ts +11 -0
- package/template/packages/types/src/pagination.ts +41 -0
- package/template/packages/types/src/role.ts +5 -0
- package/template/packages/types/src/session.ts +48 -0
- package/template/packages/types/src/stats.ts +113 -0
- package/template/packages/types/src/upload.ts +51 -0
- package/template/packages/types/src/user.ts +36 -0
- package/template/packages/types/tsconfig.json +5 -0
- package/template/packages/types/vitest.config.ts +21 -0
- package/template/packages/ui/components.json +21 -0
- package/template/packages/ui/package.json +108 -0
- package/template/packages/ui/src/__tests__/button.test.tsx +70 -0
- package/template/packages/ui/src/alert-dialog.tsx +141 -0
- package/template/packages/ui/src/alert.tsx +66 -0
- package/template/packages/ui/src/animated-theme-toggler.tsx +167 -0
- package/template/packages/ui/src/avatar.tsx +53 -0
- package/template/packages/ui/src/badge.tsx +36 -0
- package/template/packages/ui/src/button.tsx +84 -0
- package/template/packages/ui/src/card.tsx +92 -0
- package/template/packages/ui/src/checkbox.tsx +32 -0
- package/template/packages/ui/src/data-table/data-table-column-header.tsx +68 -0
- package/template/packages/ui/src/data-table/data-table-pagination.tsx +99 -0
- package/template/packages/ui/src/data-table/data-table-toolbar.tsx +55 -0
- package/template/packages/ui/src/data-table/data-table-view-options.tsx +63 -0
- package/template/packages/ui/src/data-table/data-table.tsx +167 -0
- package/template/packages/ui/src/dialog.tsx +143 -0
- package/template/packages/ui/src/dropdown-menu.tsx +257 -0
- package/template/packages/ui/src/empty-state.tsx +52 -0
- package/template/packages/ui/src/file-upload-input.tsx +202 -0
- package/template/packages/ui/src/form.tsx +168 -0
- package/template/packages/ui/src/hooks/use-mobile.ts +19 -0
- package/template/packages/ui/src/icons/brand-icons.tsx +16 -0
- package/template/packages/ui/src/input.tsx +21 -0
- package/template/packages/ui/src/label.tsx +24 -0
- package/template/packages/ui/src/lib/utils.ts +6 -0
- package/template/packages/ui/src/password-input.tsx +102 -0
- package/template/packages/ui/src/popover.tsx +48 -0
- package/template/packages/ui/src/radio-group.tsx +45 -0
- package/template/packages/ui/src/scroll-area.tsx +58 -0
- package/template/packages/ui/src/select.tsx +187 -0
- package/template/packages/ui/src/separator.tsx +28 -0
- package/template/packages/ui/src/sheet.tsx +139 -0
- package/template/packages/ui/src/sidebar.tsx +726 -0
- package/template/packages/ui/src/skeleton-variants.tsx +87 -0
- package/template/packages/ui/src/skeleton.tsx +13 -0
- package/template/packages/ui/src/slider.tsx +63 -0
- package/template/packages/ui/src/sonner.tsx +25 -0
- package/template/packages/ui/src/spinner.tsx +16 -0
- package/template/packages/ui/src/switch.tsx +31 -0
- package/template/packages/ui/src/table.tsx +116 -0
- package/template/packages/ui/src/tabs.tsx +66 -0
- package/template/packages/ui/src/textarea.tsx +18 -0
- package/template/packages/ui/src/tooltip.tsx +61 -0
- package/template/packages/ui/src/user-avatar.tsx +97 -0
- package/template/packages/ui/test-config.js +3 -0
- package/template/packages/ui/tsconfig.json +12 -0
- package/template/packages/ui/turbo.json +18 -0
- package/template/packages/ui/vitest.config.ts +17 -0
- package/template/packages/ui/vitest.setup.ts +1 -0
- package/template/packages/utils/package.json +23 -0
- package/template/packages/utils/src/__tests__/utils.test.ts +223 -0
- package/template/packages/utils/src/array.ts +18 -0
- package/template/packages/utils/src/async.ts +3 -0
- package/template/packages/utils/src/date.ts +77 -0
- package/template/packages/utils/src/errors.ts +73 -0
- package/template/packages/utils/src/number.ts +11 -0
- package/template/packages/utils/src/string.ts +13 -0
- package/template/packages/utils/tsconfig.json +5 -0
- package/template/packages/utils/vitest.config.ts +21 -0
- package/template/pnpm-workspace.yaml +4 -0
- package/template/tsconfig.base.json +32 -0
- package/template/turbo.json +133 -0
- package/template/vitest.shared.ts +26 -0
- package/template/vitest.workspace.ts +9 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import {
|
|
2
|
+
QuerySessionsSchema,
|
|
3
|
+
RevokeSessionParamsSchema,
|
|
4
|
+
RevokeUserSessionsParamsSchema,
|
|
5
|
+
} from '@repo/packages-types/session';
|
|
6
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
7
|
+
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
|
8
|
+
import type { z } from 'zod';
|
|
9
|
+
|
|
10
|
+
import { requireAuth, requireRole } from '@/hooks/auth';
|
|
11
|
+
|
|
12
|
+
type RevokeSessionParams = z.infer<typeof RevokeSessionParamsSchema>;
|
|
13
|
+
type RevokeUserSessionsParams = z.infer<typeof RevokeUserSessionsParamsSchema>;
|
|
14
|
+
|
|
15
|
+
const adminSessionsRoutes: FastifyPluginAsync = async (app) => {
|
|
16
|
+
const server = app.withTypeProvider<ZodTypeProvider>();
|
|
17
|
+
|
|
18
|
+
server.get(
|
|
19
|
+
'/admin/sessions',
|
|
20
|
+
{
|
|
21
|
+
preHandler: [requireAuth, requireRole(['admin', 'super_admin'])],
|
|
22
|
+
schema: {
|
|
23
|
+
querystring: QuerySessionsSchema,
|
|
24
|
+
description: 'Get paginated sessions with filtering (Admin only)',
|
|
25
|
+
tags: ['Admin'],
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
async (request) => {
|
|
29
|
+
return app.sessionsService.getAdminSessions(request.query);
|
|
30
|
+
}
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
server.get(
|
|
34
|
+
'/admin/sessions/stats',
|
|
35
|
+
{
|
|
36
|
+
preHandler: [requireAuth, requireRole(['admin', 'super_admin'])],
|
|
37
|
+
schema: {
|
|
38
|
+
description: 'Get session statistics (Admin only)',
|
|
39
|
+
tags: ['Admin'],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
async () => {
|
|
43
|
+
return app.sessionsService.getSessionStats();
|
|
44
|
+
}
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
server.delete<{ Params: RevokeSessionParams }>(
|
|
48
|
+
'/admin/sessions/:sessionId',
|
|
49
|
+
{
|
|
50
|
+
preHandler: [requireAuth, requireRole(['admin', 'super_admin'])],
|
|
51
|
+
schema: {
|
|
52
|
+
params: RevokeSessionParamsSchema,
|
|
53
|
+
description: 'Force revoke a session (Admin only)',
|
|
54
|
+
tags: ['Admin'],
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
async (request) => {
|
|
58
|
+
const { sessionId } = request.params;
|
|
59
|
+
await app.sessionsService.adminRevokeSession(
|
|
60
|
+
request.user!.id,
|
|
61
|
+
request.user!.role as 'user' | 'admin' | 'super_admin',
|
|
62
|
+
sessionId
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
return { message: 'Session revoked successfully' };
|
|
66
|
+
}
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
server.delete<{ Params: RevokeUserSessionsParams }>(
|
|
70
|
+
'/admin/sessions/user/:userId',
|
|
71
|
+
{
|
|
72
|
+
preHandler: [requireAuth, requireRole(['admin', 'super_admin'])],
|
|
73
|
+
schema: {
|
|
74
|
+
params: RevokeUserSessionsParamsSchema,
|
|
75
|
+
description: 'Revoke all sessions for a user (Admin only)',
|
|
76
|
+
tags: ['Admin'],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
async (request) => {
|
|
80
|
+
const { userId } = request.params;
|
|
81
|
+
const count = await app.sessionsService.adminRevokeUserSessions(
|
|
82
|
+
request.user!.id,
|
|
83
|
+
request.user!.role as 'user' | 'admin' | 'super_admin',
|
|
84
|
+
userId
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
return { message: `Revoked ${count} session(s) for user` };
|
|
88
|
+
}
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export default adminSessionsRoutes;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
2
|
+
|
|
3
|
+
import { requireAuth, requireRole } from '@/hooks/auth';
|
|
4
|
+
import { metricsService } from '@/services/metrics.service';
|
|
5
|
+
|
|
6
|
+
const metricsRoutes: FastifyPluginAsync = async (app) => {
|
|
7
|
+
app.get(
|
|
8
|
+
'/admin/metrics/stream',
|
|
9
|
+
{
|
|
10
|
+
preHandler: [requireAuth, requireRole(['admin', 'super_admin'])],
|
|
11
|
+
schema: {
|
|
12
|
+
description: 'Stream real-time system metrics via SSE (Admin only)',
|
|
13
|
+
tags: ['Admin'],
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
async (request, reply) => {
|
|
17
|
+
reply.raw.writeHead(200, {
|
|
18
|
+
'Content-Type': 'text/event-stream',
|
|
19
|
+
'Cache-Control': 'no-cache',
|
|
20
|
+
Connection: 'keep-alive',
|
|
21
|
+
'Access-Control-Allow-Origin': app.env.FRONTEND_URL,
|
|
22
|
+
'Access-Control-Allow-Credentials': 'true',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const history = metricsService.getHistory();
|
|
26
|
+
if (history.length > 0) {
|
|
27
|
+
reply.raw.write(`event: history\n`);
|
|
28
|
+
reply.raw.write(`data: ${JSON.stringify(history)}\n\n`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const unsubscribe = metricsService.subscribe((metrics) => {
|
|
32
|
+
if (!reply.raw.destroyed) {
|
|
33
|
+
reply.raw.write(`event: metrics\n`);
|
|
34
|
+
reply.raw.write(`data: ${JSON.stringify(metrics)}\n\n`);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const heartbeatInterval = setInterval(() => {
|
|
39
|
+
if (!reply.raw.destroyed) {
|
|
40
|
+
reply.raw.write(`:heartbeat\n\n`);
|
|
41
|
+
}
|
|
42
|
+
}, 15000);
|
|
43
|
+
|
|
44
|
+
request.raw.on('close', () => {
|
|
45
|
+
unsubscribe();
|
|
46
|
+
clearInterval(heartbeatInterval);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return reply;
|
|
50
|
+
}
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
app.get(
|
|
54
|
+
'/admin/metrics/snapshot',
|
|
55
|
+
{
|
|
56
|
+
preHandler: [requireAuth, requireRole(['admin', 'super_admin'])],
|
|
57
|
+
schema: {
|
|
58
|
+
description: 'Get current metrics snapshot (Admin only)',
|
|
59
|
+
tags: ['Admin'],
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
async () => {
|
|
63
|
+
return {
|
|
64
|
+
current: metricsService.getLatest(),
|
|
65
|
+
history: metricsService.getHistory(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export default metricsRoutes;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { ValidationError } from '@repo/packages-utils/errors';
|
|
2
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
import { requireAuth } from '@/hooks/auth';
|
|
6
|
+
|
|
7
|
+
const ChangePasswordSchema = z.object({
|
|
8
|
+
currentPassword: z.string().min(8, 'Password must be at least 8 characters'),
|
|
9
|
+
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const passwordRoutes: FastifyPluginAsync = async (app) => {
|
|
13
|
+
app.post<{
|
|
14
|
+
Body: z.infer<typeof ChangePasswordSchema>;
|
|
15
|
+
}>(
|
|
16
|
+
'/password/change',
|
|
17
|
+
{
|
|
18
|
+
preHandler: requireAuth,
|
|
19
|
+
schema: {
|
|
20
|
+
body: ChangePasswordSchema,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
async (request) => {
|
|
24
|
+
const { currentPassword, newPassword } = request.body;
|
|
25
|
+
|
|
26
|
+
if (currentPassword === newPassword) {
|
|
27
|
+
throw new ValidationError(
|
|
28
|
+
'New password must be different from current password'
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
await app.passwordService.changePassword(
|
|
33
|
+
request.user!.id,
|
|
34
|
+
currentPassword,
|
|
35
|
+
newPassword
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
message:
|
|
40
|
+
'Password changed successfully. All other sessions have been revoked.',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export default passwordRoutes;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
2
|
+
|
|
3
|
+
import { requireAuth } from '@/hooks/auth';
|
|
4
|
+
|
|
5
|
+
const sessionsRoutes: FastifyPluginAsync = async (app) => {
|
|
6
|
+
app.get(
|
|
7
|
+
'/sessions',
|
|
8
|
+
{
|
|
9
|
+
preHandler: requireAuth,
|
|
10
|
+
},
|
|
11
|
+
async (request) => {
|
|
12
|
+
const sessions = await app.sessionsService.getUserSessions(
|
|
13
|
+
request.user!.id
|
|
14
|
+
);
|
|
15
|
+
return { data: sessions };
|
|
16
|
+
}
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
app.delete<{
|
|
20
|
+
Params: { sessionId: string };
|
|
21
|
+
}>(
|
|
22
|
+
'/sessions/:sessionId',
|
|
23
|
+
{
|
|
24
|
+
preHandler: requireAuth,
|
|
25
|
+
},
|
|
26
|
+
async (request) => {
|
|
27
|
+
await app.sessionsService.revokeSession(
|
|
28
|
+
request.user!.id,
|
|
29
|
+
request.params.sessionId
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
return { message: 'Session revoked successfully' };
|
|
33
|
+
}
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
app.delete(
|
|
37
|
+
'/sessions',
|
|
38
|
+
{
|
|
39
|
+
preHandler: requireAuth,
|
|
40
|
+
},
|
|
41
|
+
async (request) => {
|
|
42
|
+
const currentSessionId = request.user?.session?.id;
|
|
43
|
+
await app.sessionsService.revokeAllSessions(
|
|
44
|
+
request.user!.id,
|
|
45
|
+
currentSessionId
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
return { message: 'All other sessions revoked successfully' };
|
|
49
|
+
}
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export default sessionsRoutes;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
2
|
+
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
|
3
|
+
|
|
4
|
+
import { requireAuth, requireRole } from '@/hooks/auth';
|
|
5
|
+
|
|
6
|
+
const statsRoutes: FastifyPluginAsync = async (app) => {
|
|
7
|
+
const server = app.withTypeProvider<ZodTypeProvider>();
|
|
8
|
+
|
|
9
|
+
server.get(
|
|
10
|
+
'/admin/stats',
|
|
11
|
+
{
|
|
12
|
+
preHandler: [requireAuth, requireRole(['admin', 'super_admin'])],
|
|
13
|
+
schema: {
|
|
14
|
+
description: 'Get comprehensive system statistics (Admin only)',
|
|
15
|
+
tags: ['Admin'],
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
async () => {
|
|
19
|
+
return app.statsService.getSystemStats();
|
|
20
|
+
}
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
server.get(
|
|
24
|
+
'/admin/stats/health',
|
|
25
|
+
{
|
|
26
|
+
preHandler: [requireAuth, requireRole(['admin', 'super_admin'])],
|
|
27
|
+
schema: {
|
|
28
|
+
description: 'Get system health check with DB latency (Admin only)',
|
|
29
|
+
tags: ['Admin'],
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
async () => {
|
|
33
|
+
return app.statsService.getHealthCheck();
|
|
34
|
+
}
|
|
35
|
+
);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export default statsRoutes;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import fastifyStatic from '@fastify/static';
|
|
4
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
5
|
+
|
|
6
|
+
const uploadsServeRoutes: FastifyPluginAsync = async (app) => {
|
|
7
|
+
const storageType = app.fileStorageService.getStorageType();
|
|
8
|
+
|
|
9
|
+
// Only serve local files if using local storage
|
|
10
|
+
if (storageType === 'local') {
|
|
11
|
+
const uploadsDir = path.join(process.cwd(), 'public', 'uploads');
|
|
12
|
+
|
|
13
|
+
await app.register(fastifyStatic, {
|
|
14
|
+
root: uploadsDir,
|
|
15
|
+
prefix: '/uploads/files/',
|
|
16
|
+
decorateReply: false,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
app.log.info('[+] Local file serving configured at /uploads/files/');
|
|
20
|
+
} else {
|
|
21
|
+
app.log.info(
|
|
22
|
+
`[+] Using ${storageType} storage - local file serving disabled`
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default uploadsServeRoutes;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { MultipartFile } from '@fastify/multipart';
|
|
2
|
+
import {
|
|
3
|
+
DeleteUploadParamsSchema,
|
|
4
|
+
GetUploadsQuerySchema,
|
|
5
|
+
UploadResponseSchema,
|
|
6
|
+
UploadStatsSchema,
|
|
7
|
+
} from '@repo/packages-types/upload';
|
|
8
|
+
import {
|
|
9
|
+
UnauthorizedError,
|
|
10
|
+
ValidationError,
|
|
11
|
+
} from '@repo/packages-utils/errors';
|
|
12
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
13
|
+
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
|
|
16
|
+
import { RATE_LIMIT_CONFIG } from '@/config/rate-limit.js';
|
|
17
|
+
import { requireAuth } from '@/hooks/auth';
|
|
18
|
+
|
|
19
|
+
const uploadsRoutes: FastifyPluginAsync = async (app) => {
|
|
20
|
+
const server = app.withTypeProvider<ZodTypeProvider>();
|
|
21
|
+
|
|
22
|
+
// POST /api/uploads - Upload a file
|
|
23
|
+
server.post(
|
|
24
|
+
'/uploads',
|
|
25
|
+
{
|
|
26
|
+
onRequest: requireAuth,
|
|
27
|
+
config: {
|
|
28
|
+
rateLimit: {
|
|
29
|
+
max: RATE_LIMIT_CONFIG.routes.uploads.max,
|
|
30
|
+
timeWindow: RATE_LIMIT_CONFIG.routes.uploads.timeWindow,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
schema: {
|
|
34
|
+
description: 'Upload a file',
|
|
35
|
+
tags: ['Uploads'],
|
|
36
|
+
response: {
|
|
37
|
+
201: UploadResponseSchema,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
async (request, reply) => {
|
|
42
|
+
const userId = request.user?.id;
|
|
43
|
+
|
|
44
|
+
if (!userId) {
|
|
45
|
+
throw new UnauthorizedError();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const data = await request.file();
|
|
49
|
+
|
|
50
|
+
if (!data) {
|
|
51
|
+
throw new ValidationError('No file provided');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const file = data as MultipartFile;
|
|
55
|
+
|
|
56
|
+
const upload = await app.uploadsService.uploadFile(file, userId);
|
|
57
|
+
|
|
58
|
+
return reply.status(201).send(upload);
|
|
59
|
+
}
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// GET /api/uploads - Get user's uploads
|
|
63
|
+
server.get(
|
|
64
|
+
'/uploads',
|
|
65
|
+
{
|
|
66
|
+
onRequest: requireAuth,
|
|
67
|
+
schema: {
|
|
68
|
+
querystring: GetUploadsQuerySchema,
|
|
69
|
+
description: 'Get paginated list of user uploads',
|
|
70
|
+
tags: ['Uploads'],
|
|
71
|
+
response: {
|
|
72
|
+
200: z.array(
|
|
73
|
+
UploadResponseSchema.omit({ user: true }).extend({
|
|
74
|
+
userId: z.string(),
|
|
75
|
+
})
|
|
76
|
+
),
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
async (request) => {
|
|
81
|
+
const userId = request.user?.id;
|
|
82
|
+
|
|
83
|
+
if (!userId) {
|
|
84
|
+
throw new UnauthorizedError();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const { limit, offset } = request.query;
|
|
88
|
+
|
|
89
|
+
const uploads = await app.uploadsService.getUserUploads(userId, {
|
|
90
|
+
limit,
|
|
91
|
+
offset,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return uploads;
|
|
95
|
+
}
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// GET /api/uploads/stats - Get upload statistics
|
|
99
|
+
server.get(
|
|
100
|
+
'/uploads/stats',
|
|
101
|
+
{
|
|
102
|
+
onRequest: requireAuth,
|
|
103
|
+
schema: {
|
|
104
|
+
description: 'Get upload statistics for current user',
|
|
105
|
+
tags: ['Uploads'],
|
|
106
|
+
response: {
|
|
107
|
+
200: UploadStatsSchema,
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
async (request) => {
|
|
112
|
+
const userId = request.user?.id;
|
|
113
|
+
|
|
114
|
+
if (!userId) {
|
|
115
|
+
throw new UnauthorizedError();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const stats = await app.uploadsService.getUploadStats(userId);
|
|
119
|
+
|
|
120
|
+
return stats;
|
|
121
|
+
}
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// DELETE /api/uploads/:id - Delete an upload
|
|
125
|
+
server.delete(
|
|
126
|
+
'/uploads/:id',
|
|
127
|
+
{
|
|
128
|
+
onRequest: requireAuth,
|
|
129
|
+
schema: {
|
|
130
|
+
params: DeleteUploadParamsSchema,
|
|
131
|
+
description: 'Delete an upload',
|
|
132
|
+
tags: ['Uploads'],
|
|
133
|
+
response: {
|
|
134
|
+
204: z.void(),
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
async (request, reply) => {
|
|
139
|
+
const userId = request.user?.id;
|
|
140
|
+
|
|
141
|
+
if (!userId) {
|
|
142
|
+
throw new UnauthorizedError();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const { id } = request.params;
|
|
146
|
+
|
|
147
|
+
await app.uploadsService.deleteUpload(id, userId);
|
|
148
|
+
|
|
149
|
+
return reply.status(204).send();
|
|
150
|
+
}
|
|
151
|
+
);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export default uploadsRoutes;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { QueryUsersSchema } from '@repo/packages-types/pagination';
|
|
2
|
+
import {
|
|
3
|
+
CreateUserSchema,
|
|
4
|
+
GetUserByIdSchema,
|
|
5
|
+
UpdateUserSchema,
|
|
6
|
+
} from '@repo/packages-types/user';
|
|
7
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
8
|
+
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
|
9
|
+
|
|
10
|
+
import { requireAuth, requireRole } from '@/hooks/auth';
|
|
11
|
+
|
|
12
|
+
const usersRoutes: FastifyPluginAsync = async (app) => {
|
|
13
|
+
const server = app.withTypeProvider<ZodTypeProvider>();
|
|
14
|
+
|
|
15
|
+
// GET /users - Get paginated users with filtering and sorting (Admin only)
|
|
16
|
+
server.get(
|
|
17
|
+
'/users',
|
|
18
|
+
{
|
|
19
|
+
preHandler: [requireAuth, requireRole(['admin', 'super_admin'])],
|
|
20
|
+
schema: {
|
|
21
|
+
querystring: QueryUsersSchema,
|
|
22
|
+
description:
|
|
23
|
+
'Get paginated users with filtering and sorting (Admin only)',
|
|
24
|
+
tags: ['Users'],
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
async (request) => {
|
|
28
|
+
return app.usersService.getUsers(request.query);
|
|
29
|
+
}
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// GET /users/:id - Get user by ID (Admin only)
|
|
33
|
+
server.get(
|
|
34
|
+
'/users/:id',
|
|
35
|
+
{
|
|
36
|
+
preHandler: [requireAuth, requireRole(['admin', 'super_admin'])],
|
|
37
|
+
schema: {
|
|
38
|
+
params: GetUserByIdSchema,
|
|
39
|
+
description: 'Get user by ID (Admin only)',
|
|
40
|
+
tags: ['Users'],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
async (request) => {
|
|
44
|
+
const user = await app.usersService.getUserById(request.params.id);
|
|
45
|
+
return { data: user };
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// POST /users - Create a new user (Admin only)
|
|
50
|
+
server.post(
|
|
51
|
+
'/users',
|
|
52
|
+
{
|
|
53
|
+
preHandler: [requireAuth, requireRole(['admin', 'super_admin'])],
|
|
54
|
+
schema: {
|
|
55
|
+
body: CreateUserSchema,
|
|
56
|
+
description: 'Create a new user (Admin only)',
|
|
57
|
+
tags: ['Users'],
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
async (request, reply) => {
|
|
61
|
+
const user = await app.usersService.createUser(request.body);
|
|
62
|
+
|
|
63
|
+
return reply.status(201).send({ data: user });
|
|
64
|
+
}
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// PATCH /users/:id - Update user by ID (Admin only)
|
|
68
|
+
server.patch(
|
|
69
|
+
'/users/:id',
|
|
70
|
+
{
|
|
71
|
+
preHandler: [requireAuth, requireRole(['admin', 'super_admin'])],
|
|
72
|
+
schema: {
|
|
73
|
+
params: GetUserByIdSchema,
|
|
74
|
+
body: UpdateUserSchema,
|
|
75
|
+
description: 'Update user by ID (Admin only)',
|
|
76
|
+
tags: ['Users'],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
async (request) => {
|
|
80
|
+
const user = await app.usersService.updateUser(
|
|
81
|
+
request.user!.id,
|
|
82
|
+
request.user!.role as 'user' | 'admin' | 'super_admin',
|
|
83
|
+
request.params.id,
|
|
84
|
+
request.body
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
return { data: user };
|
|
88
|
+
}
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// DELETE /users/:id - Delete user by ID (Admin only)
|
|
92
|
+
server.delete(
|
|
93
|
+
'/users/:id',
|
|
94
|
+
{
|
|
95
|
+
preHandler: [requireAuth, requireRole(['admin', 'super_admin'])],
|
|
96
|
+
schema: {
|
|
97
|
+
params: GetUserByIdSchema,
|
|
98
|
+
description: 'Delete user by ID (Admin only)',
|
|
99
|
+
tags: ['Users'],
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
async (request) => {
|
|
103
|
+
await app.usersService.deleteUser(
|
|
104
|
+
request.user!.id,
|
|
105
|
+
request.user!.role as 'user' | 'admin' | 'super_admin',
|
|
106
|
+
request.params.id
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
return { message: 'User deleted successfully' };
|
|
110
|
+
}
|
|
111
|
+
);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export default usersRoutes;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
2
|
+
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
import { requireAuth } from '@/hooks/auth';
|
|
6
|
+
|
|
7
|
+
const ResendVerificationSchema = z.object({
|
|
8
|
+
email: z.string().email().optional(),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const verificationRoutes: FastifyPluginAsync = async (app) => {
|
|
12
|
+
const server = app.withTypeProvider<ZodTypeProvider>();
|
|
13
|
+
|
|
14
|
+
// POST /verification/resend - Resend verification email
|
|
15
|
+
server.post(
|
|
16
|
+
'/verification/resend',
|
|
17
|
+
{
|
|
18
|
+
preHandler: [requireAuth],
|
|
19
|
+
schema: {
|
|
20
|
+
body: ResendVerificationSchema,
|
|
21
|
+
description:
|
|
22
|
+
'Resend verification email to authenticated user or specified email',
|
|
23
|
+
tags: ['Verification'],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
async (request, reply) => {
|
|
27
|
+
const currentUser = request.user;
|
|
28
|
+
if (!currentUser) {
|
|
29
|
+
return reply.status(401).send({
|
|
30
|
+
error: 'Unauthorized',
|
|
31
|
+
message: 'Authentication required',
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const targetEmail = request.body.email || currentUser.email;
|
|
36
|
+
|
|
37
|
+
if (request.body.email && request.body.email !== currentUser.email) {
|
|
38
|
+
return reply.status(403).send({
|
|
39
|
+
error: 'Forbidden',
|
|
40
|
+
message: 'You can only resend verification for your own email',
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const user = await app.prisma.user.findUnique({
|
|
45
|
+
where: { email: targetEmail },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (!user) {
|
|
49
|
+
return reply.status(404).send({
|
|
50
|
+
error: 'Not Found',
|
|
51
|
+
message: 'User not found',
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (user.emailVerified) {
|
|
56
|
+
return reply.status(400).send({
|
|
57
|
+
error: 'Bad Request',
|
|
58
|
+
message: 'Email is already verified',
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
await app.auth.api.sendVerificationEmail({
|
|
64
|
+
body: {
|
|
65
|
+
email: user.email,
|
|
66
|
+
callbackURL: app.env.FRONTEND_URL,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return { message: 'Verification email sent successfully' };
|
|
71
|
+
} catch (error) {
|
|
72
|
+
request.log.error(
|
|
73
|
+
{
|
|
74
|
+
err: error instanceof Error ? error : new Error(String(error)),
|
|
75
|
+
email: user.email,
|
|
76
|
+
},
|
|
77
|
+
'Failed to send verification email via Better Auth'
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
return reply.status(500).send({
|
|
81
|
+
error: 'Email Send Failed',
|
|
82
|
+
message:
|
|
83
|
+
'Failed to send verification email. Please try again later or contact support.',
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export default verificationRoutes;
|