@terreno/api 0.0.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 (119) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +170 -0
  3. package/biome.jsonc +22 -0
  4. package/bunfig.toml +4 -0
  5. package/dist/api.d.ts +227 -0
  6. package/dist/api.js +1024 -0
  7. package/dist/api.test.d.ts +1 -0
  8. package/dist/api.test.js +2143 -0
  9. package/dist/auth.d.ts +50 -0
  10. package/dist/auth.js +512 -0
  11. package/dist/auth.test.d.ts +1 -0
  12. package/dist/auth.test.js +778 -0
  13. package/dist/errors.d.ts +75 -0
  14. package/dist/errors.js +216 -0
  15. package/dist/example.d.ts +1 -0
  16. package/dist/example.js +118 -0
  17. package/dist/expressServer.d.ts +35 -0
  18. package/dist/expressServer.js +436 -0
  19. package/dist/index.d.ts +14 -0
  20. package/dist/index.js +30 -0
  21. package/dist/logger.d.ts +23 -0
  22. package/dist/logger.js +249 -0
  23. package/dist/middleware.d.ts +10 -0
  24. package/dist/middleware.js +52 -0
  25. package/dist/notifiers/googleChatNotifier.d.ts +5 -0
  26. package/dist/notifiers/googleChatNotifier.js +130 -0
  27. package/dist/notifiers/googleChatNotifier.test.d.ts +1 -0
  28. package/dist/notifiers/googleChatNotifier.test.js +260 -0
  29. package/dist/notifiers/index.d.ts +3 -0
  30. package/dist/notifiers/index.js +19 -0
  31. package/dist/notifiers/slackNotifier.d.ts +5 -0
  32. package/dist/notifiers/slackNotifier.js +130 -0
  33. package/dist/notifiers/slackNotifier.test.d.ts +1 -0
  34. package/dist/notifiers/slackNotifier.test.js +259 -0
  35. package/dist/notifiers/zoomNotifier.d.ts +34 -0
  36. package/dist/notifiers/zoomNotifier.js +181 -0
  37. package/dist/notifiers/zoomNotifier.test.d.ts +1 -0
  38. package/dist/notifiers/zoomNotifier.test.js +370 -0
  39. package/dist/openApi.d.ts +60 -0
  40. package/dist/openApi.js +441 -0
  41. package/dist/openApi.test.d.ts +1 -0
  42. package/dist/openApi.test.js +445 -0
  43. package/dist/openApiBuilder.d.ts +419 -0
  44. package/dist/openApiBuilder.js +424 -0
  45. package/dist/openApiBuilder.test.d.ts +1 -0
  46. package/dist/openApiBuilder.test.js +509 -0
  47. package/dist/openApiEtag.d.ts +7 -0
  48. package/dist/openApiEtag.js +38 -0
  49. package/dist/permissions.d.ts +26 -0
  50. package/dist/permissions.js +331 -0
  51. package/dist/permissions.test.d.ts +1 -0
  52. package/dist/permissions.test.js +413 -0
  53. package/dist/plugins.d.ts +67 -0
  54. package/dist/plugins.js +315 -0
  55. package/dist/plugins.test.d.ts +1 -0
  56. package/dist/plugins.test.js +639 -0
  57. package/dist/populate.d.ts +14 -0
  58. package/dist/populate.js +315 -0
  59. package/dist/populate.test.d.ts +1 -0
  60. package/dist/populate.test.js +133 -0
  61. package/dist/response.d.ts +0 -0
  62. package/dist/response.js +1 -0
  63. package/dist/tests/bunSetup.d.ts +1 -0
  64. package/dist/tests/bunSetup.js +297 -0
  65. package/dist/tests/index.d.ts +1 -0
  66. package/dist/tests/index.js +17 -0
  67. package/dist/tests.d.ts +99 -0
  68. package/dist/tests.js +273 -0
  69. package/dist/transformers.d.ts +25 -0
  70. package/dist/transformers.js +217 -0
  71. package/dist/transformers.test.d.ts +1 -0
  72. package/dist/transformers.test.js +370 -0
  73. package/dist/utils.d.ts +11 -0
  74. package/dist/utils.js +143 -0
  75. package/dist/utils.test.d.ts +1 -0
  76. package/dist/utils.test.js +14 -0
  77. package/index.ts +1 -0
  78. package/package.json +88 -0
  79. package/src/__snapshots__/openApi.test.ts.snap +4814 -0
  80. package/src/__snapshots__/openApiBuilder.test.ts.snap +1485 -0
  81. package/src/api.test.ts +1661 -0
  82. package/src/api.ts +1036 -0
  83. package/src/auth.test.ts +550 -0
  84. package/src/auth.ts +408 -0
  85. package/src/errors.ts +225 -0
  86. package/src/example.ts +99 -0
  87. package/src/express.d.ts +5 -0
  88. package/src/expressServer.ts +387 -0
  89. package/src/index.ts +14 -0
  90. package/src/logger.ts +190 -0
  91. package/src/middleware.ts +18 -0
  92. package/src/notifiers/googleChatNotifier.test.ts +114 -0
  93. package/src/notifiers/googleChatNotifier.ts +47 -0
  94. package/src/notifiers/index.ts +3 -0
  95. package/src/notifiers/slackNotifier.test.ts +113 -0
  96. package/src/notifiers/slackNotifier.ts +55 -0
  97. package/src/notifiers/zoomNotifier.test.ts +207 -0
  98. package/src/notifiers/zoomNotifier.ts +111 -0
  99. package/src/openApi.test.ts +331 -0
  100. package/src/openApi.ts +494 -0
  101. package/src/openApiBuilder.test.ts +442 -0
  102. package/src/openApiBuilder.ts +636 -0
  103. package/src/openApiEtag.ts +40 -0
  104. package/src/permissions.test.ts +219 -0
  105. package/src/permissions.ts +228 -0
  106. package/src/plugins.test.ts +390 -0
  107. package/src/plugins.ts +289 -0
  108. package/src/populate.test.ts +65 -0
  109. package/src/populate.ts +258 -0
  110. package/src/response.ts +0 -0
  111. package/src/tests/bunSetup.ts +234 -0
  112. package/src/tests/index.ts +1 -0
  113. package/src/tests.ts +218 -0
  114. package/src/transformers.test.ts +202 -0
  115. package/src/transformers.ts +170 -0
  116. package/src/utils.test.ts +14 -0
  117. package/src/utils.ts +47 -0
  118. package/tsconfig.json +60 -0
  119. package/types.d.ts +17 -0
@@ -0,0 +1,390 @@
1
+ import {beforeEach, describe, expect, it, setSystemTime} from "bun:test";
2
+ import type express from "express";
3
+ import {type Document, type Model, model, Schema} from "mongoose";
4
+ import supertest from "supertest";
5
+ import type TestAgent from "supertest/lib/agent";
6
+ import {modelRouter} from "./api";
7
+ import {addAuthRoutes, setupAuth} from "./auth";
8
+ import type {APIErrorConstructor} from "./errors";
9
+ import {Permissions} from "./permissions";
10
+ import {
11
+ createdUpdatedPlugin,
12
+ DateOnly,
13
+ findExactlyOne,
14
+ findOneOrNone,
15
+ type IsDeleted,
16
+ isDeletedPlugin,
17
+ upsertPlugin,
18
+ } from "./plugins";
19
+ import {authAsUser, getBaseServer, setupDb, UserModel} from "./tests";
20
+
21
+ interface Stuff extends IsDeleted {
22
+ _id: string;
23
+ name: string;
24
+ ownerId: string;
25
+ date: Date;
26
+ created: Date;
27
+ updated?: Date;
28
+ }
29
+
30
+ interface StuffModelType extends Model<Stuff> {
31
+ findOneOrNone(
32
+ query: Record<string, any>,
33
+ errorArgs?: Partial<APIErrorConstructor>
34
+ ): Promise<(Document & Stuff) | null>;
35
+ findExactlyOne(
36
+ query: Record<string, any>,
37
+ errorArgs?: Partial<APIErrorConstructor>
38
+ ): Promise<Document & Stuff>;
39
+ }
40
+
41
+ const stuffSchema = new Schema<Stuff>({
42
+ date: DateOnly,
43
+ name: String,
44
+ ownerId: String,
45
+ });
46
+
47
+ stuffSchema.plugin(isDeletedPlugin);
48
+ stuffSchema.plugin(findOneOrNone);
49
+ stuffSchema.plugin(findExactlyOne);
50
+ stuffSchema.plugin(upsertPlugin);
51
+ stuffSchema.plugin(createdUpdatedPlugin);
52
+
53
+ const StuffModel = model<Stuff>("Stuff", stuffSchema) as unknown as StuffModelType;
54
+
55
+ describe("createdUpdate", () => {
56
+ it("sets created and updated on save", async () => {
57
+ setSystemTime(new Date("2022-12-17T03:24:00.000Z"));
58
+
59
+ const stuff = await StuffModel.create({name: "Things", ownerId: "123"});
60
+ expect(stuff.created).not.toBeNull();
61
+ expect(stuff.updated).not.toBeNull();
62
+ expect(stuff.created.toISOString()).toBe("2022-12-17T03:24:00.000Z");
63
+ expect(stuff.updated?.toISOString()).toBe("2022-12-17T03:24:00.000Z");
64
+
65
+ stuff.name = "Thangs";
66
+ // Advance time by 10 seconds
67
+ setSystemTime(new Date("2022-12-17T03:24:10.000Z"));
68
+ await stuff.save();
69
+ expect(stuff.created.toISOString()).toBe("2022-12-17T03:24:00.000Z");
70
+ expect(stuff.updated && stuff.updated > stuff.created).toBe(true);
71
+ setSystemTime();
72
+ });
73
+ });
74
+
75
+ describe("isDeleted", () => {
76
+ beforeEach(async () => {
77
+ await StuffModel.deleteMany({});
78
+ await Promise.all([
79
+ StuffModel.create({
80
+ deleted: true,
81
+ name: "Things",
82
+ ownerId: "123",
83
+ }),
84
+ StuffModel.create({
85
+ name: "StuffNThings",
86
+ ownerId: "123",
87
+ }),
88
+ ]);
89
+ });
90
+
91
+ it('filters out deleted documents from "find"', async () => {
92
+ let stuff = await StuffModel.find({});
93
+ expect(stuff).toHaveLength(1);
94
+ expect(stuff[0].name).toBe("StuffNThings");
95
+ // Providing deleted in query should return deleted documents:
96
+ stuff = await StuffModel.find({deleted: true});
97
+ expect(stuff).toHaveLength(1);
98
+ expect(stuff[0].name).toBe("Things");
99
+ });
100
+
101
+ it('filters out deleted documents from "findOne"', async () => {
102
+ let stuff = await StuffModel.findOne({});
103
+ expect(stuff?.name).toBe("StuffNThings");
104
+ // Providing deleted in query should return deleted document:
105
+ stuff = await StuffModel.findOne({deleted: true});
106
+ expect(stuff?.name).toBe("Things");
107
+ });
108
+ });
109
+
110
+ describe("findOneOrNone", () => {
111
+ let things: any;
112
+
113
+ beforeEach(async () => {
114
+ await StuffModel.deleteMany({});
115
+ await setupDb();
116
+
117
+ [things] = await Promise.all([
118
+ StuffModel.create({
119
+ name: "Things",
120
+ ownerId: "123",
121
+ }),
122
+ StuffModel.create({
123
+ name: "StuffNThings",
124
+ ownerId: "123",
125
+ }),
126
+ ]);
127
+ });
128
+
129
+ it("returns null with no matches.", async () => {
130
+ const result = await StuffModel.findOneOrNone({name: "OtherStuff"});
131
+ expect(result).toBeNull();
132
+ });
133
+
134
+ it("returns a single match", async () => {
135
+ const result = await StuffModel.findOneOrNone({name: "Things"});
136
+ expect(result).not.toBeNull();
137
+ expect(result?._id.toString()).toBe(things._id.toString());
138
+ });
139
+
140
+ it("throws error with two matches.", async () => {
141
+ const fn = () => StuffModel.findOneOrNone({ownerId: "123"});
142
+ await expect(fn()).rejects.toThrow(/Stuff\.findOne query returned multiple documents/);
143
+ });
144
+
145
+ it("throws custom error with two matches.", async () => {
146
+ const fn = () => StuffModel.findOneOrNone({ownerId: "123"}, {status: 400, title: "Oh no!"});
147
+
148
+ try {
149
+ await fn();
150
+ // If the promise doesn't reject, the test should fail
151
+ throw new Error("Expected promise to reject");
152
+ } catch (error: any) {
153
+ // Check if the error has title and status properties
154
+ expect(error.title).toBe("Oh no!");
155
+ expect(error.status).toBe(400);
156
+ expect(error.detail).toBe('query: {"ownerId":"123"}');
157
+ }
158
+ });
159
+ });
160
+
161
+ describe("findExactlyOne", () => {
162
+ let things: any;
163
+
164
+ beforeEach(async () => {
165
+ await StuffModel.deleteMany({});
166
+ await setupDb();
167
+
168
+ [things] = await Promise.all([
169
+ StuffModel.create({
170
+ name: "Things",
171
+ ownerId: "123",
172
+ }),
173
+ StuffModel.create({
174
+ name: "StuffNThings",
175
+ ownerId: "123",
176
+ }),
177
+ ]);
178
+ });
179
+
180
+ it("throws error with no matches.", async () => {
181
+ const fn = () => StuffModel.findExactlyOne({name: "OtherStuff"});
182
+ await expect(fn()).rejects.toThrow(/Stuff\.findExactlyOne query returned no documents/);
183
+ });
184
+
185
+ it("returns a single match", async () => {
186
+ const result = await StuffModel.findExactlyOne({name: "Things"});
187
+ expect(result._id.toString()).toBe(things._id.toString());
188
+ });
189
+
190
+ it("throws error with two matches.", async () => {
191
+ const fn = () => StuffModel.findExactlyOne({ownerId: "123"});
192
+ await expect(fn()).rejects.toThrow(/Stuff\.findExactlyOne query returned multiple documents/);
193
+ });
194
+
195
+ it("throws custom error with two matches.", async () => {
196
+ const fn = () => StuffModel.findExactlyOne({ownerId: "123"}, {status: 400, title: "Oh no!"});
197
+
198
+ try {
199
+ await fn();
200
+ // If the promise doesn't reject, the test should fail
201
+ throw new Error("Expected promise to reject");
202
+ } catch (error: any) {
203
+ // Check if the error has title and status properties
204
+ expect(error.title).toBe("Oh no!");
205
+ expect(error.status).toBe(400);
206
+ expect(error.detail).toBe('query: {"ownerId":"123"}');
207
+ }
208
+ });
209
+ });
210
+
211
+ describe("upsertPlugin", () => {
212
+ beforeEach(async () => {
213
+ await StuffModel.deleteMany({});
214
+ await setupDb();
215
+ });
216
+
217
+ it("creates a new document when none exists", async () => {
218
+ const result = await (StuffModel as any).upsert({name: "NewThing"}, {ownerId: "456"});
219
+ expect(result.name).toBe("NewThing");
220
+ expect(result.ownerId).toBe("456");
221
+
222
+ const found = await StuffModel.findOne({name: "NewThing"});
223
+ expect(found).not.toBeNull();
224
+ expect(found?.ownerId).toBe("456");
225
+ });
226
+
227
+ it("updates existing document when one exists", async () => {
228
+ const initial = await StuffModel.create({
229
+ name: "ExistingThing",
230
+ ownerId: "123",
231
+ });
232
+
233
+ const result = await (StuffModel as any).upsert({name: "ExistingThing"}, {ownerId: "789"});
234
+
235
+ expect(result._id.toString()).toBe(initial._id.toString());
236
+ expect(result.ownerId).toBe("789");
237
+
238
+ const allDocs = await StuffModel.find({name: "ExistingThing"});
239
+ expect(allDocs).toHaveLength(1);
240
+ expect(allDocs[0].ownerId).toBe("789");
241
+ });
242
+
243
+ it("throws error when multiple documents match conditions", async () => {
244
+ await Promise.all([
245
+ StuffModel.create({name: "Thing1", ownerId: "123"}),
246
+ StuffModel.create({name: "Thing2", ownerId: "123"}),
247
+ ]);
248
+
249
+ const fn = () => (StuffModel as any).upsert({ownerId: "123"}, {name: "Updated"});
250
+ await expect(fn()).rejects.toThrow(/Stuff\.upsert find query returned multiple documents/);
251
+ });
252
+
253
+ it("combines conditions and update data for new documents", async () => {
254
+ const result = await (StuffModel as any).upsert({name: "TestCondition"}, {ownerId: "999"});
255
+
256
+ expect(result.name).toBe("TestCondition");
257
+ expect(result.ownerId).toBe("999");
258
+ });
259
+ });
260
+
261
+ describe("TypeScript return types", () => {
262
+ let _things: any;
263
+
264
+ beforeEach(async () => {
265
+ await StuffModel.deleteMany({});
266
+ await setupDb();
267
+
268
+ [_things] = await Promise.all([
269
+ StuffModel.create({
270
+ date: new Date("2023-01-01"),
271
+ name: "Things",
272
+ ownerId: "123",
273
+ }),
274
+ StuffModel.create({
275
+ date: new Date("2023-01-02"),
276
+ name: "StuffNThings",
277
+ ownerId: "123",
278
+ }),
279
+ ]);
280
+ });
281
+
282
+ it("findOneOrNone returns properly typed document or null", async () => {
283
+ const result = await StuffModel.findOneOrNone({name: "Things"});
284
+
285
+ if (result) {
286
+ expect(typeof result._id.toString()).toBe("string");
287
+ expect(typeof result.name).toBe("string");
288
+ expect(typeof result.ownerId).toBe("string");
289
+ expect(result.date).toBeInstanceOf(Date);
290
+ } else {
291
+ expect(result).toBeNull();
292
+ }
293
+ });
294
+
295
+ it("findExactlyOne returns properly typed document", async () => {
296
+ const result = await StuffModel.findExactlyOne({name: "Things"});
297
+
298
+ expect(typeof result._id.toString()).toBe("string");
299
+ expect(typeof result.name).toBe("string");
300
+ expect(typeof result.ownerId).toBe("string");
301
+ expect(result.date).toBeInstanceOf(Date);
302
+ });
303
+ });
304
+ describe("DateOnly", () => {
305
+ it("throws error with invalid date", async () => {
306
+ try {
307
+ await StuffModel.create({
308
+ date: "foo" as any,
309
+ name: "Things",
310
+ ownerId: "123",
311
+ });
312
+ } catch (error: any) {
313
+ expect(error.message).toMatch(/Cast to DateOnly failed/);
314
+ return;
315
+ }
316
+ throw new Error("Expected error was not thrown");
317
+ });
318
+
319
+ it("adjusts date to date only", async () => {
320
+ const res = await StuffModel.create({
321
+ date: "2005-10-10T17:17:17.017Z",
322
+ name: "Things",
323
+ ownerId: "123",
324
+ });
325
+ expect(res.date.toISOString()).toBe("2005-10-10T00:00:00.000Z");
326
+ });
327
+
328
+ it("filter on date only", async () => {
329
+ await StuffModel.create({
330
+ date: "2000-10-10T17:17:17.017Z",
331
+ name: "Things",
332
+ ownerId: "123",
333
+ });
334
+ let found = await StuffModel.findOne({
335
+ date: {
336
+ $gte: "2000-01-01T00:00:00.000Z",
337
+ $lt: "2001-01-01T00:00:00.000Z",
338
+ },
339
+ });
340
+ expect(found?.date.toISOString()).toBe("2000-10-10T00:00:00.000Z");
341
+ found = await StuffModel.findOne({
342
+ date: {
343
+ $gte: "2000-01-01T12:12:12.000Z",
344
+ $lt: "2001-01-01T12:12:12.000Z",
345
+ },
346
+ });
347
+ expect(found?.date.toISOString()).toBe("2000-10-10T00:00:00.000Z");
348
+ });
349
+
350
+ describe("handle 404", () => {
351
+ let agent: TestAgent;
352
+ let app: express.Application;
353
+
354
+ beforeEach(async () => {
355
+ await setupDb();
356
+ app = getBaseServer();
357
+ setupAuth(app, UserModel as any);
358
+ addAuthRoutes(app, UserModel as any);
359
+ app.use(
360
+ "/stuff",
361
+ modelRouter(StuffModel, {
362
+ allowAnonymous: true,
363
+ permissions: {
364
+ create: [Permissions.IsAny],
365
+ delete: [Permissions.IsAny],
366
+ list: [Permissions.IsAny],
367
+ read: [Permissions.IsAny],
368
+ update: [Permissions.IsAny],
369
+ },
370
+ })
371
+ );
372
+ supertest(app);
373
+ agent = await authAsUser(app, "notAdmin");
374
+ });
375
+
376
+ it("returns 404 with context for hidden document", async () => {
377
+ const doc = await StuffModel.create({deleted: true, name: "test"});
378
+ const res = await agent.get(`/stuff/${doc._id}`).expect(404);
379
+ expect(res.body.title).toBe(`Document ${doc._id} not found for model Stuff`);
380
+ expect(res.body.meta).toEqual({deleted: "true"});
381
+ });
382
+
383
+ it("returns 404 without meta for missing document", async () => {
384
+ const nonExistentId = "507f1f77bcf86cd799439011";
385
+ const res = await agent.get(`/stuff/${nonExistentId}`).expect(404);
386
+ expect(res.body.title).toBe(`Document ${nonExistentId} not found for model Stuff`);
387
+ expect(res.body.meta).toBeUndefined();
388
+ });
389
+ });
390
+ });
package/src/plugins.ts ADDED
@@ -0,0 +1,289 @@
1
+ import {DateTime} from "luxon";
2
+ import mongoose, {
3
+ type Document,
4
+ Error as MongooseError,
5
+ type Query,
6
+ type Schema,
7
+ SchemaType,
8
+ type SchemaTypeOptions,
9
+ } from "mongoose";
10
+
11
+ import {APIError, type APIErrorConstructor} from "./errors";
12
+
13
+ export interface BaseUser {
14
+ admin: boolean;
15
+ email: string;
16
+ }
17
+
18
+ export function baseUserPlugin(schema: Schema<any, any, any, any>) {
19
+ schema.add({admin: {default: false, type: Boolean}});
20
+ schema.add({email: {index: true, type: String}});
21
+ }
22
+
23
+ /** For models with the isDeletedPlugin, extend this interface to add the appropriate fields. */
24
+ export interface IsDeleted {
25
+ // Whether the model should be treated as deleted or not.
26
+ deleted: boolean;
27
+ }
28
+
29
+ export function isDeletedPlugin(schema: Schema<any, any, any, any>, defaultValue = false) {
30
+ schema.add({
31
+ deleted: {
32
+ default: defaultValue,
33
+ description:
34
+ "Deleted objects are not returned in any find() or findOne() by default. " +
35
+ "Add {deleted: true} to find them.",
36
+ index: true,
37
+ type: Boolean,
38
+ },
39
+ });
40
+ function applyDeleteFilter(q: Query<any, any>) {
41
+ const query = q.getQuery();
42
+ if (query && query.deleted === undefined) {
43
+ void q.where({deleted: {$ne: true}});
44
+ }
45
+ }
46
+ schema.pre("find", function () {
47
+ applyDeleteFilter(this);
48
+ });
49
+ schema.pre("findOne", function () {
50
+ applyDeleteFilter(this);
51
+ });
52
+ }
53
+
54
+ export function isDisabledPlugin(schema: Schema<any, any, any, any>, defaultValue = false) {
55
+ schema.add({
56
+ disabled: {
57
+ default: defaultValue,
58
+ description: "When a user is set to disable, all requests will return a 401",
59
+ index: true,
60
+ type: Boolean,
61
+ },
62
+ });
63
+ }
64
+
65
+ export interface CreatedDeleted {
66
+ updated: {type: Date; required: true};
67
+ created: {type: Date; required: true};
68
+ }
69
+
70
+ export function createdUpdatedPlugin(schema: Schema<any, any, any, any>) {
71
+ schema.add({updated: {index: true, type: Date}});
72
+ schema.add({created: {index: true, type: Date}});
73
+
74
+ schema.pre("save", function () {
75
+ if (this.disableCreatedUpdatedPlugin === true) {
76
+ return;
77
+ }
78
+ // If we aren't specifying created, use now.
79
+ if (!this.created) {
80
+ this.created = new Date();
81
+ }
82
+ // All writes change the updated time.
83
+ this.updated = new Date();
84
+ });
85
+
86
+ schema.pre(/save|updateOne|insertMany/, function () {
87
+ void this.updateOne({}, {$set: {updated: new Date()}});
88
+ });
89
+ }
90
+
91
+ export function firebaseJWTPlugin(schema: Schema) {
92
+ schema.add({firebaseId: {index: true, type: String}});
93
+ }
94
+
95
+ /**
96
+ * This adds a static method `Model.findOneOrNone` to the schema. This should replace `Model.findOne` in most instances.
97
+ * `Model.findOne` should only be used with a unique index, but that's not apparent from the docs. Otherwise you can wind
98
+ * up with a random document that matches the query. The returns either null if no document matches, the actual
99
+ * document, or throws an exception if multiple are found.
100
+ * @param schema Mongoose Schema
101
+ */
102
+ export function findOneOrNone<T>(schema: Schema<T>) {
103
+ schema.statics.findOneOrNone = async function (
104
+ query: Record<string, any>,
105
+ errorArgs?: Partial<APIErrorConstructor>
106
+ ): Promise<(Document & T) | null> {
107
+ const results = await this.find(query);
108
+ if (results.length === 0) {
109
+ return null;
110
+ }
111
+ if (results.length > 1) {
112
+ throw new APIError({
113
+ detail: `query: ${JSON.stringify(query)}`,
114
+ status: 500,
115
+ title: `${this.modelName}.findOne query returned multiple documents`,
116
+ ...errorArgs,
117
+ });
118
+ }
119
+ return results[0];
120
+ };
121
+ }
122
+
123
+ /**
124
+ * This adds a static method `Model.findExactlyOne` to the schema. This or findOneOrNone should replace `Model.findOne`
125
+ * in most instances.
126
+ * `Model.findOne` should only be used with a unique index, but that's not apparent from the docs. Otherwise you can wind
127
+ * up with a random document that matches the query. The returns the one matching document, or throws an exception if
128
+ * multiple or none are found.
129
+ * @param schema Mongoose Schema
130
+ */
131
+ export function findExactlyOne<T>(schema: Schema<T>) {
132
+ schema.statics.findExactlyOne = async function (
133
+ query: Record<string, any>,
134
+ errorArgs?: Partial<APIErrorConstructor>
135
+ ): Promise<Document & T> {
136
+ const results = await this.find(query);
137
+ if (results.length === 0) {
138
+ throw new APIError({
139
+ detail: `query: ${JSON.stringify(query)}`,
140
+ status: 404,
141
+ title: `${this.modelName}.findExactlyOne query returned no documents`,
142
+ ...errorArgs,
143
+ });
144
+ }
145
+ if (results.length > 1) {
146
+ throw new APIError({
147
+ detail: `query: ${JSON.stringify(query)}`,
148
+ status: 500,
149
+ title: `${this.modelName}.findExactlyOne query returned multiple documents`,
150
+ ...errorArgs,
151
+ });
152
+ }
153
+ return results[0];
154
+ };
155
+ }
156
+
157
+ /**
158
+ * This adds a static method `Model.upsert` to the schema. This method will either update an existing document
159
+ * that matches the conditions or create a new document if none exists. It throws an error if multiple documents
160
+ * match the conditions to prevent ambiguous updates.
161
+ * @param schema Mongoose Schema
162
+ */
163
+ export function upsertPlugin<T>(schema: Schema<any, any, any, any>) {
164
+ schema.statics.upsert = async function (
165
+ conditions: Record<string, any>,
166
+ update: Record<string, any>
167
+ ): Promise<T> {
168
+ // Try to find the document with the given conditions.
169
+ const docs = await this.find(conditions);
170
+ if (docs.length > 1) {
171
+ throw new APIError({
172
+ detail: `query: ${JSON.stringify(conditions)}`,
173
+ status: 500,
174
+ title: `${this.modelName}.upsert find query returned multiple documents`,
175
+ });
176
+ }
177
+ const doc = docs[0];
178
+
179
+ if (doc) {
180
+ // If the document exists, update it with the provided update values.
181
+ Object.assign(doc, update);
182
+ return doc.save();
183
+ }
184
+ // If the document doesn't exist, create a new one with the combined conditions and update
185
+ // values.
186
+ const combinedData = {...conditions, ...update};
187
+ const newDoc = new this(combinedData);
188
+ return newDoc.save();
189
+ };
190
+ }
191
+
192
+ /** For models with the upsertPlugin, extend this interface to add the upsert static method. */
193
+ export interface HasUpsert<T> {
194
+ upsert(conditions: Record<string, any>, update: Record<string, any>): Promise<T>;
195
+ }
196
+
197
+ export interface FindOneOrNonePlugin<T> {
198
+ findOneOrNone(
199
+ query: Record<string, any>,
200
+ errorArgs?: Partial<APIErrorConstructor>
201
+ ): Promise<(Document & T) | null>;
202
+ }
203
+
204
+ export interface FindExactlyOnePlugin<T> {
205
+ findExactlyOne(
206
+ query: Record<string, any>,
207
+ errorArgs?: Partial<APIErrorConstructor>
208
+ ): Promise<Document & T>;
209
+ }
210
+
211
+ export class DateOnly extends SchemaType {
212
+ constructor(key: string, options: SchemaTypeOptions<any>) {
213
+ super(key, options, "DateOnly");
214
+ }
215
+
216
+ handleSingle(val) {
217
+ return this.cast(val);
218
+ }
219
+
220
+ $conditionalHandlers = {
221
+ ...(SchemaType as any).prototype.$conditionalHandlers,
222
+ $gt: this.handleSingle,
223
+ $gte: this.handleSingle,
224
+ $lt: this.handleSingle,
225
+ $lte: this.handleSingle,
226
+ };
227
+
228
+ // Based on castForQuery in mongoose/lib/schema/date.js
229
+ // When using $gt, $gte, $lt, $lte, etc, we need to cast the value to a Date
230
+ castForQuery($conditional, val, context): Date | undefined {
231
+ if ($conditional == null) {
232
+ return (this as any).applySetters(val, context);
233
+ }
234
+
235
+ const handler = this.$conditionalHandlers[$conditional];
236
+
237
+ if (!handler) {
238
+ throw new Error(`Can't use ${$conditional} with DateOnly.`);
239
+ }
240
+
241
+ return handler.call(this, val);
242
+ }
243
+
244
+ // When either setting a value to a DateOnly or fetching from the DB,
245
+ // we want to strip off the time portion.
246
+ cast(val: any): Date | undefined {
247
+ if (val instanceof Date) {
248
+ const date = DateTime.fromJSDate(val).toUTC().startOf("day");
249
+ if (!date.isValid) {
250
+ throw new MongooseError.CastError(
251
+ "DateOnly",
252
+ val,
253
+ this.path,
254
+ new Error("Value is not a valid date")
255
+ );
256
+ }
257
+ return date.toJSDate();
258
+ }
259
+ if (typeof val === "string" || typeof val === "number") {
260
+ const date = DateTime.fromJSDate(new Date(val)).toUTC().startOf("day");
261
+ if (!date.isValid) {
262
+ throw new MongooseError.CastError(
263
+ "DateOnly",
264
+ val,
265
+ this.path,
266
+ new Error("Value is not a valid date")
267
+ );
268
+ }
269
+ return date.toJSDate();
270
+ }
271
+ // Handle $gte, $lte, etc
272
+ if (typeof val === "object") {
273
+ return val;
274
+ }
275
+ throw new MongooseError.CastError(
276
+ "DateOnly",
277
+ val,
278
+ this.path,
279
+ new Error("Value is not a valid date")
280
+ );
281
+ }
282
+
283
+ get(val: any): this {
284
+ return (val instanceof Date ? DateTime.fromJSDate(val).startOf("day").toJSDate() : val) as any;
285
+ }
286
+ }
287
+
288
+ // Register DateOnly with Mongoose's Schema.Types
289
+ (mongoose.Schema.Types as any).DateOnly = DateOnly;