@xenterprises/fastify-xconfig 1.0.2 → 1.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 (55) hide show
  1. package/README.md +171 -18
  2. package/dist/utils/randomUUID.d.ts +1 -1
  3. package/dist/xConfig.d.ts +1 -2
  4. package/dist/xConfig.js +2 -7
  5. package/dist/xConfig.js.map +1 -1
  6. package/package.json +24 -43
  7. package/server/app.js +78 -0
  8. package/src/auth/admin.js +181 -0
  9. package/src/auth/portal.js +177 -0
  10. package/src/integrations/cloudinary.js +98 -0
  11. package/src/integrations/geocode.js +43 -0
  12. package/src/integrations/prisma.js +30 -0
  13. package/src/integrations/sendgrid.js +58 -0
  14. package/src/integrations/twilio.js +146 -0
  15. package/src/lifecycle/xFastifyAfter.js +27 -0
  16. package/src/middleware/bugsnag.js +10 -0
  17. package/src/middleware/cors.js +10 -0
  18. package/src/middleware/fancyErrors.js +26 -0
  19. package/src/middleware/multipart.js +6 -0
  20. package/src/middleware/rateLimit.js +6 -0
  21. package/src/middleware/underPressure.js +6 -0
  22. package/src/utils/colorize.js +37 -0
  23. package/src/utils/cookie.js +5 -0
  24. package/src/utils/formatBytes.js +16 -0
  25. package/src/utils/health.js +126 -0
  26. package/src/utils/xEcho.js +12 -0
  27. package/src/utils/xSlugify.js +20 -0
  28. package/src/utils/xUUID.js +14 -0
  29. package/src/xConfig.js +117 -0
  30. package/test/index.js +17 -0
  31. package/tsconfig.json +9 -11
  32. package/xConfigReference.js +1526 -0
  33. package/xConfigWorkingList.js +720 -0
  34. package/.github/workflows/ci.yml +0 -19
  35. package/.taprc +0 -3
  36. package/example.ts +0 -13
  37. package/src/xConfig.ts +0 -20
  38. package/test/index.test-d.ts +0 -13
  39. package/test/index.test.js +0 -14
  40. package/test/xConfig.js +0 -115
  41. /package/{src → ts-reference}/integrations/cloudinary.ts +0 -0
  42. /package/{src → ts-reference}/integrations/prisma.ts +0 -0
  43. /package/{src → ts-reference}/integrations/sendgrid.ts +0 -0
  44. /package/{src → ts-reference}/integrations/stripe.ts +0 -0
  45. /package/{src → ts-reference}/integrations/twilio.ts +0 -0
  46. /package/{src → ts-reference}/middleware/bugsnag.ts +0 -0
  47. /package/{src → ts-reference}/middleware/cors.ts +0 -0
  48. /package/{src → ts-reference}/middleware/errorHandler.ts +0 -0
  49. /package/{src → ts-reference}/middleware/multipart.ts +0 -0
  50. /package/{src → ts-reference}/middleware/rateLimit.ts +0 -0
  51. /package/{src → ts-reference}/middleware/underPressure.ts +0 -0
  52. /package/{src → ts-reference}/utils/colorize.ts +0 -0
  53. /package/{src → ts-reference}/utils/formatBytes.ts +0 -0
  54. /package/{src → ts-reference}/utils/randomUUID.ts +0 -0
  55. /package/{src → ts-reference}/utils/statAsync.ts +0 -0
@@ -0,0 +1,1526 @@
1
+ /**
2
+ * *xConfig - Fastify configuration plugin for setting up middleware, services, and route handling.*
3
+ *
4
+ * This plugin provides a comprehensive setup for configuring various services such as Prisma, SendGrid,
5
+ * Twilio, Cloudinary, CORS, rate limiting, authentication, and more within a Fastify instance.
6
+ * It centralizes configuration, allowing for easy customization and scaling.
7
+ *
8
+ * *Key Features:*
9
+ * - Handles CORS, rate-limiting, multipart handling, and error handling out of the box.
10
+ * - Integrates with third-party services such as SendGrid, Twilio, Cloudinary, Stripe, and Bugsnag.
11
+ * - Provides authentication setup for both Admin and User, with Stack Auth integration.
12
+ * - Includes Prisma database connection and gracefully handles disconnecting on server shutdown.
13
+ * - Adds health check routes and resource usage monitoring, with environment validation.
14
+ * - Customizes routes listing and applies various performance and security options.
15
+ *
16
+ * *Usage:*
17
+ * This plugin should be registered in your Fastify instance with custom options provided
18
+ * for each service. It ensures proper initialization of services like SendGrid, Twilio, and Prisma.
19
+ *
20
+ * *Example:*
21
+ * ```typescript
22
+ * import Fastify from 'fastify';
23
+ * import xConfig from './path/to/xConfig';
24
+ *
25
+ * const fastify = Fastify();
26
+ * fastify.register(xConfig, {
27
+ * prisma: { active: true },
28
+ * sendGrid: { apiKey: 'your-sendgrid-api-key' },
29
+ * twilio: { accountSid: 'your-account-sid', authToken: 'your-auth-token', phoneNumber: 'your-twilio-number' },
30
+ * cloudinary: { cloudName: 'your-cloud-name', apiKey: 'your-api-key', apiSecret: 'your-api-secret' },
31
+ * auth: {
32
+ * excludedPaths: ['/public', '/portal/auth/register'],
33
+ * admin: {
34
+ * active: true,
35
+ * stackProjectId: 'your-admin-stack-project-id',
36
+ * publishableKey: 'your-admin-stack-publishable-key',
37
+ * secretKey: 'your-admin-stack-secret-key',
38
+ * },
39
+ * user: {
40
+ * active: true,
41
+ * stackProjectId: 'your-user-stack-project-id',
42
+ * publishableKey: 'your-user-stack-publishable-key',
43
+ * secretKey: 'your-user-stack-secret-key',
44
+ * },
45
+ * },
46
+ * cors: { origin: ['https://your-frontend.com'], credentials: true },
47
+ * });
48
+ *
49
+ * fastify.listen({ port: 3000 });
50
+ * ```
51
+ *
52
+ * *Parameters:*
53
+ * @param {Object} options - The options for configuring various plugins and services.
54
+ * - prisma: Prisma Client configuration (optional).
55
+ * - sendGrid: SendGrid configuration for email services (optional).
56
+ * - twilio: Twilio configuration for SMS services (optional).
57
+ * - cloudinary: Cloudinary configuration for media upload services (optional).
58
+ * - auth: Authentication configuration for Admin and User with Stack Auth integration (optional).
59
+ * - cors: CORS configuration (optional).
60
+ * - rateLimit: Rate-limiting options (optional).
61
+ * - multipart: Multipart handling options for file uploads (optional).
62
+ * - bugsnag: Bugsnag error reporting configuration (optional).
63
+ * - underPressure: Under Pressure plugin options for monitoring and throttling under load (optional).
64
+ *
65
+ * *Authentication:*
66
+ * The plugin provides both Admin and User authentication with Stack Auth integration. It handles token verification,
67
+ * protected routes, and authentication endpoints. For example:
68
+ * - `/admin/auth/login` for admin login.
69
+ * - `/portal/auth/login` for user login.
70
+ * - `/admin/auth/me` to check authentication status for admins.
71
+ * - `/portal/auth/me` to check authentication status for users.
72
+ * - `/admin/auth/verify` to verify admin tokens.
73
+ * - `/portal/auth/verify` to verify user tokens.
74
+ *
75
+ * *Stack Auth Integration:*
76
+ * The plugin integrates with Stack Auth (https://stack-auth.com) for secure authentication.
77
+ * You'll need to provide:
78
+ * - `stackProjectId` - Your Stack Auth project ID
79
+ * - `publishableKey` - Your Stack Auth publishable client key
80
+ * - `secretKey` - Your Stack Auth secret server key
81
+ *
82
+ * These can be provided via environment variables or in the plugin options.
83
+ *
84
+ * *Health Check:*
85
+ * The `/health` route provides a health check with details about uptime, memory usage, CPU load,
86
+ * database and Redis status, and environment variable validation.
87
+ *
88
+ * *Services:*
89
+ * - **Prisma**: Initializes Prisma Client and decorates the Fastify instance for database queries.
90
+ * - **SendGrid**: Provides an email sending service through SendGrid, integrated with the Fastify instance.
91
+ * - **Twilio**: Provides SMS sending and phone number validation services.
92
+ * - **Cloudinary**: Handles file uploads to Cloudinary and deletion of media files.
93
+ * - **Authentication**: Stack Auth integration for secure user and admin authentication.
94
+ *
95
+ * *Hooks:*
96
+ * - `onRequest`: Validates tokens for protected routes.
97
+ * - `onClose`: Gracefully disconnects Prisma and other services on server shutdown.
98
+ *
99
+ * *Error Handling:*
100
+ * Fancy error handling is enabled by default, showing enhanced error messages during development.
101
+ * Bugsnag integration is optional for real-time error reporting.
102
+ *
103
+ * *Route Listing:*
104
+ * The plugin prints all routes after registration, color-coded by HTTP method, unless disabled.
105
+ *
106
+ * *Dependencies:*
107
+ * - Prisma Client, SendGrid, Twilio, Cloudinary, Fastify CORS, Fastify Rate Limit, Fastify Multipart,
108
+ * Fastify Under Pressure, and Fastify Bugsnag are used for various services and integrations.
109
+ *
110
+ * *Notes:*
111
+ * - Environment variables such as `DATABASE_URL`, `ADMIN_STACK_PROJECT_ID`, and `USER_STACK_PROJECT_ID` are expected to be set.
112
+ * - Services like SendGrid, Twilio, and Cloudinary require API keys to be passed via the options.
113
+ * - This plugin is highly customizable, with options to enable/disable each feature.
114
+ *
115
+ * *Environment Variables:*
116
+ * - `ADMIN_STACK_PROJECT_ID` - Stack Auth project ID for admin authentication
117
+ * - `ADMIN_STACK_PUBLISHABLE_CLIENT_KEY` - Stack Auth publishable key for admin authentication
118
+ * - `ADMIN_STACK_SECRET_SERVER_KEY` - Stack Auth secret key for admin authentication
119
+ * - `USER_STACK_PROJECT_ID` - Stack Auth project ID for user authentication
120
+ * - `USER_STACK_PUBLISHABLE_CLIENT_KEY` - Stack Auth publishable key for user authentication
121
+ * - `USER_STACK_SECRET_SERVER_KEY` - Stack Auth secret key for user authentication
122
+ *
123
+ * *Example Configuration:*
124
+ * ```javascript
125
+ * fastify.register(xConfig, {
126
+ * prisma: { active: true },
127
+ * sendGrid: { apiKey: 'SG.your_api_key' },
128
+ * twilio: {
129
+ * accountSid: 'ACxxxxxxxxxxxxxxxx',
130
+ * authToken: 'your_auth_token',
131
+ * phoneNumber: '+1234567890',
132
+ * },
133
+ * cloudinary: {
134
+ * cloudName: 'your-cloud-name',
135
+ * apiKey: 'your-api-key',
136
+ * apiSecret: 'your-api-secret',
137
+ * },
138
+ * auth: {
139
+ * excludedPaths: ['/public', '/portal/auth/login', '/portal/auth/register'],
140
+ * admin: {
141
+ * active: true,
142
+ * stackProjectId: 'your-admin-stack-project-id',
143
+ * publishableKey: 'your-admin-stack-publishable-key',
144
+ * secretKey: 'your-admin-stack-secret-key',
145
+ * },
146
+ * user: {
147
+ * active: true,
148
+ * stackProjectId: 'your-user-stack-project-id',
149
+ * publishableKey: 'your-user-stack-publishable-key',
150
+ * secretKey: 'your-user-stack-secret-key',
151
+ * },
152
+ * },
153
+ * cors: { origin: ['https://your-frontend.com'], credentials: true },
154
+ * });
155
+ * ```
156
+ */
157
+
158
+ import fp from "fastify-plugin";
159
+ import jose from "jose";
160
+ import { PrismaClient } from "@prisma/client";
161
+ import Sendgrid from '@sendgrid/mail';
162
+ import sgClient from '@sendgrid/client';
163
+ import Twilio from "twilio";
164
+ import { v2 as Cloudinary } from "cloudinary";
165
+ const isProduction = process.env.NODE_ENV === 'production';
166
+ import Stripe from 'stripe';
167
+ /*
168
+ ===== SET VARS =====
169
+ Setting variables for the config
170
+ */
171
+ const COLORS = { POST: 33, GET: 32, PUT: 34, DELETE: 31, PATCH: 90, clear: 39 };
172
+ const colorize = (m, t) =>
173
+ `\u001b[${COLORS[m] || COLORS.clear}m${t}\u001b[${COLORS.clear}m`;
174
+ const printRoutes = (routes, colors = true) =>
175
+ routes
176
+ .sort((a, b) => a.url.localeCompare(b.url))
177
+ .forEach(({ method, url }) => {
178
+ // Ensure method is always an array
179
+ const methodsArray = Array.isArray(method) ? method : [method];
180
+
181
+ // Filter out 'HEAD' methods
182
+ methodsArray
183
+ .filter((m) => m !== 'HEAD')
184
+ .forEach((m) =>
185
+ console.info(
186
+ `${colors ? colorize(m, m) : m}\t${colors ? colorize(m, url) : url}`
187
+ )
188
+ );
189
+ });
190
+
191
+ // Import necessary modules
192
+ import os from "os";
193
+ import process from "process";
194
+ import fs from "fs";
195
+ import util from "util";
196
+
197
+ // Promisify fs functions
198
+ const statAsync = util.promisify(fs.stat);
199
+
200
+ // Record the server start time
201
+ const serverStartTime = Date.now();
202
+
203
+ // Helper function to format bytes into human-readable format
204
+ function formatBytes(bytes, decimals = 2) {
205
+ if (bytes === 0) return "0 Bytes";
206
+ const k = 1024;
207
+ const dm = decimals < 0 ? 0 : decimals;
208
+ const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB"];
209
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
210
+ const formattedNumber = parseFloat((bytes / Math.pow(k, i)).toFixed(dm));
211
+ return `${formattedNumber} ${sizes[i]}`;
212
+ }
213
+
214
+ /*
215
+ ===== SET MAIN FUNCTION AND OPTIONS =====
216
+ Setting variables for the config
217
+ */
218
+ async function xConfig(fastify, options) {
219
+ const {
220
+ professional = false,
221
+ fancyErrors = true,
222
+ prisma: prismaOptions = {},
223
+ bugsnag: bugsnagOptions = {},
224
+ stripe: stripeOptions = {},
225
+ sendGrid: sendGridOptions = {},
226
+ twilio: twilioOptions = {},
227
+ cloudinary: cloudinaryOptions = {},
228
+ auth: authOptions = {},
229
+ cors: corsOptions = {},
230
+ underPressure: underPressureOptions = {},
231
+ multipart: multipartOptions = {},
232
+ rateLimit: rateLimitOptions = {},
233
+ geocode: geocodeOptions = {},
234
+ } = options;
235
+
236
+ // add starting console with emoji
237
+ console.info("\n 🌮 Starting xConfig...\n");
238
+
239
+ /*
240
+ ===== LIST ROUTES =====
241
+ Moved the onRoute hook to the top to capture all routes.
242
+ */
243
+ const routes = [];
244
+ fastify.addHook("onRoute", (r) => routes.push(r));
245
+
246
+ /*
247
+ ===== CORS =====
248
+ */
249
+ if (corsOptions.active !== false) {
250
+ await fastify.register(import("@fastify/cors"), {
251
+ origin: corsOptions.origin || [
252
+ "https://getx.io",
253
+ "http://localhost:3000",
254
+ ],
255
+ credentials:
256
+ corsOptions.credentials !== undefined ? corsOptions.credentials : true,
257
+ methods: corsOptions.methods || [
258
+ "GET",
259
+ "POST",
260
+ "PUT",
261
+ "DELETE",
262
+ "OPTIONS",
263
+ ],
264
+ strictPreflight: false,
265
+ hideOptionsRoute: true,
266
+ allowedHeaders: [
267
+ "Content-Type", // Standard content-type headers
268
+ "Authorization", // Needed for JWT or OAuth authentication
269
+ "Accept", // Accept headers for specifying response type
270
+ "DNT", // Do Not Track header
271
+ "Referer" // Referrer header for tracking
272
+ ],
273
+ exposedHeaders: [
274
+ "Authorization", // For accessing token headers after login
275
+ "Set-Cookie" // Expose Set-Cookie header to client
276
+ ],
277
+ });
278
+ console.info(" ✅ CORS Enabled");
279
+ }
280
+
281
+ /*
282
+ ===== SENSIBLE =====
283
+ */
284
+ fastify.register(import('@fastify/sensible'))
285
+
286
+ /*
287
+ ===== UNDER PRESSURE =====
288
+ */
289
+ if (underPressureOptions.active !== false) {
290
+ fastify.register(import("@fastify/under-pressure"), underPressureOptions);
291
+ console.info(" ✅ Under Pressure Enabled");
292
+ }
293
+
294
+ /*
295
+ ===== RATE LIMIT =====
296
+ */
297
+ if (rateLimitOptions.active !== false) {
298
+ fastify.register(import("@fastify/rate-limit"), rateLimitOptions);
299
+ console.info(" ✅ Rate Limiting Enabled");
300
+ }
301
+
302
+ /*
303
+ ===== MULTIPART =====
304
+ */
305
+ if (multipartOptions.active !== false) {
306
+ fastify.register(import("@fastify/multipart"), multipartOptions);
307
+ console.info(" ✅ Multipart Enabled");
308
+ }
309
+
310
+ /*
311
+ ===== BUGSNAG =====
312
+ */
313
+ if (bugsnagOptions.active !== false) {
314
+ if (!bugsnagOptions.apiKey)
315
+ throw new Error("Bugsnag API key must be provided.");
316
+ fastify.register(import("fastify-bugsnag"), {
317
+ apiKey: bugsnagOptions.apiKey,
318
+ });
319
+ console.info(" ✅ BugSnag Enabled");
320
+ }
321
+
322
+ /*
323
+ ===== EXTEND ERRORS =====
324
+ */
325
+ if (fancyErrors !== false) {
326
+ fastify.setErrorHandler((error, request, reply) => {
327
+ const statusCode = error.statusCode || 500;
328
+ const response = {
329
+ status: statusCode,
330
+ message: error.message || "Internal Server Error",
331
+ // Only show stack in development mode
332
+ stack: process.env.NODE_ENV === "development" ? error.stack : undefined,
333
+ };
334
+
335
+ // Optional Bugsnag error reporting
336
+ if (fastify.bugsnag) {
337
+ fastify.bugsnag.notify(error);
338
+ }
339
+
340
+ fastify.log.error(response);
341
+ reply.status(statusCode).send(response);
342
+ });
343
+ console.info(" ✅ Fancy Errors Enabled");
344
+ }
345
+
346
+ /*
347
+ ===== PRISMA =====
348
+ */
349
+ if (prismaOptions.active !== false) {
350
+ delete prismaOptions.active;
351
+ const prisma = new PrismaClient(prismaOptions);
352
+
353
+ // Connect to the database
354
+ await prisma.$connect();
355
+
356
+ // Decorate Fastify instance with Prisma Client
357
+ fastify.decorate("prisma", prisma);
358
+
359
+ // Disconnect Prisma Client when Fastify closes
360
+ fastify.addHook("onClose", async () => {
361
+ await fastify.prisma.$disconnect();
362
+ });
363
+
364
+ console.info(" ✅ Prisma Enabled");
365
+ }
366
+
367
+ /*
368
+ ===== STRIPE =====
369
+ */
370
+ if (stripeOptions.active === true) {
371
+ if (!stripeOptions.apiKey) throw new Error("Stripe API key must be provided.");
372
+
373
+
374
+
375
+ const stripeClient = new Stripe(process.env.STRIPE_SECRET_KEY, {
376
+ apiVersion: '2022-11-15',
377
+ });
378
+
379
+ const stripePlugin = async function (fastify, options) {
380
+ fastify.decorate('stripe', {
381
+ // Create a Payment Intent (for processing payments)
382
+ createPaymentIntent: async (amount, currency = 'usd', customerId) => {
383
+ try {
384
+ const paymentIntent = await stripeClient.paymentIntents.create({
385
+ amount,
386
+ currency,
387
+ customer: customerId,
388
+ });
389
+ return paymentIntent;
390
+ } catch (error) {
391
+ fastify.log.error('Failed to create payment intent:', error);
392
+ throw new Error('Failed to create payment intent.');
393
+ }
394
+ },
395
+
396
+ // Create a Customer
397
+ createCustomer: async (email, name, paymentMethodId) => {
398
+ try {
399
+ const customer = await stripeClient.customers.create({
400
+ email,
401
+ name,
402
+ payment_method: paymentMethodId,
403
+ invoice_settings: {
404
+ default_payment_method: paymentMethodId,
405
+ },
406
+ });
407
+ return customer;
408
+ } catch (error) {
409
+ fastify.log.error('Failed to create customer:', error);
410
+ throw new Error('Failed to create customer.');
411
+ }
412
+ },
413
+
414
+ // Create a Subscription (recurring payments)
415
+ createSubscription: async (customerId, priceId) => {
416
+ try {
417
+ const subscription = await stripeClient.subscriptions.create({
418
+ customer: customerId,
419
+ items: [{ price: priceId }],
420
+ expand: ['latest_invoice.payment_intent'],
421
+ });
422
+ return subscription;
423
+ } catch (error) {
424
+ fastify.log.error('Failed to create subscription:', error);
425
+ throw new Error('Failed to create subscription.');
426
+ }
427
+ },
428
+
429
+ // Retrieve Payment Details
430
+ retrievePayment: async (paymentIntentId) => {
431
+ try {
432
+ const paymentIntent = await stripeClient.paymentIntents.retrieve(paymentIntentId);
433
+ return paymentIntent;
434
+ } catch (error) {
435
+ fastify.log.error('Failed to retrieve payment intent:', error);
436
+ throw new Error('Failed to retrieve payment intent.');
437
+ }
438
+ },
439
+
440
+ // Create an Invoice
441
+ createInvoice: async (customerId, items) => {
442
+ try {
443
+ const invoiceItemPromises = items.map(async item => {
444
+ return stripeClient.invoiceItems.create({
445
+ customer: customerId,
446
+ price: item.priceId, // Assuming you have the price ID
447
+ });
448
+ });
449
+ await Promise.all(invoiceItemPromises);
450
+
451
+ const invoice = await stripeClient.invoices.create({
452
+ customer: customerId,
453
+ auto_advance: true, // Automatically finalize the invoice
454
+ });
455
+ return invoice;
456
+ } catch (error) {
457
+ fastify.log.error('Failed to create invoice:', error);
458
+ throw new Error('Failed to create invoice.');
459
+ }
460
+ },
461
+
462
+ // Handle Webhooks (e.g., payment success, failed, etc.)
463
+ handleWebhook: async (req, res, signature, endpointSecret) => {
464
+ let event;
465
+ try {
466
+ event = stripeClient.webhooks.constructEvent(
467
+ req.rawBody, // Stripe requires the raw body for webhooks
468
+ signature,
469
+ endpointSecret
470
+ );
471
+ } catch (error) {
472
+ fastify.log.error('Failed to verify webhook signature:', error);
473
+ throw new Error('Webhook signature verification failed.');
474
+ }
475
+
476
+ // Process the event (e.g., payment success, subscription created)
477
+ return event;
478
+ },
479
+ });
480
+
481
+ console.info(" ✅ Stripe Enabled");
482
+ }
483
+ }
484
+
485
+ /*
486
+ ===== SENDGRID =====
487
+ */
488
+ if (sendGridOptions.active !== false) {
489
+ if (!sendGridOptions.apiKey)
490
+ throw new Error("SendGrid API key must be provided.");
491
+
492
+ Sendgrid.setApiKey(sendGridOptions.apiKey);
493
+ sgClient.setApiKey(sendGridOptions.apiKeyEmailValidation);
494
+ // Decorator to group SendGrid-related methods under fastify.sendGrid
495
+ fastify.decorate('sendgrid', {
496
+ sendEmail: async (to, subject, templateId, dynamicTemplateData) => {
497
+ try {
498
+ const msg = {
499
+ to,
500
+ from: sendGridOptions.fromEmail,
501
+ subject,
502
+ templateId,
503
+ dynamicTemplateData,
504
+ };
505
+ await Sendgrid.send(msg);
506
+ return true;
507
+ } catch (error) {
508
+ fastify.log.error("SendGrid send email failed:", error);
509
+ throw new Error("Failed to send email.");
510
+ }
511
+ },
512
+ validateEmail: async (email) => {
513
+ const data = {
514
+ email: email,
515
+ // source: 'signup',
516
+ };
517
+
518
+ const request = {
519
+ url: `/v3/validations/email`,
520
+ method: 'POST',
521
+ body: data,
522
+ };
523
+
524
+ try {
525
+ const [response, body] = await sgClient.request(request);
526
+ console.log(body)
527
+ if (response.statusCode === 200) {
528
+ return body?.result; // Return the validation result
529
+ } else {
530
+ throw new Error(body.errors ? body.errors.map(err => err.message).join(', ') : 'Failed to validate email.');
531
+ }
532
+ } catch (error) {
533
+ fastify.log.error('SendGrid email validation failed:', error);
534
+ throw new Error('Failed to validate email.');
535
+ }
536
+ }
537
+ });
538
+
539
+ console.info(" ✅ SendGrid Enabled");
540
+ }
541
+
542
+ /*
543
+ ===== TWILIO =====
544
+ */
545
+ if (twilioOptions.active !== false) {
546
+ // Return if missing Twilio credentials
547
+ if (
548
+ !twilioOptions.accountSid ||
549
+ !twilioOptions.authToken ||
550
+ !twilioOptions.phoneNumber
551
+ )
552
+ throw new Error(
553
+ "Twilio accountSid, authToken, and phoneNumber must be provided."
554
+ );
555
+
556
+ const twilioClient = Twilio(
557
+ twilioOptions.accountSid,
558
+ twilioOptions.authToken
559
+ );
560
+
561
+ // Decorator to group Twilio-related methods under fastify.twilio
562
+ fastify.decorate('twilio', {
563
+ sendSMS: async (to, body) => {
564
+ try {
565
+ const message = await twilioClient.messages.create({
566
+ body,
567
+ to,
568
+ from: twilioOptions.phoneNumber, // Use the Twilio phone number from options
569
+ });
570
+ return message;
571
+ } catch (error) {
572
+ fastify.log.error("Twilio send SMS failed:", error);
573
+ throw new Error("Failed to send SMS.");
574
+ }
575
+ },
576
+ sendMMS: async (to, body, mediaUrl) => {
577
+ try {
578
+ const message = await twilioClient.messages.create({
579
+ body,
580
+ to,
581
+ from: twilioOptions.phoneNumber,
582
+ mediaUrl: [mediaUrl], // Array of media URLs
583
+ });
584
+ return message;
585
+ } catch (error) {
586
+ fastify.log.error("Twilio send MMS failed:", error);
587
+ throw new Error("Failed to send MMS.");
588
+ }
589
+ },
590
+ makeVoiceCall: async (to, twimlUrl) => {
591
+ try {
592
+ const call = await twilioClient.calls.create({
593
+ to,
594
+ from: twilioOptions.phoneNumber,
595
+ url: twimlUrl, // URL that provides TwiML instructions
596
+ });
597
+ return call;
598
+ } catch (error) {
599
+ fastify.log.error("Twilio make voice call failed:", error);
600
+ throw new Error("Failed to make voice call.");
601
+ }
602
+ },
603
+ sendVerificationCode: async (to, channel = 'sms') => {
604
+ try {
605
+ const verification = await twilioClient.verify.services(twilioOptions.verifyServiceSid)
606
+ .verifications
607
+ .create({ to, channel }); // channel can be 'sms' or 'call'
608
+ return verification;
609
+ } catch (error) {
610
+ fastify.log.error("Twilio send verification code failed:", error);
611
+ throw new Error("Failed to send verification code.");
612
+ }
613
+ },
614
+ verifyCode: async (to, code) => {
615
+ try {
616
+ const verificationCheck = await twilioClient.verify.services(twilioOptions.verifyServiceSid)
617
+ .verificationChecks
618
+ .create({ to, code });
619
+ return verificationCheck;
620
+ } catch (error) {
621
+ fastify.log.error("Twilio verify code failed:", error);
622
+ throw new Error("Failed to verify code.");
623
+ }
624
+ },
625
+ fetchSMSHistory: async (fromDate, toDate) => {
626
+ try {
627
+ const messages = await twilioClient.messages.list({
628
+ dateSentAfter: fromDate,
629
+ dateSentBefore: toDate,
630
+ limit: 100,
631
+ });
632
+ return messages;
633
+ } catch (error) {
634
+ fastify.log.error("Twilio fetch SMS history failed:", error);
635
+ throw new Error("Failed to fetch SMS history.");
636
+ }
637
+ },
638
+ handleIncomingSMS: (req, reply) => {
639
+ const { Body, From } = req.body;
640
+
641
+ // Process incoming message
642
+ console.log(`Message received from ${From}: ${Body}`);
643
+
644
+ reply.type('text/xml');
645
+ reply.send('<Response><Message>Thanks for your message!</Message></Response>');
646
+ },
647
+ checkCallStatus: async (callSid) => {
648
+ try {
649
+ const call = await twilioClient.calls(callSid).fetch();
650
+ return call;
651
+ } catch (error) {
652
+ fastify.log.error("Twilio check call status failed:", error);
653
+ throw new Error("Failed to check call status.");
654
+ }
655
+ },
656
+ validatePhoneNumber: async (phoneNumber) => {
657
+ try {
658
+ // Perform phone number validation with Twilio
659
+ const validation = await twilioClient.lookups.v2
660
+ .phoneNumbers(phoneNumber)
661
+ .fetch();
662
+
663
+ // Check if the phone number is valid (Twilio may return "valid": true/false)
664
+ const isValid = validation.valid !== undefined ? validation.valid : true;
665
+
666
+ // Return the required structure
667
+ return {
668
+ sms: phoneNumber, // The phone number
669
+ smsValidate: validation, // The full validation payload from Twilio
670
+ isSmsValidated: isValid // Set true/false based on the validation
671
+ };
672
+ } catch (error) {
673
+ fastify.log.error("Twilio phone number validation failed:", error);
674
+
675
+ // Return the structure with failed validation
676
+ return {
677
+ sms: phoneNumber, // The phone number
678
+ smsValidate: {}, // Empty object as validation failed
679
+ isSmsValidated: false // Validation failed
680
+ };
681
+ }
682
+ }
683
+ });
684
+
685
+ console.info(" ✅ Twilio Enabled");
686
+ }
687
+
688
+ /*
689
+ ===== CLOUDINARY =====
690
+ */
691
+ if (cloudinaryOptions.active !== false) {
692
+ if (
693
+ !cloudinaryOptions.cloudName ||
694
+ !cloudinaryOptions.apiKey ||
695
+ !cloudinaryOptions.apiSecret
696
+ ) {
697
+ throw new Error(
698
+ "Cloudinary cloudName, apiKey, and apiSecret must be provided."
699
+ );
700
+ }
701
+
702
+ Cloudinary.config({
703
+ cloud_name: cloudinaryOptions.cloudName,
704
+ api_key: cloudinaryOptions.apiKey,
705
+ api_secret: cloudinaryOptions.apiSecret,
706
+ },
707
+ );
708
+
709
+ fastify.decorate('cloudinary', {
710
+ upload: async (fileStream, options = {}) => {
711
+ return new Promise((resolve, reject) => {
712
+ if (cloudinaryOptions.folder) {
713
+ options.folder = options.folder || cloudinaryOptions.folder;
714
+ }
715
+ options.resource_type = options.resource_type || 'auto';
716
+ options.timestamp = Math.floor(Date.now() / 1000); // Add timestamp to options
717
+ options.use_filename = options.use_filename !== undefined ? options.use_filename : true;
718
+ options.unique_filename = options.unique_filename !== undefined ? options.unique_filename : true;
719
+
720
+ const uploadStream = Cloudinary.uploader.upload_stream(options, (error, result) => {
721
+ if (error) {
722
+ fastify.log.error("Cloudinary upload failed:", error);
723
+ reject(new Error(`Failed to upload to Cloudinary: ${JSON.stringify(error)}`));
724
+ } else {
725
+ resolve(result); // Resolve with the result from Cloudinary
726
+ }
727
+ });
728
+
729
+ fileStream.pipe(uploadStream)
730
+ .on('error', (streamError) => {
731
+ fastify.log.error("Stream error:", streamError);
732
+ reject(new Error(`Stream error during Cloudinary upload: ${streamError.message}`));
733
+ })
734
+ .on('end', () => {
735
+ fastify.log.info("Stream ended successfully.");
736
+ });
737
+ });
738
+ },
739
+
740
+ uploadLarge: async (fileStream, options = {}) => {
741
+ return new Promise((resolve, reject) => {
742
+ if (cloudinaryOptions.folder) {
743
+ options.folder = options.folder || cloudinaryOptions.folder;
744
+ }
745
+ options.resource_type = options.resource_type || 'auto';
746
+ options.timestamp = Math.floor(Date.now() / 1000);
747
+ options.use_filename = options.use_filename !== undefined ? options.use_filename : true;
748
+ options.unique_filename = options.unique_filename !== undefined ? options.unique_filename : true;
749
+ options.overwrite = options.overwrite !== undefined ? options.overwrite : false;
750
+
751
+ const uploadStream = Cloudinary.uploader.upload_stream(options, (error, result) => {
752
+ if (error) {
753
+ fastify.log.error("Cloudinary upload failed:", error);
754
+ reject(new Error(`Failed to upload to Cloudinary: ${JSON.stringify(error)}`));
755
+ } else {
756
+ resolve(result);
757
+ }
758
+ });
759
+
760
+ fileStream.pipe(uploadStream)
761
+ .on('error', (streamError) => {
762
+ fastify.log.error("Stream error:", streamError);
763
+ reject(new Error(`Stream error during Cloudinary upload: ${streamError.message}`));
764
+ })
765
+ .on('end', () => {
766
+ fastify.log.info("Stream ended successfully.");
767
+ });
768
+ });
769
+ },
770
+
771
+ delete: async (publicId) => {
772
+ try {
773
+ const response = await Cloudinary.uploader.destroy(publicId);
774
+ return response;
775
+ } catch (error) {
776
+ fastify.log.error("Cloudinary delete failed:", error);
777
+ throw new Error("Failed to delete from Cloudinary.");
778
+ }
779
+ }
780
+ });
781
+
782
+ console.info(" ✅ Cloudinary Enabled");
783
+ }
784
+
785
+ /*
786
+ ===== AUTHENTICATION =====
787
+ Admin and User authentication are handled separately and redundantly,
788
+ sharing no code between them.
789
+ */
790
+
791
+ // Register @fastify/cookie plugin
792
+ fastify.register(import("@fastify/cookie"));
793
+
794
+ console.info(" ✅ @fastify/cookie Enabled");
795
+
796
+ /*
797
+ ===== Admin Authentication =====
798
+ */
799
+ /*
800
+ ===== Admin Authentication =====
801
+ */
802
+ if (authOptions.admin?.active !== false) {
803
+
804
+ // Ensure the admin JWT secret is provided
805
+ if (!authOptions.admin.secret) {
806
+ throw new Error("Admin JWT secret must be provided.");
807
+ }
808
+
809
+ const adminAuthOptions = authOptions.admin;
810
+ const adminCookieName =
811
+ adminAuthOptions.cookieOptions?.name || "adminToken";
812
+ const adminRefreshCookieName =
813
+ adminAuthOptions.cookieOptions?.refreshTokenName || "adminRefreshToken";
814
+ const adminCookieOptions = {
815
+ httpOnly: true, // Ensures the cookie is not accessible via JavaScript
816
+ secure: isProduction, // true in production (HTTPS), false in development (HTTP)
817
+ sameSite: isProduction ? 'None' : 'Lax', // 'None' for cross-origin, 'Lax' for development
818
+ path: '/', // Ensure cookies are valid for the entire site
819
+ };
820
+ const adminExcludedPaths = adminAuthOptions.excludedPaths || [
821
+ "/admin/auth/login",
822
+ "/admin/auth/logout",
823
+ ];
824
+
825
+ // Decorator to hash admin passwords
826
+ async function hashAdminPassword(password) {
827
+ const saltRounds = 10; // Number of salt rounds for bcrypt (10 is generally a good default)
828
+ try {
829
+ const hashedPassword = await bcrypt.hash(password, saltRounds);
830
+ return hashedPassword;
831
+ } catch (error) {
832
+ throw new Error("Failed to hash password: " + error.message);
833
+ }
834
+ }
835
+
836
+ fastify.decorate("hashAdminPassword", hashAdminPassword);
837
+
838
+ // Register JWT for admin
839
+ await fastify.register(jwt, {
840
+ secret: adminAuthOptions.secret,
841
+ sign: { algorithm: 'HS256', expiresIn: adminAuthOptions.expiresIn || "15m" },
842
+ cookie: {
843
+ cookieName: adminCookieName,
844
+ signed: false,
845
+ },
846
+ namespace: "adminJwt",
847
+ jwtVerify: "adminJwtVerify",
848
+ jwtSign: "adminJwtSign",
849
+ });
850
+
851
+ // Common function to set tokens as cookies
852
+ const setAdminAuthCookies = (reply, accessToken, refreshToken) => {
853
+ reply.setCookie(adminCookieName, accessToken, adminCookieOptions);
854
+ reply.setCookie(adminRefreshCookieName, refreshToken, {
855
+ // ...adminCookieOptions,
856
+ httpOnly: true, // Ensures the cookie is not accessible via JavaScript
857
+ secure: isProduction, // true in production (HTTPS), false in development (HTTP)
858
+ sameSite: isProduction ? 'None' : 'Lax', // 'None' for cross-origin, 'Lax' for development
859
+ path: '/', // Ensure cookies are valid for the entire site
860
+ });
861
+ };
862
+
863
+ // Admin authentication hook
864
+ fastify.addHook("onRequest", async (request, reply) => {
865
+ const url = request.url;
866
+
867
+ // Skip authentication for excluded paths
868
+ if (adminExcludedPaths.some((path) => url.startsWith(path))) {
869
+ return;
870
+ }
871
+
872
+ if (url.startsWith("/admin")) {
873
+ try {
874
+ // Extract token from cookie or Authorization header
875
+ const authHeader = request.headers.authorization;
876
+ const authToken =
877
+ authHeader && authHeader.startsWith("Bearer ")
878
+ ? authHeader.slice(7)
879
+ : null;
880
+ const token = request.cookies[adminCookieName] || authToken;
881
+
882
+ if (!token) {
883
+ throw fastify.httpErrors.unauthorized(
884
+ "Admin access token not provided"
885
+ );
886
+ }
887
+
888
+ // Verify access token
889
+ const decoded = await request.adminJwtVerify(token);
890
+ request.adminAuth = decoded; // Attach admin auth context
891
+ } catch (err) {
892
+ // Use built-in HTTP error handling
893
+ reply.send(
894
+ fastify.httpErrors.unauthorized("Invalid or expired access token")
895
+ );
896
+ }
897
+ }
898
+ });
899
+
900
+ // Admin login route
901
+ fastify.post("/admin/auth/login", async (req, reply) => {
902
+ try {
903
+ const { email, password } = req.body;
904
+
905
+ // Validate input
906
+ if (!email || !password) {
907
+ throw fastify.httpErrors.badRequest(
908
+ "Email and password are required"
909
+ );
910
+ }
911
+
912
+ // Fetch admin from the database
913
+ const admin = await fastify.prisma.admins.findUnique({
914
+ where: { email },
915
+ });
916
+ if (!admin) {
917
+ throw fastify.httpErrors.unauthorized("Invalid credentials");
918
+ }
919
+
920
+ // Compare passwords using bcrypt
921
+ const isValidPassword = await bcrypt.compare(password, admin.password);
922
+ if (!isValidPassword) {
923
+ throw fastify.httpErrors.unauthorized("Invalid credentials");
924
+ }
925
+
926
+ // Issue access token
927
+ const accessToken = await reply.adminJwtSign({ id: admin.id });
928
+
929
+ // Generate refresh token
930
+ const refreshToken = randomUUID();
931
+ const hashedRefreshToken = await bcrypt.hash(refreshToken, 10);
932
+
933
+ // Store hashed refresh token in the database
934
+ await fastify.prisma.admins.update({
935
+ where: { id: admin.id },
936
+ data: { refreshToken: hashedRefreshToken },
937
+ });
938
+
939
+ // Set tokens as cookies
940
+ setAdminAuthCookies(reply, accessToken, refreshToken);
941
+
942
+ reply.send({ accessToken });
943
+ } catch (err) {
944
+ reply.send(err);
945
+ }
946
+ });
947
+
948
+ // Admin refresh token route
949
+ fastify.post("/admin/auth/refresh", async (req, reply) => {
950
+ try {
951
+ const adminAuth = req.adminAuth;
952
+ const refreshToken = req.cookies[adminRefreshCookieName];
953
+ if (!refreshToken) {
954
+ throw fastify.httpErrors.unauthorized("Refresh token not provided");
955
+ }
956
+
957
+ // Fetch admin from the database using the refresh token
958
+ const admin = await fastify.prisma.admins.findFirst({
959
+ where: { id: adminAuth.id, refreshToken: { not: null } },
960
+ });
961
+ if (!admin) {
962
+ throw fastify.httpErrors.unauthorized("Invalid refresh token");
963
+ }
964
+
965
+ // Verify the refresh token
966
+ const isValid = await bcrypt.compare(refreshToken, admin.refreshToken);
967
+ if (!isValid) {
968
+ throw fastify.httpErrors.unauthorized("Invalid refresh token");
969
+ }
970
+
971
+ // Issue new access token
972
+ const accessToken = await reply.adminJwtSign({ id: admin.id });
973
+
974
+ // Generate new refresh token
975
+ const newRefreshToken = randomUUID();
976
+ const hashedNewRefreshToken = await bcrypt.hash(newRefreshToken, 10);
977
+
978
+ // Update refresh token in the database
979
+ await fastify.prisma.admins.update({
980
+ where: { id: admin.id },
981
+ data: { refreshToken: hashedNewRefreshToken },
982
+ });
983
+
984
+ // Set new tokens as cookies
985
+ setAdminAuthCookies(reply, accessToken, newRefreshToken);
986
+
987
+ reply.send({ accessToken });
988
+ } catch (err) {
989
+ reply.send(err);
990
+ }
991
+ });
992
+
993
+ // Admin logout route
994
+ fastify.post("/admin/auth/logout", async (req, reply) => {
995
+ try {
996
+ const adminAuth = req.adminAuth;
997
+ if (adminAuth) {
998
+ // Delete refresh token from the database
999
+ await fastify.prisma.admins.update({
1000
+ where: { id: adminAuth.id },
1001
+ data: { refreshToken: null },
1002
+ });
1003
+ }
1004
+
1005
+ // Clear cookies
1006
+ reply.clearCookie(adminCookieName, { path: "/" });
1007
+ reply.clearCookie(adminRefreshCookieName, { path: "/" });
1008
+
1009
+ reply.send({ message: "Logged out successfully" });
1010
+ } catch (err) {
1011
+ reply.send(err);
1012
+ }
1013
+ });
1014
+
1015
+ // Admin authentication status route
1016
+ fastify.get("/admin/auth/me", async (req, reply) => {
1017
+ try {
1018
+ const adminAuth = req.adminAuth;
1019
+
1020
+ // Fetch admin details from database
1021
+ const admin = await fastify.prisma.admins.findUnique({
1022
+ where: { id: adminAuth.id },
1023
+ select: { id: true, firstName: true, lastName: true, email: true },
1024
+ });
1025
+
1026
+ if (!admin) {
1027
+ throw fastify.httpErrors.notFound("Admin not found");
1028
+ }
1029
+
1030
+ reply.send(admin);
1031
+ } catch (err) {
1032
+ reply.send(err);
1033
+ }
1034
+ });
1035
+
1036
+ console.info(" ✅ Auth Admin Enabled");
1037
+ }
1038
+
1039
+ /*
1040
+ ===== User Authentication =====
1041
+ */
1042
+ if (authOptions.user?.active !== false) {
1043
+ // Ensure the user JWT secret is provided
1044
+ if (!authOptions.user.secret) {
1045
+ throw new Error("User JWT secret must be provided.");
1046
+ }
1047
+
1048
+ const userAuthOptions = authOptions.user;
1049
+ const userCookieName = userAuthOptions.cookieOptions?.name || "userToken";
1050
+ const userRefreshCookieName =
1051
+ userAuthOptions.cookieOptions?.refreshTokenName || "userRefreshToken";
1052
+ const userCookieOptions = {
1053
+ httpOnly: true, // Ensures the cookie is not accessible via JavaScript
1054
+ secure: isProduction, // true in production (HTTPS), false in development (HTTP)
1055
+ sameSite: isProduction ? 'None' : 'Lax', // 'None' for cross-origin, 'Lax' for development
1056
+ path: '/', // Ensure cookies are valid for the entire site
1057
+ };
1058
+ const userExcludedPaths = userAuthOptions.excludedPaths || [
1059
+ "/portal/auth/login",
1060
+ "/portal/auth/logout",
1061
+ "/portal/auth/register",
1062
+ ];
1063
+
1064
+ // Register JWT for user
1065
+ await fastify.register(jwt, {
1066
+ secret: userAuthOptions.secret,
1067
+ sign: { algorithm: 'HS256', expiresIn: userAuthOptions.expiresIn || "15m" },
1068
+ cookie: { cookieName: userCookieName, signed: false },
1069
+ namespace: "userJwt",
1070
+ jwtVerify: "userJwtVerify",
1071
+ jwtSign: "userJwtSign",
1072
+ });
1073
+
1074
+ // Common function to set tokens as cookies
1075
+ const setAuthCookies = (reply, accessToken, refreshToken) => {
1076
+ reply.setCookie(userCookieName, accessToken, userCookieOptions);
1077
+ reply.setCookie(userRefreshCookieName, refreshToken, {
1078
+ // ...userCookieOptions,
1079
+ httpOnly: true, // Ensures the cookie is not accessible via JavaScript
1080
+ secure: isProduction, // true in production (HTTPS), false in development (HTTP)
1081
+ sameSite: isProduction ? 'None' : 'Lax', // 'None' for cross-origin, 'Lax' for development
1082
+ path: '/', // Ensure cookies are valid for the entire site
1083
+ });
1084
+ };
1085
+
1086
+ // User authentication hook
1087
+ fastify.addHook("onRequest", async (request, reply) => {
1088
+ const url = request.url;
1089
+
1090
+ // Skip authentication for excluded paths
1091
+ if (userExcludedPaths.some((path) => url.startsWith(path))) {
1092
+ return;
1093
+ }
1094
+
1095
+ if (url.startsWith("/portal")) {
1096
+ try {
1097
+ // Extract token from cookie or Authorization header
1098
+ const authHeader = request.headers.authorization;
1099
+ const authToken =
1100
+ authHeader && authHeader.startsWith("Bearer ")
1101
+ ? authHeader.slice(7)
1102
+ : null;
1103
+ const token = request.cookies[userCookieName] || authToken;
1104
+
1105
+ if (!token) {
1106
+ throw fastify.httpErrors.unauthorized(
1107
+ "User access token not provided"
1108
+ );
1109
+ }
1110
+
1111
+ // Verify access token using the namespaced verify method
1112
+ const decoded = await request.userJwtVerify(token);
1113
+ request.userAuth = decoded; // Attach user auth context
1114
+ } catch (err) {
1115
+ // Use built-in HTTP error handling
1116
+ reply.send(
1117
+ fastify.httpErrors.unauthorized("Invalid or expired access token")
1118
+ );
1119
+ }
1120
+ }
1121
+ });
1122
+
1123
+ // User registration route
1124
+ fastify.post("/portal/auth/register", async (req, reply) => {
1125
+ try {
1126
+ const { email, password, firstName, lastName } = req.body;
1127
+
1128
+ // Validate input
1129
+ if (!email || !password || !firstName || !lastName) {
1130
+ throw fastify.httpErrors.badRequest("Missing required fields");
1131
+ }
1132
+
1133
+ // Check if user already exists
1134
+ const existingUser = await fastify.prisma.users.findUnique({
1135
+ where: { email },
1136
+ });
1137
+ if (existingUser) {
1138
+ throw fastify.httpErrors.conflict("Email already in use");
1139
+ }
1140
+
1141
+ // Hash the password
1142
+ const hashedPassword = await bcrypt.hash(password, 10);
1143
+
1144
+ // Create the user
1145
+ const user = await fastify.prisma.users.create({
1146
+ data: {
1147
+ email,
1148
+ password: hashedPassword,
1149
+ firstName,
1150
+ lastName,
1151
+ },
1152
+ });
1153
+
1154
+ // Send welcome email
1155
+ await fastify.sendGrid.sendEmail(email, auth?.user?.registerEmail?.subject, auth?.user?.registerEmail?.templateId, { name: firstName, email });
1156
+
1157
+ // Issue access token
1158
+ const accessToken = await reply.userJwtSign({ id: user.id });
1159
+
1160
+ // Generate refresh token
1161
+ const refreshToken = randomUUID();
1162
+ const hashedRefreshToken = await bcrypt.hash(refreshToken, 10);
1163
+
1164
+ // Store hashed refresh token in the database
1165
+ await fastify.prisma.users.update({
1166
+ where: { id: user.id },
1167
+ data: { refreshToken: hashedRefreshToken },
1168
+ });
1169
+
1170
+ // Set tokens as cookies
1171
+ setAuthCookies(reply, accessToken, refreshToken);
1172
+
1173
+ reply.send({ accessToken });
1174
+ } catch (err) {
1175
+ reply.send(err);
1176
+ }
1177
+ });
1178
+
1179
+ // User login route
1180
+ fastify.post("/portal/auth/login", async (req, reply) => {
1181
+ try {
1182
+ const { email, password } = req.body;
1183
+
1184
+ if (!email || !password) {
1185
+ throw fastify.httpErrors.badRequest(
1186
+ "Email and password are required"
1187
+ );
1188
+ }
1189
+
1190
+ // Fetch user from the database
1191
+ const user = await fastify.prisma.users.findUnique({
1192
+ where: { email },
1193
+ });
1194
+ if (!user) {
1195
+ throw fastify.httpErrors.unauthorized("Invalid credentials");
1196
+ }
1197
+
1198
+ // Compare passwords using bcrypt
1199
+ const isValidPassword = await bcrypt.compare(password, user.password);
1200
+ if (!isValidPassword) {
1201
+ throw fastify.httpErrors.unauthorized("Invalid credentials");
1202
+ }
1203
+
1204
+ // Issue access token
1205
+ const accessToken = await reply.userJwtSign({ id: user.id });
1206
+
1207
+ // Generate refresh token
1208
+ const refreshToken = randomUUID();
1209
+ const hashedRefreshToken = await bcrypt.hash(refreshToken, 10);
1210
+
1211
+ // Store hashed refresh token in the database
1212
+ await fastify.prisma.users.update({
1213
+ where: { id: user.id },
1214
+ data: { refreshToken: hashedRefreshToken },
1215
+ });
1216
+
1217
+ // Set tokens as cookies
1218
+ setAuthCookies(reply, accessToken, refreshToken);
1219
+
1220
+ reply.send({ accessToken });
1221
+ } catch (err) {
1222
+ reply.send(err);
1223
+ }
1224
+ });
1225
+
1226
+ // User refresh token route
1227
+ fastify.post("/portal/auth/refresh", async (req, reply) => {
1228
+ try {
1229
+ const userAuth = req.userAuth;
1230
+ const refreshToken = req.cookies[userRefreshCookieName];
1231
+ if (!refreshToken) {
1232
+ throw fastify.httpErrors.unauthorized("Refresh token not provided");
1233
+ }
1234
+
1235
+ // Fetch user from the database using the refresh token
1236
+ const user = await fastify.prisma.users.findFirst({
1237
+ where: { id: userAuth?.id, refreshToken: { not: null } },
1238
+ });
1239
+
1240
+ if (!user) {
1241
+ throw fastify.httpErrors.unauthorized("Invalid refresh token");
1242
+ }
1243
+
1244
+ // Verify the refresh token
1245
+ const isValid = await bcrypt.compare(refreshToken, user.refreshToken);
1246
+ if (!isValid) {
1247
+ throw fastify.httpErrors.unauthorized("Invalid refresh token");
1248
+ }
1249
+
1250
+ // Issue new access token
1251
+ const accessToken = await reply.userJwtSign({ id: user.id });
1252
+
1253
+ // Generate new refresh token
1254
+ const newRefreshToken = randomUUID();
1255
+ const hashedNewRefreshToken = await bcrypt.hash(newRefreshToken, 10);
1256
+
1257
+ // Update refresh token in the database
1258
+ await fastify.prisma.users.update({
1259
+ where: { id: user.id },
1260
+ data: { refreshToken: hashedNewRefreshToken },
1261
+ });
1262
+
1263
+ // Set new tokens as cookies
1264
+ setAuthCookies(reply, accessToken, newRefreshToken);
1265
+
1266
+ reply.send({ accessToken });
1267
+ } catch (err) {
1268
+ reply.send(err);
1269
+ }
1270
+ });
1271
+
1272
+ // User logout route
1273
+ fastify.post("/portal/auth/logout", async (req, reply) => {
1274
+ try {
1275
+ const userAuth = req.userAuth;
1276
+ if (userAuth) {
1277
+ // Delete refresh token from the database
1278
+ await fastify.prisma.users.update({
1279
+ where: { id: userAuth.id },
1280
+ data: { refreshToken: null },
1281
+ });
1282
+ }
1283
+
1284
+ // Clear cookies
1285
+ reply.clearCookie(userCookieName, { path: "/" });
1286
+ reply.clearCookie(userRefreshCookieName, { path: "/" });
1287
+
1288
+ reply.send({ message: "Logged out successfully" });
1289
+ } catch (err) {
1290
+ reply.send(err);
1291
+ }
1292
+ });
1293
+
1294
+ // User authentication status route
1295
+ fastify.get("/portal/auth/me", async (req, reply) => {
1296
+ try {
1297
+ const userAuth = req.userAuth;
1298
+
1299
+ // Fetch user details from database
1300
+ const user = await fastify.prisma.users.findUnique({
1301
+ where: { id: userAuth.id },
1302
+ select: { id: true, email: true, firstName: true, lastName: true, ...authOptions.user.me },
1303
+ });
1304
+
1305
+ if (!user) {
1306
+ throw fastify.httpErrors.notFound("User not found");
1307
+ }
1308
+
1309
+ reply.send(user);
1310
+ } catch (err) {
1311
+ reply.send(err);
1312
+ }
1313
+ });
1314
+
1315
+ console.info(" ✅ Auth User Enabled");
1316
+ }
1317
+
1318
+ /*
1319
+ ===== GEOCODE =====
1320
+ */
1321
+ if (geocodeOptions.active !== false) {
1322
+ if (!geocodeOptions.apiKey) {
1323
+ throw new Error("Geocode API key must be provided.");
1324
+ }
1325
+ const geocodioEndpoint = 'https://api.geocod.io/v1.7/geocode';
1326
+ const geocodioApiKey = geocodeOptions.apiKey;
1327
+
1328
+ fastify.decorate('geocode', {
1329
+ // Method to get lat/long and county from a zip code
1330
+ async getLatLongByZip(zipCode) {
1331
+ try {
1332
+ const url = `${geocodioEndpoint}?q=${zipCode}&fields=cd,stateleg&api_key=${geocodioApiKey}`;
1333
+ const response = await fetch(url);
1334
+ const result = await response.json();
1335
+
1336
+ if (result.results && result.results.length > 0) {
1337
+ const location = result.results[0].location;
1338
+ const addressComponents = result.results[0].address_components;
1339
+
1340
+ return {
1341
+ zip: zipCode,
1342
+ lat: location.lat,
1343
+ lng: location.lng,
1344
+ city: addressComponents.city,
1345
+ county: addressComponents.county,
1346
+ country: addressComponents.country,
1347
+ state: addressComponents.state,
1348
+ addressComponents: addressComponents
1349
+ };
1350
+ } else {
1351
+ throw new Error('No results found for the provided zip code.');
1352
+ }
1353
+ } catch (error) {
1354
+ fastify.log.error('Failed to fetch geolocation data:', error);
1355
+ throw new Error('Failed to fetch geolocation data.');
1356
+ }
1357
+ }
1358
+ });
1359
+
1360
+ console.info(' ✅ Geocodio Enabled');
1361
+ }
1362
+
1363
+
1364
+
1365
+ /*
1366
+ ===== LIST ROUTES AFTER ALL PLUGINS =====
1367
+ Use the after() method to ensure this runs after all plugins are registered.
1368
+ */
1369
+ fastify.after(() => {
1370
+ if (professional !== true) {
1371
+ console.info(" ✅ Listing Routes:");
1372
+ fastify.ready(() => {
1373
+ printRoutes(routes, options.colors !== false);
1374
+ // Add rocket emoji
1375
+ console.info(
1376
+ `🚀 Server is ready on port ${process.env.PORT || 3000}\n\n`
1377
+ );
1378
+ // Add goodbye emoji for server shutting down
1379
+ fastify.addHook("onClose", () =>
1380
+ console.info("Server shutting down... Goodbye 👋")
1381
+ );
1382
+ });
1383
+ }
1384
+ });
1385
+
1386
+ /*
1387
+ * ===== HEALTH CHECK ROUTE =====
1388
+ */
1389
+
1390
+ // Health Check Route
1391
+ fastify.get("/health", async (request, reply) => {
1392
+ const status = {
1393
+ status: "healthy",
1394
+ timestamp: new Date().toISOString(),
1395
+ uptime: process.uptime(), // Uptime in seconds
1396
+ version: "1.0.0", // Fetch dynamically if possible
1397
+ environment: process.env.NODE_ENV || "development",
1398
+ dependencies: {},
1399
+ resources: {},
1400
+ details: {},
1401
+ };
1402
+
1403
+ try {
1404
+ // Database connectivity check
1405
+ if (fastify.prisma) {
1406
+ await fastify.prisma.$queryRaw`SELECT 1`;
1407
+ status.dependencies.database = "up";
1408
+ } else {
1409
+ status.dependencies.database = "not configured";
1410
+ }
1411
+ } catch (error) {
1412
+ status.dependencies.database = "down";
1413
+ status.status = "degraded";
1414
+ status.details.databaseError = error.message;
1415
+ }
1416
+
1417
+ try {
1418
+ // Redis connectivity check
1419
+ if (fastify.redis) {
1420
+ await fastify.redis.ping();
1421
+ status.dependencies.redis = "up";
1422
+ } else {
1423
+ status.dependencies.redis = "not configured";
1424
+ }
1425
+ } catch (error) {
1426
+ status.dependencies.redis = "down";
1427
+ status.status = "degraded";
1428
+ status.details.redisError = error.message;
1429
+ }
1430
+
1431
+ // Resource usage
1432
+ const memoryUsage = process.memoryUsage();
1433
+ const loadAverage = os.loadavg();
1434
+
1435
+ status.resources.memory = {
1436
+ rss: formatBytes(memoryUsage.rss),
1437
+ heapTotal: formatBytes(memoryUsage.heapTotal),
1438
+ heapUsed: formatBytes(memoryUsage.heapUsed),
1439
+ external: formatBytes(memoryUsage.external),
1440
+ arrayBuffers: formatBytes(memoryUsage.arrayBuffers),
1441
+ };
1442
+
1443
+ status.resources.cpu = {
1444
+ loadAverage, // 1, 5, and 15 minute load averages
1445
+ cpus: os.cpus().length,
1446
+ };
1447
+
1448
+ // Disk space availability
1449
+ try {
1450
+ // Implement disk space check using 'check-disk-space' package
1451
+ // Install it via 'npm install check-disk-space'
1452
+ const checkDiskSpace = (await import("check-disk-space")).default;
1453
+ const diskSpace = await checkDiskSpace("/"); // Replace '/' with your drive or mount point
1454
+ status.resources.disk = {
1455
+ free: formatBytes(diskSpace.free),
1456
+ size: formatBytes(diskSpace.size),
1457
+ };
1458
+ } catch (error) {
1459
+ status.resources.disk = "unknown";
1460
+ status.details.diskError = error.message;
1461
+ }
1462
+
1463
+ // Application-specific checks
1464
+ // Example: Check if a scheduled job is running
1465
+ if (typeof isJobRunning === "function" && !isJobRunning()) {
1466
+ status.status = "degraded";
1467
+ status.details.jobStatus = "Scheduled job is not running";
1468
+ }
1469
+
1470
+ // Configuration validation
1471
+ const requiredEnvVars = [
1472
+ "DATABASE_URL",
1473
+ "ADMIN_JWT_SECRET",
1474
+ "USER_JWT_SECRET",
1475
+ ];
1476
+ const missingEnvVars = requiredEnvVars.filter(
1477
+ (varName) => !process.env[varName]
1478
+ );
1479
+ if (missingEnvVars.length > 0) {
1480
+ status.status = "degraded";
1481
+ status.details.missingEnvVars = missingEnvVars;
1482
+ }
1483
+
1484
+ // Determine overall health
1485
+ if (status.status === "healthy") {
1486
+ reply.send(status);
1487
+ } else {
1488
+ reply.status(500).send(status);
1489
+ }
1490
+ });
1491
+
1492
+ /*
1493
+ ===== Slugify =====
1494
+ */
1495
+ fastify.decorate('slugify', (string) => {
1496
+ return string
1497
+ .toString()
1498
+ .toLowerCase() // Convert to lowercase
1499
+ .trim() // Trim leading and trailing spaces
1500
+ .replace(/\s+/g, '-') // Replace spaces with -
1501
+ .replace(/[^\w\-]+/g, '') // Remove all non-word characters (allow only letters, numbers, and dashes)
1502
+ .replace(/\-\-+/g, '-') // Replace multiple dashes with a single dash
1503
+ .replace(/^-+/, '') // Trim leading dashes
1504
+ .replace(/-+$/, ''); // Trim trailing dashes
1505
+ });
1506
+ /*
1507
+ ===== Generate UUID =====
1508
+ */
1509
+ fastify.decorate('UUID', () => {
1510
+ return randomUUID(); // Generate a UUID using uncrypto's randomUUID
1511
+ });
1512
+
1513
+ /*
1514
+ ===== Ensure Proper Cleanup on Server Shutdown =====
1515
+ */
1516
+ fastify.addHook("onClose", async () => {
1517
+ if (fastify.prisma) {
1518
+ await fastify.prisma.$disconnect();
1519
+ }
1520
+ // Add additional cleanup for other services if necessary
1521
+ });
1522
+ }
1523
+
1524
+ export default fp(xConfig, {
1525
+ name: "xConfig",
1526
+ });