@terreno/api 0.11.8 → 0.11.9

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.
@@ -62,6 +62,7 @@ var mongodb_1 = require("better-auth/adapters/mongodb");
62
62
  var node_1 = require("better-auth/node");
63
63
  var mongoose_1 = __importDefault(require("mongoose"));
64
64
  var logger_1 = require("./logger");
65
+ var plugins_1 = require("./plugins");
65
66
  /**
66
67
  * Creates a Better Auth instance with MongoDB adapter.
67
68
  */
@@ -135,7 +136,9 @@ var createBetterAuthSessionMiddleware = function (auth, userModel) {
135
136
  if (!((session === null || session === void 0 ? void 0 : session.user) && (session === null || session === void 0 ? void 0 : session.session))) return [3 /*break*/, 7];
136
137
  betterAuthUser = session.user;
137
138
  if (!userModel) return [3 /*break*/, 6];
138
- return [4 /*yield*/, userModel.findOne({ betterAuthId: betterAuthUser.id })];
139
+ return [4 /*yield*/, (0, plugins_1.findOneOrNoneFor)(userModel, {
140
+ betterAuthId: betterAuthUser.id,
141
+ })];
139
142
  case 2:
140
143
  appUser = _a.sent();
141
144
  if (!appUser) return [3 /*break*/, 3];
@@ -185,7 +188,9 @@ var syncBetterAuthUser = function (userModel, betterAuthUser, oauthProvider) { r
185
188
  switch (_a.label) {
186
189
  case 0:
187
190
  _a.trys.push([0, 8, , 9]);
188
- return [4 /*yield*/, userModel.findOne({ betterAuthId: betterAuthUser.id })];
191
+ return [4 /*yield*/, (0, plugins_1.findOneOrNoneFor)(userModel, {
192
+ betterAuthId: betterAuthUser.id,
193
+ })];
189
194
  case 1:
190
195
  existingUser = _a.sent();
191
196
  if (!existingUser) return [3 /*break*/, 3];
@@ -198,7 +203,9 @@ var syncBetterAuthUser = function (userModel, betterAuthUser, oauthProvider) { r
198
203
  case 2:
199
204
  _a.sent();
200
205
  return [2 /*return*/, existingUser];
201
- case 3: return [4 /*yield*/, userModel.findOne({ email: betterAuthUser.email })];
206
+ case 3: return [4 /*yield*/, (0, plugins_1.findOneOrNoneFor)(userModel, {
207
+ email: betterAuthUser.email,
208
+ })];
202
209
  case 4:
203
210
  userByEmail = _a.sent();
204
211
  if (!userByEmail) return [3 /*break*/, 6];
@@ -46,12 +46,12 @@ export interface GitHubUserFields {
46
46
  * userSchema.plugin(githubUserPlugin);
47
47
  * ```
48
48
  */
49
- export declare function githubUserPlugin(schema: any): void;
49
+ export declare const githubUserPlugin: (schema: any) => void;
50
50
  /**
51
51
  * Sets up GitHub OAuth authentication strategy.
52
52
  * Call this after setupAuth() in your server initialization.
53
53
  */
54
- export declare function setupGitHubAuth(_app: express.Application, userModel: UserModel, githubOptions: GitHubAuthOptions): void;
54
+ export declare const setupGitHubAuth: (_app: express.Application, userModel: UserModel, githubOptions: GitHubAuthOptions) => void;
55
55
  /**
56
56
  * Adds GitHub OAuth routes to the Express application.
57
57
  *
@@ -61,4 +61,4 @@ export declare function setupGitHubAuth(_app: express.Application, userModel: Us
61
61
  * - POST /auth/github/link - Links GitHub account to authenticated user (requires JWT auth)
62
62
  * - DELETE /auth/github/unlink - Unlinks GitHub account from authenticated user (requires JWT auth)
63
63
  */
64
- export declare function addGitHubAuthRoutes(app: express.Application, userModel: UserModel, githubOptions: GitHubAuthOptions, authOptions?: AuthOptions): void;
64
+ export declare const addGitHubAuthRoutes: (app: express.Application, userModel: UserModel, githubOptions: GitHubAuthOptions, authOptions?: AuthOptions) => void;
@@ -39,14 +39,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
39
39
  return (mod && mod.__esModule) ? mod : { "default": mod };
40
40
  };
41
41
  Object.defineProperty(exports, "__esModule", { value: true });
42
- exports.githubUserPlugin = githubUserPlugin;
43
- exports.setupGitHubAuth = setupGitHubAuth;
44
- exports.addGitHubAuthRoutes = addGitHubAuthRoutes;
42
+ exports.addGitHubAuthRoutes = exports.setupGitHubAuth = exports.githubUserPlugin = void 0;
45
43
  var passport_1 = __importDefault(require("passport"));
46
44
  var passport_github2_1 = require("passport-github2");
47
45
  var auth_1 = require("./auth");
48
46
  var errors_1 = require("./errors");
49
47
  var logger_1 = require("./logger");
48
+ var plugins_1 = require("./plugins");
50
49
  /**
51
50
  * Plugin to add GitHub authentication fields to a user schema.
52
51
  * Apply this plugin to your User schema if you want to enable GitHub auth.
@@ -58,20 +57,20 @@ var logger_1 = require("./logger");
58
57
  * userSchema.plugin(githubUserPlugin);
59
58
  * ```
60
59
  */
61
- function githubUserPlugin(schema) {
60
+ var githubUserPlugin = function (schema) {
62
61
  schema.add({
63
62
  githubAvatarUrl: { type: String },
64
63
  githubId: { index: true, sparse: true, type: String, unique: true },
65
64
  githubProfileUrl: { type: String },
66
65
  githubUsername: { type: String },
67
66
  });
68
- }
67
+ };
68
+ exports.githubUserPlugin = githubUserPlugin;
69
69
  /**
70
70
  * Sets up GitHub OAuth authentication strategy.
71
71
  * Call this after setupAuth() in your server initialization.
72
72
  */
73
- function setupGitHubAuth(_app, userModel, githubOptions) {
74
- var _this = this;
73
+ var setupGitHubAuth = function (_app, userModel, githubOptions) {
75
74
  var _a;
76
75
  var scope = (_a = githubOptions.scope) !== null && _a !== void 0 ? _a : ["user:email"];
77
76
  passport_1.default.use("github", new passport_github2_1.Strategy({
@@ -80,7 +79,7 @@ function setupGitHubAuth(_app, userModel, githubOptions) {
80
79
  clientSecret: githubOptions.clientSecret,
81
80
  passReqToCallback: true,
82
81
  scope: scope,
83
- }, (function (req, accessToken, refreshToken, profile, done) { return __awaiter(_this, void 0, void 0, function () {
82
+ }, (function (req, accessToken, refreshToken, profile, done) { return __awaiter(void 0, void 0, void 0, function () {
84
83
  var existingUser, user, githubId, existingGitHubUser, user, email, existingEmailUser, newUser, error_1;
85
84
  var _a, _b, _c, _d, _e, _f, _g, _h;
86
85
  return __generator(this, function (_j) {
@@ -95,7 +94,7 @@ function setupGitHubAuth(_app, userModel, githubOptions) {
95
94
  return [2 /*return*/, done(null, user)];
96
95
  case 2:
97
96
  githubId = profile.id;
98
- return [4 /*yield*/, userModel.findOne({ githubId: githubId })];
97
+ return [4 /*yield*/, (0, plugins_1.findOneOrNoneFor)(userModel, { githubId: githubId })];
99
98
  case 3:
100
99
  existingGitHubUser = _j.sent();
101
100
  if (!existingUser) return [3 /*break*/, 7];
@@ -129,7 +128,7 @@ function setupGitHubAuth(_app, userModel, githubOptions) {
129
128
  }
130
129
  email = (_d = (_c = profile.emails) === null || _c === void 0 ? void 0 : _c[0]) === null || _d === void 0 ? void 0 : _d.value;
131
130
  if (!email) return [3 /*break*/, 11];
132
- return [4 /*yield*/, userModel.findOne({ email: email })];
131
+ return [4 /*yield*/, (0, plugins_1.findOneOrNoneFor)(userModel, { email: email })];
133
132
  case 8:
134
133
  existingEmailUser = _j.sent();
135
134
  if (!existingEmailUser) return [3 /*break*/, 11];
@@ -167,7 +166,8 @@ function setupGitHubAuth(_app, userModel, githubOptions) {
167
166
  }
168
167
  });
169
168
  }); })));
170
- }
169
+ };
170
+ exports.setupGitHubAuth = setupGitHubAuth;
171
171
  /**
172
172
  * Adds GitHub OAuth routes to the Express application.
173
173
  *
@@ -177,8 +177,7 @@ function setupGitHubAuth(_app, userModel, githubOptions) {
177
177
  * - POST /auth/github/link - Links GitHub account to authenticated user (requires JWT auth)
178
178
  * - DELETE /auth/github/unlink - Unlinks GitHub account from authenticated user (requires JWT auth)
179
179
  */
180
- function addGitHubAuthRoutes(app, userModel, githubOptions, authOptions) {
181
- var _this = this;
180
+ var addGitHubAuthRoutes = function (app, userModel, githubOptions, authOptions) {
182
181
  var router = require("express").Router();
183
182
  // Initiate GitHub OAuth flow
184
183
  router.get("/github", function (req, _res, next) {
@@ -194,7 +193,7 @@ function addGitHubAuthRoutes(app, userModel, githubOptions, authOptions) {
194
193
  router.get("/github/callback", passport_1.default.authenticate("github", {
195
194
  failureRedirect: "/auth/github/failure",
196
195
  session: false,
197
- }), function (req, res) { return __awaiter(_this, void 0, void 0, function () {
196
+ }), function (req, res) { return __awaiter(void 0, void 0, void 0, function () {
198
197
  var tokens, returnTo, url, error_2;
199
198
  var _a, _b, _c, _d;
200
199
  return __generator(this, function (_e) {
@@ -249,7 +248,7 @@ function addGitHubAuthRoutes(app, userModel, githubOptions, authOptions) {
249
248
  })(req, res, next);
250
249
  }, passport_1.default.authenticate("github", { session: false }));
251
250
  // Unlink GitHub from user account
252
- router.delete("/github/unlink", passport_1.default.authenticate("jwt", { session: false }), function (req, res) { return __awaiter(_this, void 0, void 0, function () {
251
+ router.delete("/github/unlink", passport_1.default.authenticate("jwt", { session: false }), function (req, res) { return __awaiter(void 0, void 0, void 0, function () {
253
252
  var user, hasPassword, error_3;
254
253
  return __generator(this, function (_a) {
255
254
  switch (_a.label) {
@@ -290,4 +289,5 @@ function addGitHubAuthRoutes(app, userModel, githubOptions, authOptions) {
290
289
  }); });
291
290
  }
292
291
  app.use("/auth", router);
293
- }
292
+ };
293
+ exports.addGitHubAuthRoutes = addGitHubAuthRoutes;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ var bun_test_1 = require("bun:test");
37
+ var Sentry = __importStar(require("@sentry/bun"));
38
+ var middleware_1 = require("./middleware");
39
+ var buildReq = function (headers) {
40
+ return {
41
+ get: function (name) { return headers[name]; },
42
+ };
43
+ };
44
+ var buildNext = function () { return (0, bun_test_1.mock)(function () { }); };
45
+ (0, bun_test_1.describe)("sentryAppVersionMiddleware", function () {
46
+ var setTagMock;
47
+ (0, bun_test_1.beforeEach)(function () {
48
+ // bunSetup.ts mocks @sentry/bun so that getCurrentScope() returns a scope
49
+ // with a Bun mock setTag. Clear that mock between tests so each assertion
50
+ // sees only its own calls.
51
+ setTagMock = Sentry.getCurrentScope().setTag;
52
+ setTagMock.mockClear();
53
+ });
54
+ (0, bun_test_1.it)("sets the app_version tag when the App-Version header is present", function () {
55
+ var next = buildNext();
56
+ var req = buildReq({ "App-Version": "1.2.3" });
57
+ (0, middleware_1.sentryAppVersionMiddleware)(req, {}, next);
58
+ (0, bun_test_1.expect)(setTagMock).toHaveBeenCalledTimes(1);
59
+ (0, bun_test_1.expect)(setTagMock.mock.calls[0]).toEqual(["app_version", "1.2.3"]);
60
+ (0, bun_test_1.expect)(next).toHaveBeenCalledTimes(1);
61
+ });
62
+ (0, bun_test_1.it)("does not set a tag when the App-Version header is missing", function () {
63
+ var next = buildNext();
64
+ var req = buildReq({});
65
+ (0, middleware_1.sentryAppVersionMiddleware)(req, {}, next);
66
+ (0, bun_test_1.expect)(setTagMock).not.toHaveBeenCalled();
67
+ (0, bun_test_1.expect)(next).toHaveBeenCalledTimes(1);
68
+ });
69
+ (0, bun_test_1.it)("does not set a tag when the App-Version header is an empty string", function () {
70
+ var next = buildNext();
71
+ var req = buildReq({ "App-Version": "" });
72
+ (0, middleware_1.sentryAppVersionMiddleware)(req, {}, next);
73
+ (0, bun_test_1.expect)(setTagMock).not.toHaveBeenCalled();
74
+ (0, bun_test_1.expect)(next).toHaveBeenCalledTimes(1);
75
+ });
76
+ (0, bun_test_1.it)("calls next exactly once with no arguments when the header is present", function () {
77
+ var next = buildNext();
78
+ (0, middleware_1.sentryAppVersionMiddleware)(buildReq({ "App-Version": "9.9.9" }), {}, next);
79
+ (0, bun_test_1.expect)(next).toHaveBeenCalledTimes(1);
80
+ (0, bun_test_1.expect)(next.mock.calls[0]).toHaveLength(0);
81
+ });
82
+ });
@@ -108,7 +108,7 @@ var sendToGoogleChat = function (messageText_1) {
108
108
  args_1[_i - 1] = arguments[_i];
109
109
  }
110
110
  return __awaiter(void 0, __spreadArray([messageText_1], __read(args_1), false), void 0, function (messageText, _a) {
111
- var chatWebhooksString, msg, chatWebhooks, chatChannel, chatWebhookUrl, msg, formattedMessageText, error_1;
111
+ var chatWebhooksString, msg, chatWebhooks, chatChannel, chatWebhookUrl, msg, formattedMessageText, error_1, errorObj;
112
112
  var _b, _c, _d;
113
113
  var _e = _a === void 0 ? {} : _a, channel = _e.channel, _f = _e.shouldThrow, shouldThrow = _f === void 0 ? false : _f, env = _e.env;
114
114
  return __generator(this, function (_g) {
@@ -143,12 +143,13 @@ var sendToGoogleChat = function (messageText_1) {
143
143
  return [3 /*break*/, 4];
144
144
  case 3:
145
145
  error_1 = _g.sent();
146
- logger_1.logger.error("Error posting to Google Chat: ".concat((_c = error_1.text) !== null && _c !== void 0 ? _c : error_1.message));
146
+ errorObj = error_1;
147
+ logger_1.logger.error("Error posting to Google Chat: ".concat((_c = errorObj.text) !== null && _c !== void 0 ? _c : errorObj.message));
147
148
  Sentry.captureException(error_1);
148
149
  if (shouldThrow) {
149
150
  throw new errors_1.APIError({
150
151
  status: 500,
151
- title: "Error posting to Google Chat: ".concat((_d = error_1.text) !== null && _d !== void 0 ? _d : error_1.message),
152
+ title: "Error posting to Google Chat: ".concat((_d = errorObj.text) !== null && _d !== void 0 ? _d : errorObj.message),
152
153
  });
153
154
  }
154
155
  return [3 /*break*/, 4];
package/dist/plugins.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type Document, type Schema, SchemaType, type SchemaTypeOptions } from "mongoose";
1
+ import mongoose, { type Document, type FilterQuery, type Schema, SchemaType, type SchemaTypeOptions } from "mongoose";
2
2
  import { type APIErrorConstructor } from "./errors";
3
3
  export interface BaseUser {
4
4
  admin: boolean;
@@ -31,6 +31,17 @@ export declare const firebaseJWTPlugin: (schema: Schema) => void;
31
31
  * @param schema Mongoose Schema
32
32
  */
33
33
  export declare const findOneOrNone: <T>(schema: Schema<T>) => void;
34
+ /**
35
+ * Helper that performs a `findOneOrNone` lookup against any Mongoose model. Returns the matching
36
+ * document, `null` if none match, or throws if more than one matches. If the model's schema has
37
+ * the {@link findOneOrNone} plugin applied, the plugin static is used; otherwise the lookup is
38
+ * performed directly via `model.find(...)`. Prefer this helper from framework code where the
39
+ * consumer's model may or may not have the plugin installed.
40
+ * @param model Mongoose Model
41
+ * @param query Mongoose query object
42
+ * @param errorArgs Optional overrides for the thrown {@link APIError} when multiple match
43
+ */
44
+ export declare const findOneOrNoneFor: <T>(model: mongoose.Model<T>, query: FilterQuery<T>, errorArgs?: Partial<APIErrorConstructor>) => Promise<(Document & T) | null>;
34
45
  /**
35
46
  * This adds a static method `Model.findExactlyOne` to the schema. This or findOneOrNone should replace `Model.findOne`
36
47
  * in most instances.
package/dist/plugins.js CHANGED
@@ -95,7 +95,7 @@ var __generator = (this && this.__generator) || function (thisArg, body) {
95
95
  }
96
96
  };
97
97
  Object.defineProperty(exports, "__esModule", { value: true });
98
- exports.DateOnly = exports.upsertPlugin = exports.findExactlyOne = exports.findOneOrNone = exports.firebaseJWTPlugin = exports.createdUpdatedPlugin = exports.isDisabledPlugin = exports.isDeletedPlugin = exports.baseUserPlugin = void 0;
98
+ exports.DateOnly = exports.upsertPlugin = exports.findExactlyOne = exports.findOneOrNoneFor = exports.findOneOrNone = exports.firebaseJWTPlugin = exports.createdUpdatedPlugin = exports.isDisabledPlugin = exports.isDeletedPlugin = exports.baseUserPlugin = void 0;
99
99
  var luxon_1 = require("luxon");
100
100
  var mongoose_1 = __importStar(require("mongoose"));
101
101
  var errors_1 = require("./errors");
@@ -199,6 +199,39 @@ var findOneOrNone = function (schema) {
199
199
  };
200
200
  };
201
201
  exports.findOneOrNone = findOneOrNone;
202
+ /**
203
+ * Helper that performs a `findOneOrNone` lookup against any Mongoose model. Returns the matching
204
+ * document, `null` if none match, or throws if more than one matches. If the model's schema has
205
+ * the {@link findOneOrNone} plugin applied, the plugin static is used; otherwise the lookup is
206
+ * performed directly via `model.find(...)`. Prefer this helper from framework code where the
207
+ * consumer's model may or may not have the plugin installed.
208
+ * @param model Mongoose Model
209
+ * @param query Mongoose query object
210
+ * @param errorArgs Optional overrides for the thrown {@link APIError} when multiple match
211
+ */
212
+ var findOneOrNoneFor = function (model, query, errorArgs) { return __awaiter(void 0, void 0, void 0, function () {
213
+ var withStatic, results;
214
+ return __generator(this, function (_a) {
215
+ switch (_a.label) {
216
+ case 0:
217
+ withStatic = model;
218
+ if (typeof withStatic.findOneOrNone === "function") {
219
+ return [2 /*return*/, withStatic.findOneOrNone(query, errorArgs)];
220
+ }
221
+ return [4 /*yield*/, model.find(query)];
222
+ case 1:
223
+ results = _a.sent();
224
+ if (results.length === 0) {
225
+ return [2 /*return*/, null];
226
+ }
227
+ if (results.length > 1) {
228
+ throw new errors_1.APIError(__assign({ detail: "query: ".concat(JSON.stringify(query)), status: 500, title: "".concat(model.modelName, ".findOne query returned multiple documents") }, errorArgs));
229
+ }
230
+ return [2 /*return*/, results[0]];
231
+ }
232
+ });
233
+ }); };
234
+ exports.findOneOrNoneFor = findOneOrNoneFor;
202
235
  /**
203
236
  * This adds a static method `Model.findExactlyOne` to the schema. This or findOneOrNone should replace `Model.findOne`
204
237
  * in most instances.
@@ -278,6 +278,188 @@ var StuffModel = (0, mongoose_1.model)("Stuff", stuffSchema);
278
278
  });
279
279
  }); });
280
280
  });
281
+ var bareThingSchema = new mongoose_1.Schema({
282
+ name: { description: "The name of the bare item", type: String },
283
+ ownerId: { description: "The owner of the bare item", type: String },
284
+ });
285
+ var BareThingModel = (0, mongoose_1.model)("BareThing", bareThingSchema);
286
+ (0, bun_test_1.describe)("findOneOrNoneFor", function () {
287
+ (0, bun_test_1.beforeEach)(function () { return __awaiter(void 0, void 0, void 0, function () {
288
+ return __generator(this, function (_a) {
289
+ switch (_a.label) {
290
+ case 0: return [4 /*yield*/, StuffModel.deleteMany({})];
291
+ case 1:
292
+ _a.sent();
293
+ return [4 /*yield*/, BareThingModel.deleteMany({})];
294
+ case 2:
295
+ _a.sent();
296
+ return [4 /*yield*/, (0, tests_1.setupDb)()];
297
+ case 3:
298
+ _a.sent();
299
+ return [2 /*return*/];
300
+ }
301
+ });
302
+ }); });
303
+ (0, bun_test_1.describe)("when the schema has the findOneOrNone plugin", function () {
304
+ var things;
305
+ (0, bun_test_1.beforeEach)(function () { return __awaiter(void 0, void 0, void 0, function () {
306
+ var _a;
307
+ return __generator(this, function (_b) {
308
+ switch (_b.label) {
309
+ case 0: return [4 /*yield*/, Promise.all([
310
+ StuffModel.create({ name: "Things", ownerId: "123" }),
311
+ StuffModel.create({ name: "StuffNThings", ownerId: "123" }),
312
+ ])];
313
+ case 1:
314
+ _a = __read.apply(void 0, [_b.sent(), 1]), things = _a[0];
315
+ return [2 /*return*/];
316
+ }
317
+ });
318
+ }); });
319
+ (0, bun_test_1.it)("returns null with no matches", function () { return __awaiter(void 0, void 0, void 0, function () {
320
+ var result;
321
+ return __generator(this, function (_a) {
322
+ switch (_a.label) {
323
+ case 0: return [4 /*yield*/, (0, plugins_1.findOneOrNoneFor)(StuffModel, { name: "OtherStuff" })];
324
+ case 1:
325
+ result = _a.sent();
326
+ (0, bun_test_1.expect)(result).toBeNull();
327
+ return [2 /*return*/];
328
+ }
329
+ });
330
+ }); });
331
+ (0, bun_test_1.it)("returns a single match", function () { return __awaiter(void 0, void 0, void 0, function () {
332
+ var result;
333
+ return __generator(this, function (_a) {
334
+ switch (_a.label) {
335
+ case 0: return [4 /*yield*/, (0, plugins_1.findOneOrNoneFor)(StuffModel, { name: "Things" })];
336
+ case 1:
337
+ result = _a.sent();
338
+ (0, bun_test_1.expect)(result).not.toBeNull();
339
+ (0, bun_test_1.expect)(result === null || result === void 0 ? void 0 : result._id.toString()).toBe(things._id.toString());
340
+ return [2 /*return*/];
341
+ }
342
+ });
343
+ }); });
344
+ (0, bun_test_1.it)("throws when multiple documents match", function () { return __awaiter(void 0, void 0, void 0, function () {
345
+ var fn;
346
+ return __generator(this, function (_a) {
347
+ switch (_a.label) {
348
+ case 0:
349
+ fn = function () { return (0, plugins_1.findOneOrNoneFor)(StuffModel, { ownerId: "123" }); };
350
+ return [4 /*yield*/, (0, bun_test_1.expect)(fn()).rejects.toThrow(/Stuff\.findOne query returned multiple documents/)];
351
+ case 1:
352
+ _a.sent();
353
+ return [2 /*return*/];
354
+ }
355
+ });
356
+ }); });
357
+ (0, bun_test_1.it)("forwards errorArgs to the thrown APIError", function () { return __awaiter(void 0, void 0, void 0, function () {
358
+ var fn, error_2;
359
+ return __generator(this, function (_a) {
360
+ switch (_a.label) {
361
+ case 0:
362
+ fn = function () {
363
+ return (0, plugins_1.findOneOrNoneFor)(StuffModel, { ownerId: "123" }, { status: 400, title: "Oh no!" });
364
+ };
365
+ _a.label = 1;
366
+ case 1:
367
+ _a.trys.push([1, 3, , 4]);
368
+ return [4 /*yield*/, fn()];
369
+ case 2:
370
+ _a.sent();
371
+ throw new Error("Expected promise to reject");
372
+ case 3:
373
+ error_2 = _a.sent();
374
+ (0, bun_test_1.expect)(error_2.title).toBe("Oh no!");
375
+ (0, bun_test_1.expect)(error_2.status).toBe(400);
376
+ return [3 /*break*/, 4];
377
+ case 4: return [2 /*return*/];
378
+ }
379
+ });
380
+ }); });
381
+ });
382
+ (0, bun_test_1.describe)("when the schema does NOT have the findOneOrNone plugin", function () {
383
+ var bareThings;
384
+ (0, bun_test_1.beforeEach)(function () { return __awaiter(void 0, void 0, void 0, function () {
385
+ var _a;
386
+ return __generator(this, function (_b) {
387
+ switch (_b.label) {
388
+ case 0: return [4 /*yield*/, Promise.all([
389
+ BareThingModel.create({ name: "Things", ownerId: "123" }),
390
+ BareThingModel.create({ name: "StuffNThings", ownerId: "123" }),
391
+ ])];
392
+ case 1:
393
+ _a = __read.apply(void 0, [_b.sent(), 1]), bareThings = _a[0];
394
+ return [2 /*return*/];
395
+ }
396
+ });
397
+ }); });
398
+ (0, bun_test_1.it)("returns null with no matches", function () { return __awaiter(void 0, void 0, void 0, function () {
399
+ var result;
400
+ return __generator(this, function (_a) {
401
+ switch (_a.label) {
402
+ case 0: return [4 /*yield*/, (0, plugins_1.findOneOrNoneFor)(BareThingModel, { name: "OtherStuff" })];
403
+ case 1:
404
+ result = _a.sent();
405
+ (0, bun_test_1.expect)(result).toBeNull();
406
+ return [2 /*return*/];
407
+ }
408
+ });
409
+ }); });
410
+ (0, bun_test_1.it)("returns a single match", function () { return __awaiter(void 0, void 0, void 0, function () {
411
+ var result;
412
+ var _a, _b;
413
+ return __generator(this, function (_c) {
414
+ switch (_c.label) {
415
+ case 0: return [4 /*yield*/, (0, plugins_1.findOneOrNoneFor)(BareThingModel, { name: "Things" })];
416
+ case 1:
417
+ result = _c.sent();
418
+ (0, bun_test_1.expect)(result).not.toBeNull();
419
+ (0, bun_test_1.expect)((_a = result === null || result === void 0 ? void 0 : result._id) === null || _a === void 0 ? void 0 : _a.toString()).toBe((_b = bareThings._id) === null || _b === void 0 ? void 0 : _b.toString());
420
+ return [2 /*return*/];
421
+ }
422
+ });
423
+ }); });
424
+ (0, bun_test_1.it)("throws when multiple documents match", function () { return __awaiter(void 0, void 0, void 0, function () {
425
+ var fn;
426
+ return __generator(this, function (_a) {
427
+ switch (_a.label) {
428
+ case 0:
429
+ fn = function () { return (0, plugins_1.findOneOrNoneFor)(BareThingModel, { ownerId: "123" }); };
430
+ return [4 /*yield*/, (0, bun_test_1.expect)(fn()).rejects.toThrow(/BareThing\.findOne query returned multiple documents/)];
431
+ case 1:
432
+ _a.sent();
433
+ return [2 /*return*/];
434
+ }
435
+ });
436
+ }); });
437
+ (0, bun_test_1.it)("forwards errorArgs to the thrown APIError", function () { return __awaiter(void 0, void 0, void 0, function () {
438
+ var fn, error_3;
439
+ return __generator(this, function (_a) {
440
+ switch (_a.label) {
441
+ case 0:
442
+ fn = function () {
443
+ return (0, plugins_1.findOneOrNoneFor)(BareThingModel, { ownerId: "123" }, { status: 400, title: "Oh no!" });
444
+ };
445
+ _a.label = 1;
446
+ case 1:
447
+ _a.trys.push([1, 3, , 4]);
448
+ return [4 /*yield*/, fn()];
449
+ case 2:
450
+ _a.sent();
451
+ throw new Error("Expected promise to reject");
452
+ case 3:
453
+ error_3 = _a.sent();
454
+ (0, bun_test_1.expect)(error_3.title).toBe("Oh no!");
455
+ (0, bun_test_1.expect)(error_3.status).toBe(400);
456
+ return [3 /*break*/, 4];
457
+ case 4: return [2 /*return*/];
458
+ }
459
+ });
460
+ }); });
461
+ });
462
+ });
281
463
  (0, bun_test_1.describe)("findExactlyOne", function () {
282
464
  var things;
283
465
  (0, bun_test_1.beforeEach)(function () { return __awaiter(void 0, void 0, void 0, function () {
@@ -345,7 +527,7 @@ var StuffModel = (0, mongoose_1.model)("Stuff", stuffSchema);
345
527
  });
346
528
  }); });
347
529
  (0, bun_test_1.it)("throws custom error with two matches.", function () { return __awaiter(void 0, void 0, void 0, function () {
348
- var fn, error_2;
530
+ var fn, error_4;
349
531
  return __generator(this, function (_a) {
350
532
  switch (_a.label) {
351
533
  case 0:
@@ -359,11 +541,11 @@ var StuffModel = (0, mongoose_1.model)("Stuff", stuffSchema);
359
541
  // If the promise doesn't reject, the test should fail
360
542
  throw new Error("Expected promise to reject");
361
543
  case 3:
362
- error_2 = _a.sent();
544
+ error_4 = _a.sent();
363
545
  // Check if the error has title and status properties
364
- (0, bun_test_1.expect)(error_2.title).toBe("Oh no!");
365
- (0, bun_test_1.expect)(error_2.status).toBe(400);
366
- (0, bun_test_1.expect)(error_2.detail).toBe('query: {"ownerId":"123"}');
546
+ (0, bun_test_1.expect)(error_4.title).toBe("Oh no!");
547
+ (0, bun_test_1.expect)(error_4.status).toBe(400);
548
+ (0, bun_test_1.expect)(error_4.detail).toBe('query: {"ownerId":"123"}');
367
549
  return [3 /*break*/, 4];
368
550
  case 4: return [2 /*return*/];
369
551
  }
@@ -526,7 +708,7 @@ var StuffModel = (0, mongoose_1.model)("Stuff", stuffSchema);
526
708
  });
527
709
  (0, bun_test_1.describe)("DateOnly", function () {
528
710
  (0, bun_test_1.it)("throws error with invalid date", function () { return __awaiter(void 0, void 0, void 0, function () {
529
- var error_3;
711
+ var error_5;
530
712
  return __generator(this, function (_a) {
531
713
  switch (_a.label) {
532
714
  case 0:
@@ -540,8 +722,8 @@ var StuffModel = (0, mongoose_1.model)("Stuff", stuffSchema);
540
722
  _a.sent();
541
723
  return [3 /*break*/, 3];
542
724
  case 2:
543
- error_3 = _a.sent();
544
- (0, bun_test_1.expect)(error_3.message).toMatch(/Cast to DateOnly failed/);
725
+ error_5 = _a.sent();
726
+ (0, bun_test_1.expect)(error_5.message).toMatch(/Cast to DateOnly failed/);
545
727
  return [2 /*return*/];
546
728
  case 3: throw new Error("Expected error was not thrown");
547
729
  }
@@ -7,8 +7,10 @@ export interface ConsentFormCheckbox {
7
7
  }
8
8
  export type ConsentFormType = "agreement" | "privacy" | "hipaa" | "research" | "terms" | "custom";
9
9
  export type ConsentFormMethods = {};
10
- export type ConsentFormStatics = FindExactlyOnePlugin<ConsentFormDocument> & FindOneOrNonePlugin<ConsentFormDocument>;
11
- export type ConsentFormModel = mongoose.Model<ConsentFormDocument, object, ConsentFormMethods> & ConsentFormStatics;
10
+ export interface ConsentFormStatics extends FindExactlyOnePlugin<ConsentFormDocument>, FindOneOrNonePlugin<ConsentFormDocument> {
11
+ }
12
+ export interface ConsentFormModel extends mongoose.Model<ConsentFormDocument, object, ConsentFormMethods>, ConsentFormStatics {
13
+ }
12
14
  export interface ConsentFormDocument extends mongoose.Document {
13
15
  _id: mongoose.Types.ObjectId;
14
16
  title: string;
package/package.json CHANGED
@@ -104,5 +104,5 @@
104
104
  "updateSnapshot": "bun test --update-snapshots"
105
105
  },
106
106
  "types": "dist/index.d.ts",
107
- "version": "0.11.8"
107
+ "version": "0.11.9"
108
108
  }
@@ -13,6 +13,7 @@ import mongoose from "mongoose";
13
13
  import type {UserModel} from "./auth";
14
14
  import type {BetterAuthConfig, BetterAuthSessionData, BetterAuthUser} from "./betterAuth";
15
15
  import {logger} from "./logger";
16
+ import {findOneOrNoneFor} from "./plugins";
16
17
 
17
18
  /**
18
19
  * The Better Auth instance type.
@@ -109,7 +110,9 @@ export const createBetterAuthSessionMiddleware = (
109
110
 
110
111
  if (userModel) {
111
112
  // Look up the application user by betterAuthId
112
- const appUser = await userModel.findOne({betterAuthId: betterAuthUser.id});
113
+ const appUser = await findOneOrNoneFor(userModel, {
114
+ betterAuthId: betterAuthUser.id,
115
+ });
113
116
  if (appUser) {
114
117
  (req as any).user = appUser;
115
118
  (req as any).betterAuthSession = session;
@@ -151,7 +154,9 @@ export const syncBetterAuthUser = async (
151
154
  oauthProvider?: string
152
155
  ): Promise<any> => {
153
156
  try {
154
- const existingUser: any = await userModel.findOne({betterAuthId: betterAuthUser.id});
157
+ const existingUser: any = await findOneOrNoneFor(userModel, {
158
+ betterAuthId: betterAuthUser.id,
159
+ });
155
160
 
156
161
  if (existingUser) {
157
162
  // Update existing user if needed
@@ -164,7 +169,9 @@ export const syncBetterAuthUser = async (
164
169
  }
165
170
 
166
171
  // Check if user exists by email (migration case)
167
- const userByEmail: any = await userModel.findOne({email: betterAuthUser.email});
172
+ const userByEmail: any = await findOneOrNoneFor(userModel, {
173
+ email: betterAuthUser.email,
174
+ });
168
175
  if (userByEmail) {
169
176
  // Link existing user to Better Auth
170
177
  userByEmail.betterAuthId = betterAuthUser.id;
package/src/githubAuth.ts CHANGED
@@ -5,6 +5,7 @@ import {generateTokens, type UserModel} from "./auth";
5
5
  import {APIError} from "./errors";
6
6
  import type {AuthOptions} from "./expressServer";
7
7
  import {logger} from "./logger";
8
+ import {findOneOrNoneFor} from "./plugins";
8
9
 
9
10
  /** Options for configuring GitHub OAuth authentication */
10
11
  export interface GitHubAuthOptions {
@@ -57,24 +58,24 @@ export interface GitHubUserFields {
57
58
  * userSchema.plugin(githubUserPlugin);
58
59
  * ```
59
60
  */
60
- export function githubUserPlugin(schema: any) {
61
+ export const githubUserPlugin = (schema: any): void => {
61
62
  schema.add({
62
63
  githubAvatarUrl: {type: String},
63
64
  githubId: {index: true, sparse: true, type: String, unique: true},
64
65
  githubProfileUrl: {type: String},
65
66
  githubUsername: {type: String},
66
67
  });
67
- }
68
+ };
68
69
 
69
70
  /**
70
71
  * Sets up GitHub OAuth authentication strategy.
71
72
  * Call this after setupAuth() in your server initialization.
72
73
  */
73
- export function setupGitHubAuth(
74
+ export const setupGitHubAuth = (
74
75
  _app: express.Application,
75
76
  userModel: UserModel,
76
77
  githubOptions: GitHubAuthOptions
77
- ) {
78
+ ): void => {
78
79
  const scope = githubOptions.scope ?? ["user:email"];
79
80
 
80
81
  passport.use(
@@ -112,7 +113,7 @@ export function setupGitHubAuth(
112
113
  const githubId = profile.id;
113
114
 
114
115
  // Check if user with this GitHub ID already exists
115
- const existingGitHubUser = await userModel.findOne({githubId} as any);
116
+ const existingGitHubUser = await findOneOrNoneFor(userModel, {githubId});
116
117
 
117
118
  // Case 1: User is authenticated and wants to link GitHub account
118
119
  if (existingUser) {
@@ -155,7 +156,7 @@ export function setupGitHubAuth(
155
156
 
156
157
  // Check if user with this email already exists
157
158
  if (email) {
158
- const existingEmailUser = await userModel.findOne({email} as any);
159
+ const existingEmailUser = await findOneOrNoneFor(userModel, {email});
159
160
  if (existingEmailUser) {
160
161
  // If account linking is allowed, link GitHub to existing email account
161
162
  if (githubOptions.allowAccountLinking !== false) {
@@ -195,7 +196,7 @@ export function setupGitHubAuth(
195
196
  }) as any
196
197
  ) as passport.Strategy
197
198
  );
198
- }
199
+ };
199
200
 
200
201
  /**
201
202
  * Adds GitHub OAuth routes to the Express application.
@@ -206,12 +207,12 @@ export function setupGitHubAuth(
206
207
  * - POST /auth/github/link - Links GitHub account to authenticated user (requires JWT auth)
207
208
  * - DELETE /auth/github/unlink - Unlinks GitHub account from authenticated user (requires JWT auth)
208
209
  */
209
- export function addGitHubAuthRoutes(
210
+ export const addGitHubAuthRoutes = (
210
211
  app: express.Application,
211
212
  userModel: UserModel,
212
213
  githubOptions: GitHubAuthOptions,
213
214
  authOptions?: AuthOptions
214
- ): void {
215
+ ): void => {
215
216
  const router = require("express").Router();
216
217
 
217
218
  // Initiate GitHub OAuth flow
@@ -332,4 +333,4 @@ export function addGitHubAuthRoutes(
332
333
  }
333
334
 
334
335
  app.use("/auth", router);
335
- }
336
+ };
@@ -0,0 +1,71 @@
1
+ import {beforeEach, describe, expect, it, type Mock, mock} from "bun:test";
2
+ import * as Sentry from "@sentry/bun";
3
+ import type {NextFunction, Request, Response} from "express";
4
+
5
+ import {sentryAppVersionMiddleware} from "./middleware";
6
+
7
+ const buildReq = (headers: Record<string, string | undefined>): Request => {
8
+ return {
9
+ get: (name: string) => headers[name],
10
+ } as unknown as Request;
11
+ };
12
+
13
+ const buildNext = (): Mock<() => void> => mock(() => {});
14
+
15
+ describe("sentryAppVersionMiddleware", () => {
16
+ let setTagMock: Mock<(key: string, value: string) => void>;
17
+
18
+ beforeEach(() => {
19
+ // bunSetup.ts mocks @sentry/bun so that getCurrentScope() returns a scope
20
+ // with a Bun mock setTag. Clear that mock between tests so each assertion
21
+ // sees only its own calls.
22
+ setTagMock = Sentry.getCurrentScope().setTag as unknown as Mock<
23
+ (key: string, value: string) => void
24
+ >;
25
+ setTagMock.mockClear();
26
+ });
27
+
28
+ it("sets the app_version tag when the App-Version header is present", () => {
29
+ const next = buildNext();
30
+ const req = buildReq({"App-Version": "1.2.3"});
31
+
32
+ sentryAppVersionMiddleware(req, {} as Response, next as unknown as NextFunction);
33
+
34
+ expect(setTagMock).toHaveBeenCalledTimes(1);
35
+ expect(setTagMock.mock.calls[0]).toEqual(["app_version", "1.2.3"]);
36
+ expect(next).toHaveBeenCalledTimes(1);
37
+ });
38
+
39
+ it("does not set a tag when the App-Version header is missing", () => {
40
+ const next = buildNext();
41
+ const req = buildReq({});
42
+
43
+ sentryAppVersionMiddleware(req, {} as Response, next as unknown as NextFunction);
44
+
45
+ expect(setTagMock).not.toHaveBeenCalled();
46
+ expect(next).toHaveBeenCalledTimes(1);
47
+ });
48
+
49
+ it("does not set a tag when the App-Version header is an empty string", () => {
50
+ const next = buildNext();
51
+ const req = buildReq({"App-Version": ""});
52
+
53
+ sentryAppVersionMiddleware(req, {} as Response, next as unknown as NextFunction);
54
+
55
+ expect(setTagMock).not.toHaveBeenCalled();
56
+ expect(next).toHaveBeenCalledTimes(1);
57
+ });
58
+
59
+ it("calls next exactly once with no arguments when the header is present", () => {
60
+ const next = buildNext();
61
+
62
+ sentryAppVersionMiddleware(
63
+ buildReq({"App-Version": "9.9.9"}),
64
+ {} as Response,
65
+ next as unknown as NextFunction
66
+ );
67
+
68
+ expect(next).toHaveBeenCalledTimes(1);
69
+ expect(next.mock.calls[0]).toHaveLength(0);
70
+ });
71
+ });
@@ -34,13 +34,14 @@ export const sendToGoogleChat = async (
34
34
 
35
35
  try {
36
36
  await axios.post(chatWebhookUrl, {text: formattedMessageText});
37
- } catch (error: any) {
38
- logger.error(`Error posting to Google Chat: ${error.text ?? error.message}`);
37
+ } catch (error: unknown) {
38
+ const errorObj = error as {text?: string; message?: string};
39
+ logger.error(`Error posting to Google Chat: ${errorObj.text ?? errorObj.message}`);
39
40
  Sentry.captureException(error);
40
41
  if (shouldThrow) {
41
42
  throw new APIError({
42
43
  status: 500,
43
- title: `Error posting to Google Chat: ${error.text ?? error.message}`,
44
+ title: `Error posting to Google Chat: ${errorObj.text ?? errorObj.message}`,
44
45
  });
45
46
  }
46
47
  }
@@ -13,6 +13,7 @@ import {
13
13
  DateOnly,
14
14
  findExactlyOne,
15
15
  findOneOrNone,
16
+ findOneOrNoneFor,
16
17
  firebaseJWTPlugin,
17
18
  type IsDeleted,
18
19
  isDeletedPlugin,
@@ -187,6 +188,106 @@ describe("findOneOrNone", () => {
187
188
  });
188
189
  });
189
190
 
191
+ interface BareThing {
192
+ name: string;
193
+ ownerId: string;
194
+ }
195
+
196
+ const bareThingSchema = new Schema<BareThing>({
197
+ name: {description: "The name of the bare item", type: String},
198
+ ownerId: {description: "The owner of the bare item", type: String},
199
+ });
200
+
201
+ const BareThingModel = model<BareThing>("BareThing", bareThingSchema);
202
+
203
+ describe("findOneOrNoneFor", () => {
204
+ beforeEach(async () => {
205
+ await StuffModel.deleteMany({});
206
+ await BareThingModel.deleteMany({});
207
+ await setupDb();
208
+ });
209
+
210
+ describe("when the schema has the findOneOrNone plugin", () => {
211
+ let things: any;
212
+
213
+ beforeEach(async () => {
214
+ [things] = await Promise.all([
215
+ StuffModel.create({name: "Things", ownerId: "123"}),
216
+ StuffModel.create({name: "StuffNThings", ownerId: "123"}),
217
+ ]);
218
+ });
219
+
220
+ it("returns null with no matches", async () => {
221
+ const result = await findOneOrNoneFor(StuffModel, {name: "OtherStuff"});
222
+ expect(result).toBeNull();
223
+ });
224
+
225
+ it("returns a single match", async () => {
226
+ const result = await findOneOrNoneFor(StuffModel, {name: "Things"});
227
+ expect(result).not.toBeNull();
228
+ expect(result?._id.toString()).toBe(things._id.toString());
229
+ });
230
+
231
+ it("throws when multiple documents match", async () => {
232
+ const fn = () => findOneOrNoneFor(StuffModel, {ownerId: "123"});
233
+ await expect(fn()).rejects.toThrow(/Stuff\.findOne query returned multiple documents/);
234
+ });
235
+
236
+ it("forwards errorArgs to the thrown APIError", async () => {
237
+ const fn = () =>
238
+ findOneOrNoneFor(StuffModel, {ownerId: "123"}, {status: 400, title: "Oh no!"});
239
+
240
+ try {
241
+ await fn();
242
+ throw new Error("Expected promise to reject");
243
+ } catch (error: any) {
244
+ expect(error.title).toBe("Oh no!");
245
+ expect(error.status).toBe(400);
246
+ }
247
+ });
248
+ });
249
+
250
+ describe("when the schema does NOT have the findOneOrNone plugin", () => {
251
+ let bareThings: any;
252
+
253
+ beforeEach(async () => {
254
+ [bareThings] = await Promise.all([
255
+ BareThingModel.create({name: "Things", ownerId: "123"}),
256
+ BareThingModel.create({name: "StuffNThings", ownerId: "123"}),
257
+ ]);
258
+ });
259
+
260
+ it("returns null with no matches", async () => {
261
+ const result = await findOneOrNoneFor(BareThingModel, {name: "OtherStuff"});
262
+ expect(result).toBeNull();
263
+ });
264
+
265
+ it("returns a single match", async () => {
266
+ const result = await findOneOrNoneFor(BareThingModel, {name: "Things"});
267
+ expect(result).not.toBeNull();
268
+ expect((result as any)?._id?.toString()).toBe(bareThings._id?.toString());
269
+ });
270
+
271
+ it("throws when multiple documents match", async () => {
272
+ const fn = () => findOneOrNoneFor(BareThingModel, {ownerId: "123"});
273
+ await expect(fn()).rejects.toThrow(/BareThing\.findOne query returned multiple documents/);
274
+ });
275
+
276
+ it("forwards errorArgs to the thrown APIError", async () => {
277
+ const fn = () =>
278
+ findOneOrNoneFor(BareThingModel, {ownerId: "123"}, {status: 400, title: "Oh no!"});
279
+
280
+ try {
281
+ await fn();
282
+ throw new Error("Expected promise to reject");
283
+ } catch (error: any) {
284
+ expect(error.title).toBe("Oh no!");
285
+ expect(error.status).toBe(400);
286
+ }
287
+ });
288
+ });
289
+ });
290
+
190
291
  describe("findExactlyOne", () => {
191
292
  let things: any;
192
293
 
package/src/plugins.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import {DateTime} from "luxon";
2
2
  import mongoose, {
3
3
  type Document,
4
+ type FilterQuery,
4
5
  Error as MongooseError,
5
6
  type Query,
6
7
  type Schema,
@@ -129,6 +130,40 @@ export const findOneOrNone = <T>(schema: Schema<T>): void => {
129
130
  };
130
131
  };
131
132
 
133
+ /**
134
+ * Helper that performs a `findOneOrNone` lookup against any Mongoose model. Returns the matching
135
+ * document, `null` if none match, or throws if more than one matches. If the model's schema has
136
+ * the {@link findOneOrNone} plugin applied, the plugin static is used; otherwise the lookup is
137
+ * performed directly via `model.find(...)`. Prefer this helper from framework code where the
138
+ * consumer's model may or may not have the plugin installed.
139
+ * @param model Mongoose Model
140
+ * @param query Mongoose query object
141
+ * @param errorArgs Optional overrides for the thrown {@link APIError} when multiple match
142
+ */
143
+ export const findOneOrNoneFor = async <T>(
144
+ model: mongoose.Model<T>,
145
+ query: FilterQuery<T>,
146
+ errorArgs?: Partial<APIErrorConstructor>
147
+ ): Promise<(Document & T) | null> => {
148
+ const withStatic = model as mongoose.Model<T> & Partial<FindOneOrNonePlugin<T>>;
149
+ if (typeof withStatic.findOneOrNone === "function") {
150
+ return withStatic.findOneOrNone(query, errorArgs);
151
+ }
152
+ const results = await model.find(query);
153
+ if (results.length === 0) {
154
+ return null;
155
+ }
156
+ if (results.length > 1) {
157
+ throw new APIError({
158
+ detail: `query: ${JSON.stringify(query)}`,
159
+ status: 500,
160
+ title: `${model.modelName}.findOne query returned multiple documents`,
161
+ ...errorArgs,
162
+ });
163
+ }
164
+ return results[0] as unknown as Document & T;
165
+ };
166
+
132
167
  /**
133
168
  * This adds a static method `Model.findExactlyOne` to the schema. This or findOneOrNone should replace `Model.findOne`
134
169
  * in most instances.
@@ -12,11 +12,13 @@ export type ConsentFormType = "agreement" | "privacy" | "hipaa" | "research" | "
12
12
  // biome-ignore lint/complexity/noBannedTypes: No methods.
13
13
  export type ConsentFormMethods = {};
14
14
 
15
- export type ConsentFormStatics = FindExactlyOnePlugin<ConsentFormDocument> &
16
- FindOneOrNonePlugin<ConsentFormDocument>;
15
+ export interface ConsentFormStatics
16
+ extends FindExactlyOnePlugin<ConsentFormDocument>,
17
+ FindOneOrNonePlugin<ConsentFormDocument> {}
17
18
 
18
- export type ConsentFormModel = mongoose.Model<ConsentFormDocument, object, ConsentFormMethods> &
19
- ConsentFormStatics;
19
+ export interface ConsentFormModel
20
+ extends mongoose.Model<ConsentFormDocument, object, ConsentFormMethods>,
21
+ ConsentFormStatics {}
20
22
 
21
23
  export interface ConsentFormDocument extends mongoose.Document {
22
24
  _id: mongoose.Types.ObjectId;