@devmoods/express-extras 0.22.3 → 0.23.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/README.md CHANGED
@@ -8,15 +8,17 @@ _Extremely opinionated_ set of utilities for cranking out production-ready Node.
8
8
  - Thin layer on top of [Express](https://expressjs.com) with better support for `async`/`await`
9
9
  - PostgreSQL with transactions and migrations
10
10
  - Redis
11
- - Slack notifications
11
+ - Slack (and Discord) client for sending notifications
12
12
  - Delayed jobs with [Faktory](https://contribsys.com/faktory/)
13
- - Validation of requests using [JSON Schemas](https://json-schema.org/)
14
- - Logging helpers
15
- - Graceful shutdown
16
- - Read configuration from `.env` files
13
+ - Validation of HTTP requests using [JSON Schemas](https://json-schema.org/)
14
+ - Logging helpers to write well-formatted contextual JSON logs to `stdout`
17
15
  - Request local context using [AsyncLocalStorage](https://nodejs.org/api/async_context.html)
18
16
  - [RFC 7807 Problem Details for HTTP APIs](https://tools.ietf.org/html/rfc7807) by default
19
17
  - Works best with the [PnPM](https://pnpm.io/) package manager
18
+ - Uses [Sentry](https://sentry.io/) for external error monitoring
19
+ - Inspired by [The Twelve-Factor App](https://12factor.net/)
20
+ - Graceful shutdown
21
+ - Read configuration from `.env` files
20
22
 
21
23
  ## Goals
22
24
 
@@ -35,13 +37,22 @@ _Extremely opinionated_ set of utilities for cranking out production-ready Node.
35
37
  Put environment variables in `.env` at the application will automatically read those on startup. For test configuration in unit and integrations tests, use `.env.test`.
36
38
 
37
39
  ```bash
38
- READINESS_PROBE_MS=0
40
+ NODE_ENV=development
41
+ PORT=3000
42
+ READINESS_PROBE_MS=
39
43
  FORCE_EXIT_TIMEOUT=
40
- SLACK_WEBHOOK_URL=
41
- REDIS_URL=
42
- PGUSER=my_database
44
+
45
+ APP_JWT_SECRET=123456
46
+
43
47
  PGHOST=localhost
48
+ PGUSER=my_database
44
49
  PGPASSWORD=
45
50
  PGDATABASE=my_database_dev
46
51
  PGPORT=5432
52
+
53
+ REDIS_URL=
54
+
55
+ SLACK_WEBHOOK_URL=
56
+ SENTRY_DSN=
57
+ SLACK_WEBHOOK_URL=
47
58
  ```
package/dist/auth.d.ts CHANGED
@@ -10,18 +10,18 @@ export interface AuthOptions<UserModel extends Record<string, any>> {
10
10
  redis: RedisClient;
11
11
  filterUser(user: UserModel): Partial<UserModel>;
12
12
  tokenExpiry?: number;
13
- onPasswordResetRequested?(user: UserModel, temporaryToken: string): void | Promise<void>;
13
+ onPasswordResetRequest?(user: UserModel, temporaryToken: string): void | Promise<void>;
14
14
  onPasswordChanged?(user: UserModel, newPassword: string): Promise<void>;
15
+ onEmailVerificationRequest?(user: UserModel, verificationToken: string): Promise<void>;
15
16
  onEmailVerified?(user: UserModel): Promise<void>;
16
- onEmailVerificationRequest?(email: string): Promise<void>;
17
17
  }
18
18
  export interface CreateAuth<UserModel extends Record<string, any>> {
19
19
  createRouter: () => Router;
20
20
  useCurrentUser: () => UserModel | undefined;
21
21
  middleware: (authorize?: boolean) => RequestHandler;
22
- createVerificationToken(email: string): Promise<string>;
22
+ createVerificationToken(user: UserModel): Promise<string>;
23
23
  }
24
- export declare function createAuth<UserModel extends Record<string, any>>({ getUserByToken, methods: { emailAndPassword }, filterUser, redis, tokenExpiry, onPasswordResetRequested, onPasswordChanged, onEmailVerified, onEmailVerificationRequest, }: AuthOptions<UserModel>): CreateAuth<UserModel>;
24
+ export declare function createAuth<UserModel extends Record<string, any>>({ getUserByToken, methods: { emailAndPassword }, filterUser, redis, tokenExpiry, onPasswordResetRequest, onPasswordChanged, onEmailVerified, onEmailVerificationRequest, }: AuthOptions<UserModel>): CreateAuth<UserModel>;
25
25
  /**
26
26
  * Extract a Bearer token from the HTTP Authorization header
27
27
  * of `req`.
package/dist/auth.js CHANGED
@@ -14,7 +14,7 @@ const TEMP_TOKEN_EXIRES_IN = 3600;
14
14
  export function createAuthToken(payload, secret = process.env.APP_JWT_SECRET) {
15
15
  return jwt.sign(payload, secret, { expiresIn: TOKEN_EXPIRES_IN });
16
16
  }
17
- export function createAuth({ getUserByToken, methods: { emailAndPassword }, filterUser = (x) => x, redis, tokenExpiry = TOKEN_EXPIRES_IN, onPasswordResetRequested, onPasswordChanged, onEmailVerified, onEmailVerificationRequest, }) {
17
+ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filterUser = (x) => x, redis, tokenExpiry = TOKEN_EXPIRES_IN, onPasswordResetRequest, onPasswordChanged, onEmailVerified, onEmailVerificationRequest, }) {
18
18
  const useCurrentUser = () => useContext('currentUser');
19
19
  const setCurrentUser = (user) => setContext('currentUser', user);
20
20
  const skipPasswordCheck = async () => true;
@@ -88,7 +88,15 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
88
88
  required: ['email'],
89
89
  }), route(async (req) => {
90
90
  const { email } = req.body;
91
- await onEmailVerificationRequest?.(email);
91
+ if (!emailAndPassword) {
92
+ throw new Error('Not applicable for login method');
93
+ }
94
+ const user = await emailAndPassword(email, skipPasswordCheck);
95
+ const verificationToken = crypto.randomBytes(32).toString('hex');
96
+ await redis.set(verificationToken, user.email, {
97
+ EX: TEMP_TOKEN_EXIRES_IN,
98
+ });
99
+ await onEmailVerificationRequest?.(user, verificationToken);
92
100
  return 202;
93
101
  }));
94
102
  router.post('/verify-email', validateBody({
@@ -96,20 +104,20 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
96
104
  properties: { token: tokenSchema, email: emailSchema },
97
105
  required: ['token', 'email'],
98
106
  }), route(async (req) => {
99
- const { token } = req.body;
100
- const email = await redis.get(token);
101
- if (!email) {
107
+ const { token, email } = req.body;
108
+ const storedEmail = await redis.get(token);
109
+ if (!storedEmail) {
102
110
  logger.info('invalid verification token', { token });
103
111
  throw BadRequest('invalid verification token');
104
112
  }
105
- if (email !== req.body.email) {
113
+ if (storedEmail !== email) {
106
114
  logger.info('email verification mismatch', {
107
- expected: email,
108
- actual: req.body.email,
115
+ expected: storedEmail,
116
+ actual: email,
109
117
  });
110
118
  throw BadRequest('invalid verification token');
111
119
  }
112
- const user = await emailAndPassword(email, skipPasswordCheck);
120
+ const user = await emailAndPassword(storedEmail, skipPasswordCheck);
113
121
  setCurrentUser(user);
114
122
  await onEmailVerified?.(user);
115
123
  await redis.del(token);
@@ -141,7 +149,7 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
141
149
  properties: { email: emailSchema },
142
150
  required: ['email'],
143
151
  }), route(async (req) => {
144
- const email = req.body.email;
152
+ const { email } = req.body;
145
153
  if (!emailAndPassword) {
146
154
  throw new Error('Not applicable for login method');
147
155
  }
@@ -151,7 +159,7 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
151
159
  await redis.set(temporaryToken, user.email, {
152
160
  EX: TEMP_TOKEN_EXIRES_IN,
153
161
  });
154
- await onPasswordResetRequested?.(user, temporaryToken);
162
+ await onPasswordResetRequest?.(user, temporaryToken);
155
163
  logger.info('password reset request', { email });
156
164
  }
157
165
  catch (error) {
@@ -206,12 +214,8 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
206
214
  }
207
215
  };
208
216
  }
209
- async function createVerificationToken(email) {
210
- if (!emailAndPassword) {
211
- throw new Error('Not applicable for login method');
212
- }
217
+ async function createVerificationToken(user) {
213
218
  try {
214
- const user = await emailAndPassword(email, skipPasswordCheck);
215
219
  const temporaryToken = crypto.randomBytes(32).toString('hex');
216
220
  await redis.set(temporaryToken, user.email, {
217
221
  EX: TEMP_TOKEN_EXIRES_IN,
package/dist/faktory.js CHANGED
@@ -2,6 +2,7 @@ import { AsyncLocalStorage } from 'async_hooks';
2
2
  import { randomUUID } from 'crypto';
3
3
  import { create } from 'faktory-worker';
4
4
  import { getLogger } from './logger.js';
5
+ import { startTimer } from './utils.js';
5
6
  const logger = getLogger('faktory');
6
7
  const JOB_ID = 'jobId';
7
8
  export function createJobs({ queueName }) {
@@ -29,7 +30,7 @@ export function createJobs({ queueName }) {
29
30
  await client.close();
30
31
  }
31
32
  catch (error) {
32
- logger.error(error, 'Failed to create job', {
33
+ logger.error(error, 'failed to create job', {
33
34
  jobId: jid,
34
35
  jobName,
35
36
  args: JSON.stringify(args),
@@ -72,15 +73,9 @@ export async function createFaktoryWorker({ queueName, jobs, }) {
72
73
  queueName,
73
74
  availableJobs: availableJobs.map((j) => j.jobName),
74
75
  });
75
- const getDurationInMilliseconds = (start) => {
76
- const NS_PER_SEC = 1e9;
77
- const NS_TO_S = 1e9;
78
- const diff = process.hrtime(start);
79
- return (diff[0] * NS_PER_SEC + diff[1]) / NS_TO_S;
80
- };
81
76
  faktory.use(async (ctx, next) => {
82
77
  const jobId = ctx.job.jid;
83
- const start = process.hrtime();
78
+ const timer = startTimer();
84
79
  await context.run(new Map(), async () => {
85
80
  const store = context.getStore();
86
81
  if (store) {
@@ -88,7 +83,7 @@ export async function createFaktoryWorker({ queueName, jobs, }) {
88
83
  }
89
84
  await next();
90
85
  });
91
- const duration = getDurationInMilliseconds(start);
86
+ const duration = timer.getDuration();
92
87
  logger.info('job succeeded', {
93
88
  jobName: ctx.job.jobtype,
94
89
  jobId,
package/dist/listen.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { createTerminus } from '@godaddy/terminus';
2
2
  import { getLogger } from './logger.js';
3
- const logger = getLogger('core');
4
- const READINESS_PERIOD_MS = parseInt(process.env.DM_READINESS_PERIOD_MS || '', 10) || 0;
5
- const FORCE_EXIT_TIMEOUT = parseInt(process.env.DM_FORCE_EXIT_TIMEOUT || '', 10) || 1000;
3
+ const logger = getLogger('http');
4
+ const READINESS_PERIOD_MS = parseInt(process.env.READINESS_PERIOD_MS || '', 10) || 0;
5
+ const FORCE_EXIT_TIMEOUT = parseInt(process.env.FORCE_EXIT_TIMEOUT || '', 10) || 1000;
6
6
  /**
7
7
  * Listen on the given port and setup proper signal
8
8
  * handlers for terminating the process properly.
@@ -29,4 +29,4 @@ export declare function after(): RequestHandler;
29
29
  /**
30
30
  * Alias of `route()`
31
31
  */
32
- export declare function asyncMiddleware(fn: RequestHandler): RequestHandler;
32
+ export declare function asyncMiddleware<RequestBody, RequestQuery = void>(fn: RequestHandler<Record<string, string>, any, RequestBody, RequestQuery>): RequestHandler<Record<string, string>, any, RequestBody, RequestQuery>;
@@ -6,9 +6,10 @@ import helmet from 'helmet';
6
6
  import { withRequestContext } from './context.js';
7
7
  import { NotFound } from './errors.js';
8
8
  import { getLogger } from './logger.js';
9
+ import { startTimer } from './utils.js';
9
10
  export const urlencodedParser = bodyParser.urlencoded;
10
11
  export const jsonParser = bodyParser.json;
11
- const logger = getLogger('core');
12
+ const logger = getLogger('http');
12
13
  export { asyncMiddleware as route };
13
14
  /**
14
15
  * Register middleware that must come before the app routes.
@@ -19,7 +20,6 @@ export function before({ cookies = false, helmet: useHelmet = true, json: useJso
19
20
  logRequestDuration(),
20
21
  Sentry.Handlers.requestHandler(),
21
22
  Sentry.Handlers.tracingHandler(),
22
- analyticsProxy(),
23
23
  useHelmet &&
24
24
  helmet({
25
25
  contentSecurityPolicy: {
@@ -44,17 +44,12 @@ export function before({ cookies = false, helmet: useHelmet = true, json: useJso
44
44
  }),
45
45
  useJson && jsonParser(),
46
46
  cookies && cookieParser(),
47
+ analyticsProxy(),
47
48
  ].filter(Boolean));
48
49
  }
49
50
  function logRequestDuration() {
50
- const getDurationInMilliseconds = (start) => {
51
- const NS_PER_SEC = 1e9;
52
- const NS_TO_S = 1e9;
53
- const diff = process.hrtime(start);
54
- return (diff[0] * NS_PER_SEC + diff[1]) / NS_TO_S;
55
- };
56
51
  return (req, res, next) => {
57
- const start = process.hrtime();
52
+ const timer = startTimer();
58
53
  logger.info('http request', {
59
54
  method: req.method,
60
55
  url: req.originalUrl,
@@ -62,7 +57,7 @@ function logRequestDuration() {
62
57
  ip: req.ip,
63
58
  });
64
59
  res.on('finish', () => {
65
- const duration = getDurationInMilliseconds(start);
60
+ const duration = timer.getDuration();
66
61
  logger.info('http response', {
67
62
  method: req.method,
68
63
  url: req.originalUrl,
package/dist/migrate.d.ts CHANGED
@@ -10,7 +10,7 @@ interface MigrationFileAPI {
10
10
  down(fn: any): Promise<string> | Promise<void> | string | void;
11
11
  }
12
12
  export declare function cli(): Promise<void>;
13
- interface MigratorOptions {
13
+ export interface MigratorOptions {
14
14
  postgres?: ReturnType<typeof createPostgres>;
15
15
  migrationTableName?: string;
16
16
  verbose?: boolean;
@@ -23,8 +23,9 @@ export declare class Migrator {
23
23
  private filenameRegExp;
24
24
  private verbose;
25
25
  constructor({ postgres, pathToMigrations, migrationTableName, verbose, }?: MigratorOptions);
26
- init(): Promise<import("pg").QueryResult<import("pg").QueryResultRow>>;
27
- create(name: string | null | undefined): Promise<string>;
26
+ init(): Promise<boolean>;
27
+ isInstalled(): Promise<boolean>;
28
+ create(name: string | null | undefined, format?: 'ts' | 'sql'): Promise<string>;
28
29
  runMigrations(direction: 'up' | 'down'): Promise<number>;
29
30
  log(...args: any[]): void;
30
31
  executeMigration(migrations: Migration[], currentVersion: number, direction: 'up' | 'down'): Promise<number>;
package/dist/migrate.js CHANGED
@@ -9,7 +9,7 @@ Usage
9
9
  $ migrate init
10
10
  $ migrate up
11
11
  $ migrate down
12
- $ migrate create <name>
12
+ $ migrate create <name> [--typescript]
13
13
  `, {
14
14
  description: 'PostgreSQL migration tool',
15
15
  importMeta: import.meta,
@@ -32,12 +32,18 @@ Usage
32
32
  case 'down':
33
33
  await migrator.runMigrations(action);
34
34
  break;
35
- case 'init':
36
- await migrator.init();
37
- migrator.log(chalk.green('Migrations table was successfully setup.'));
35
+ case 'init': {
36
+ const created = await migrator.init();
37
+ if (created) {
38
+ migrator.log(chalk.green('Migrations table was successfully setup.'));
39
+ }
40
+ else {
41
+ migrator.log(chalk.green('Migrations table already existing.'));
42
+ }
38
43
  break;
44
+ }
39
45
  case 'create': {
40
- const createdMigration = await migrator.create(args[0]);
46
+ const createdMigration = await migrator.create(args[0], cli.flags.typescript ? 'ts' : 'sql');
41
47
  migrator.log(chalk.green('Created migration', createdMigration));
42
48
  break;
43
49
  }
@@ -61,15 +67,30 @@ export class Migrator {
61
67
  this.verbose = verbose;
62
68
  }
63
69
  async init() {
64
- return this.postgres.query(`
65
- CREATE TABLE ${this.migrationTableName} (
70
+ if (await this.isInstalled()) {
71
+ return false;
72
+ }
73
+ await fs.mkdir(this.pathToMigrations, { recursive: true });
74
+ await this.postgres.query(`
75
+ CREATE TABLE IF NOT EXISTS ${this.migrationTableName} (
66
76
  id serial PRIMARY KEY,
67
77
  version int NOT NULL,
68
78
  run_at timestamp NOT NULL
69
79
  );
70
80
  `);
81
+ return true;
82
+ }
83
+ async isInstalled() {
84
+ try {
85
+ await this.postgres.query(`SELECT * FROM ${this.migrationTableName}`);
86
+ await fs.stat(this.pathToMigrations);
87
+ return true;
88
+ }
89
+ catch (error) {
90
+ return false;
91
+ }
71
92
  }
72
- async create(name) {
93
+ async create(name, format = 'sql') {
73
94
  if (!name) {
74
95
  throw new Error('Missing positional argument <name>');
75
96
  }
@@ -77,15 +98,25 @@ export class Migrator {
77
98
  const lastVersion = existingMigrations.length
78
99
  ? existingMigrations[existingMigrations.length - 1].version
79
100
  : 0;
80
- const filename = path.join(this.pathToMigrations, `${lastVersion + 1}_${name}.ts`);
81
- const template = `// Generated ${new Date()}\n\nexport function up(query: any) {\n\n}\n\nexport function down(query: any) {\n\n}\n`;
82
- await fs.writeFile(filename, template);
101
+ const filename = path.join(this.pathToMigrations, `${lastVersion + 1}_${name}.${format}`);
102
+ const getTemplate = () => {
103
+ switch (format) {
104
+ case 'sql':
105
+ return `-- Migration ${name} created ${new Date()}\n\nSELECT 1;\n\n-- ROLLBACK --\n\nSELECT 1;\n`;
106
+ case 'ts':
107
+ return `// Migration ${name} created ${new Date()}\n\nexport function up(query: any) {}\n\nexport function down(query: any) {}\n`;
108
+ }
109
+ };
110
+ await fs.writeFile(filename, getTemplate());
83
111
  return filename;
84
112
  }
85
113
  async runMigrations(direction) {
86
114
  if (direction !== 'up' && direction !== 'down') {
87
115
  throw new Error('Only up and down are supported.');
88
116
  }
117
+ if (!(await this.isInstalled())) {
118
+ await this.init();
119
+ }
89
120
  const migrations = await this.getMigrations();
90
121
  const currentVersion = await this.getCurrentVersion();
91
122
  return this.executeMigration(migrations, currentVersion, direction);
@@ -159,9 +190,9 @@ export class Migrator {
159
190
  return import(filename);
160
191
  }
161
192
  const content = await fs.readFile(filename, 'utf-8');
162
- const [up, down] = content.split('-- ROLLBACK --');
193
+ const [up, down] = content.split(/[-\s]+ROLLBACK[-\s]+/i);
163
194
  return {
164
- useTransaction: !content.includes('-- NO TRANSACTION --'),
195
+ useTransaction: !/[-\s]+NO[\s]*TRANSACTION[-\s]+/i.test(content),
165
196
  up() {
166
197
  return up.trim();
167
198
  },
package/dist/sentry.d.ts CHANGED
@@ -58,6 +58,7 @@ export declare function createSentryOptions(app: Application | null, options?: S
58
58
  profilesSampler?: ((samplingContext: import("@sentry/types").SamplingContext) => number | boolean) | undefined;
59
59
  serverName?: string | undefined;
60
60
  includeLocalVariables?: boolean | undefined;
61
+ clientClass?: typeof Sentry.NodeClient | undefined;
61
62
  shouldCreateSpanForRequest?(this: void, url: string): boolean;
62
63
  onFatalError?(this: void, error: Error): void;
63
64
  };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Start a high resolution timer
3
+ *
4
+ */
5
+ export declare function startTimer(): {
6
+ getDuration(): number;
7
+ };
package/dist/utils.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Start a high resolution timer
3
+ *
4
+ */
5
+ export function startTimer() {
6
+ const start = process.hrtime();
7
+ return {
8
+ getDuration() {
9
+ const elapsed = process.hrtime(start);
10
+ return (elapsed[0] * 1e9 + elapsed[1]) / 1e9;
11
+ },
12
+ };
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devmoods/express-extras",
3
- "version": "0.22.3",
3
+ "version": "0.23.0",
4
4
  "description": "Kubernetes-ready utilities for rapid Node.js application development",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -24,12 +24,13 @@
24
24
  "license": "ISC",
25
25
  "devDependencies": {
26
26
  "@devmoods/eslint-config": "^1.2.0",
27
- "@sentry/types": "^7.60.1",
27
+ "@sentry/types": "^7.61.1",
28
28
  "@types/express-serve-static-core": "^4.17.35",
29
- "@types/node": "20.4.5",
30
- "@types/supertest": "2.0.12",
31
- "@typescript-eslint/eslint-plugin": "^6.2.0",
32
- "@typescript-eslint/parser": "^6.2.0",
29
+ "@types/node": "20.4.7",
30
+ "@types/supertest": "^2.0.12",
31
+ "@typescript-eslint/eslint-plugin": "^6.2.1",
32
+ "@typescript-eslint/parser": "^6.2.1",
33
+ "@vitest/expect": "^0.34.1",
33
34
  "chalk": "^5.3.0",
34
35
  "eslint": "^8.46.0",
35
36
  "eslint-plugin-jsx-a11y": "^6.7.1",
@@ -39,17 +40,16 @@
39
40
  "faktory-worker": "^4.5.1",
40
41
  "import-sort-cli": "6.0.0",
41
42
  "import-sort-style-module": "6.0.0",
42
- "isomorphic-fetch": "3.0.0",
43
- "prettier": "3.0.0",
43
+ "prettier": "3.0.1",
44
44
  "supertest": "6.3.3",
45
45
  "ts-node": "10.9.1",
46
46
  "typescript": "5.1.6",
47
- "vitest": "^0.33.0"
47
+ "vitest": "^0.34.1"
48
48
  },
49
49
  "dependencies": {
50
50
  "@devmoods/fetch": "^3.3.0",
51
51
  "@godaddy/terminus": "^4.12.1",
52
- "@sentry/node": "^7.60.1",
52
+ "@sentry/node": "^7.61.1",
53
53
  "@types/bcrypt": "^5.0.0",
54
54
  "@types/body-parser": "^1.19.2",
55
55
  "@types/cookie-parser": "^1.4.3",
@@ -58,6 +58,7 @@
58
58
  "@types/koa-compose": "^3.2.5",
59
59
  "@types/nodemailer": "^6.4.9",
60
60
  "@types/pg": "^8.10.2",
61
+ "@types/superagent": "^4.1.18",
61
62
  "@types/uuid": "^9.0.2",
62
63
  "ajv": "^8.12.0",
63
64
  "bcrypt": "^5.1.0",
@@ -71,7 +72,7 @@
71
72
  "jsonwebtoken": "^9.0.1",
72
73
  "meow": "^12.0.1",
73
74
  "nodemailer": "^6.9.4",
74
- "pg": "^8.11.1",
75
+ "pg": "^8.11.2",
75
76
  "redis": "^4.6.7",
76
77
  "sql-template-strings": "^2.2.2",
77
78
  "uuid": "^9.0.0"