@slango/mangusta 1.0.8 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,385 @@
1
+ import { Document, Model, Schema, Types } from 'mongoose';
2
+
3
+ import { Id, toObjectId, toStringId } from '../helpers.js';
4
+ import { PluginFunction } from '../types.js';
5
+
6
+ export const defaultReactionTypes = ['👍', '❤️', '🤔', '😂', '😮'] as const;
7
+
8
+ export type ReactionType = (typeof defaultReactionTypes)[number];
9
+
10
+ export interface ReactionCountSummary {
11
+ perType: Record<string, number>;
12
+ total: number;
13
+ }
14
+
15
+ export type ReactionUserField<UserField extends string = 'user'> = {
16
+ [key in UserField]: Types.ObjectId;
17
+ };
18
+
19
+ export type ReactionTypeField<TypeField extends string = 'type'> = {
20
+ [key in TypeField]: string;
21
+ };
22
+
23
+ export type ReactionTimestampField<TimestampField extends string = 'createdAt'> = {
24
+ [key in TimestampField]?: Date;
25
+ };
26
+
27
+ export type ReactionEntry<
28
+ UserField extends string = 'user',
29
+ TypeField extends string = 'type',
30
+ TimestampField extends string = 'createdAt',
31
+ > = ReactionTimestampField<TimestampField> &
32
+ ReactionTypeField<TypeField> &
33
+ ReactionUserField<UserField>;
34
+
35
+ export type WithReactions<
36
+ Field extends string = 'reactions',
37
+ UserField extends string = 'user',
38
+ TypeField extends string = 'type',
39
+ TimestampField extends string = 'createdAt',
40
+ > = {
41
+ [key in Field]: ReactionEntry<UserField, TypeField, TimestampField>[];
42
+ };
43
+
44
+ export interface WithReactionsMethods {
45
+ addReaction(user: Id, type: string, timestampValue?: Date): this;
46
+ countReactions(type?: string): number | ReactionCountSummary;
47
+ hasReaction(user: Id, type?: string): boolean;
48
+ removeReaction(user: Id, type?: string): this;
49
+ }
50
+
51
+ export interface ReactionsMiddlewareOptions<
52
+ Field extends string = 'reactions',
53
+ UserField extends string = 'user',
54
+ TypeField extends string = 'type',
55
+ TimestampField extends string = 'createdAt',
56
+ > {
57
+ allowedTypes?: readonly string[];
58
+ allowMultiplePerUser?: boolean;
59
+ field?: Field;
60
+ indexType?: -1 | 1 | boolean;
61
+ indexUser?: -1 | 1 | boolean;
62
+ timestamp?: boolean;
63
+ timestampField?: TimestampField;
64
+ typeField?: TypeField;
65
+ userField?: UserField;
66
+ userRef?: string;
67
+ }
68
+
69
+ const reactionsMiddleware: PluginFunction<ReactionsMiddlewareOptions> = <
70
+ Field extends string = 'reactions',
71
+ UserField extends string = 'user',
72
+ TypeField extends string = 'type',
73
+ TimestampField extends string = 'createdAt',
74
+ DocType extends Document &
75
+ WithReactions<Field, UserField, TypeField, TimestampField> &
76
+ WithReactionsMethods = Document &
77
+ WithReactions<Field, UserField, TypeField, TimestampField> &
78
+ WithReactionsMethods,
79
+ >(
80
+ schema: Schema<DocType, Model<DocType>>,
81
+ {
82
+ allowedTypes = defaultReactionTypes,
83
+ allowMultiplePerUser = false,
84
+ field = 'reactions' as Field,
85
+ indexType = false,
86
+ indexUser = true,
87
+ timestamp = true,
88
+ timestampField = 'createdAt' as TimestampField,
89
+ typeField = 'type' as TypeField,
90
+ userField = 'user' as UserField,
91
+ userRef = 'User',
92
+ }: ReactionsMiddlewareOptions<Field, UserField, TypeField, TimestampField> = {},
93
+ ): void => {
94
+ const allowedTypesSet = new Set(allowedTypes);
95
+ const shouldEnforceAllowedTypes = allowedTypesSet.size > 0;
96
+
97
+ const reactionSchemaDefinition: Record<string, unknown> = {
98
+ [userField]: {
99
+ ref: userRef,
100
+ required: true,
101
+ type: Types.ObjectId,
102
+ },
103
+ [typeField]: {
104
+ required: true,
105
+ type: String,
106
+ ...(shouldEnforceAllowedTypes && { enum: Array.from(allowedTypesSet) }),
107
+ },
108
+ };
109
+
110
+ const reactionsSubSchema = new Schema<ReactionEntry<UserField, TypeField, TimestampField>>(
111
+ reactionSchemaDefinition,
112
+ {
113
+ _id: false,
114
+ timestamps: timestamp ? { createdAt: timestampField, updatedAt: false } : false,
115
+ },
116
+ );
117
+
118
+ schema.add(
119
+ new Schema<WithReactions<Field, UserField, TypeField, TimestampField>>({
120
+ [field]: {
121
+ default: [],
122
+ type: [reactionsSubSchema],
123
+ },
124
+ }),
125
+ );
126
+
127
+ if (indexUser) {
128
+ schema.index({ [`${field}.${userField}`]: indexUser === true ? 1 : indexUser });
129
+ }
130
+
131
+ if (indexType) {
132
+ schema.index({ [`${field}.${typeField}`]: indexType === true ? 1 : indexType });
133
+ }
134
+
135
+ const ensureAllowedType = (reactionType: string): void => {
136
+ if (shouldEnforceAllowedTypes && !allowedTypesSet.has(reactionType)) {
137
+ throw new Error(`Reaction type "${reactionType}" is not permitted.`);
138
+ }
139
+ };
140
+
141
+ const normalizeEntry = (
142
+ entry: ReactionEntry<UserField, TypeField, TimestampField>,
143
+ ): null | ReactionEntry<UserField, TypeField, TimestampField> => {
144
+ const userValue = entry[userField];
145
+ const typeValue = entry[typeField];
146
+
147
+ if (!userValue || !typeValue) {
148
+ return null;
149
+ }
150
+
151
+ ensureAllowedType(typeValue);
152
+
153
+ const normalized: ReactionEntry<UserField, TypeField, TimestampField> = {
154
+ ...entry,
155
+ [userField]: toObjectId(userValue),
156
+ [typeField]: typeValue,
157
+ } as ReactionEntry<UserField, TypeField, TimestampField>;
158
+
159
+ const rawTimestamp = entry[timestampField] as unknown;
160
+
161
+ if (timestamp) {
162
+ if (rawTimestamp instanceof Date) {
163
+ normalized[timestampField] = rawTimestamp as ReactionEntry<
164
+ UserField,
165
+ TypeField,
166
+ TimestampField
167
+ >[TimestampField];
168
+ } else if (rawTimestamp === undefined) {
169
+ normalized[timestampField] = new Date() as ReactionEntry<
170
+ UserField,
171
+ TypeField,
172
+ TimestampField
173
+ >[TimestampField];
174
+ } else if (typeof rawTimestamp === 'string' || typeof rawTimestamp === 'number') {
175
+ normalized[timestampField] = new Date(rawTimestamp) as ReactionEntry<
176
+ UserField,
177
+ TypeField,
178
+ TimestampField
179
+ >[TimestampField];
180
+ }
181
+ } else if (rawTimestamp !== undefined) {
182
+ delete (normalized as Record<string, unknown>)[timestampField];
183
+ }
184
+
185
+ return normalized;
186
+ };
187
+
188
+ schema.pre<DocType>('save', function reactionsPreSave(next) {
189
+ try {
190
+ const existing =
191
+ (this.get(field) as ReactionEntry<UserField, TypeField, TimestampField>[] | undefined) ??
192
+ [];
193
+
194
+ if (!existing.length) {
195
+ return next();
196
+ }
197
+
198
+ const normalizedEntries: ReactionEntry<UserField, TypeField, TimestampField>[] = [];
199
+ const seenUsers = new Map<string, number>();
200
+ let modified = false;
201
+
202
+ for (const entry of existing) {
203
+ const normalized = normalizeEntry(entry);
204
+ if (!normalized) {
205
+ modified = true;
206
+ continue;
207
+ }
208
+
209
+ if (!allowMultiplePerUser) {
210
+ const userKey = normalized[userField].toString();
211
+ const idx = seenUsers.get(userKey);
212
+
213
+ if (idx !== undefined) {
214
+ normalizedEntries[idx] = normalized;
215
+ modified = true;
216
+ } else {
217
+ seenUsers.set(userKey, normalizedEntries.length);
218
+ normalizedEntries.push(normalized);
219
+ }
220
+ } else {
221
+ normalizedEntries.push(normalized);
222
+ }
223
+ }
224
+
225
+ if (normalizedEntries.length !== existing.length || modified) {
226
+ this.set(field, normalizedEntries);
227
+ }
228
+
229
+ next();
230
+ } catch (err) {
231
+ if (err instanceof Error) {
232
+ next(err);
233
+ return;
234
+ }
235
+
236
+ next(new Error('Unknown error while normalizing reactions.'));
237
+ }
238
+ });
239
+
240
+ schema.methods.addReaction = function addReaction(
241
+ this: DocType,
242
+ user: Id,
243
+ reactionType: string,
244
+ timestampValue?: Date,
245
+ ): DocType {
246
+ ensureAllowedType(reactionType);
247
+
248
+ const userId = toObjectId(user);
249
+ const userKey = userId.toString();
250
+ const reactions = (
251
+ (this.get(field) as ReactionEntry<UserField, TypeField, TimestampField>[]) ?? []
252
+ )
253
+ .map((entry) => normalizeEntry(entry))
254
+ .filter(
255
+ (entry): entry is ReactionEntry<UserField, TypeField, TimestampField> => entry !== null,
256
+ );
257
+
258
+ let updated = false;
259
+
260
+ if (!allowMultiplePerUser) {
261
+ const existingIndex = reactions.findIndex(
262
+ (entry) => entry[userField]?.toString() === userKey,
263
+ );
264
+
265
+ const newEntry: ReactionEntry<UserField, TypeField, TimestampField> = {
266
+ [userField]: userId,
267
+ [typeField]: reactionType,
268
+ } as ReactionEntry<UserField, TypeField, TimestampField>;
269
+
270
+ if (timestamp) {
271
+ newEntry[timestampField] = (timestampValue ?? new Date()) as ReactionEntry<
272
+ UserField,
273
+ TypeField,
274
+ TimestampField
275
+ >[TimestampField];
276
+ }
277
+
278
+ if (existingIndex !== -1) {
279
+ reactions[existingIndex] = newEntry;
280
+ } else {
281
+ reactions.push(newEntry);
282
+ }
283
+
284
+ updated = true;
285
+ } else {
286
+ const newEntry: ReactionEntry<UserField, TypeField, TimestampField> = {
287
+ [userField]: userId,
288
+ [typeField]: reactionType,
289
+ } as ReactionEntry<UserField, TypeField, TimestampField>;
290
+
291
+ if (timestamp) {
292
+ newEntry[timestampField] = (timestampValue ?? new Date()) as ReactionEntry<
293
+ UserField,
294
+ TypeField,
295
+ TimestampField
296
+ >[TimestampField];
297
+ }
298
+
299
+ reactions.push(newEntry);
300
+ updated = true;
301
+ }
302
+
303
+ if (updated) {
304
+ this.set(field, reactions);
305
+ }
306
+
307
+ return this;
308
+ };
309
+
310
+ schema.methods.removeReaction = function removeReaction(
311
+ this: DocType,
312
+ user: Id,
313
+ reactionType?: string,
314
+ ): DocType {
315
+ const userKey = toStringId(user);
316
+ const reactions =
317
+ (this.get(field) as ReactionEntry<UserField, TypeField, TimestampField>[] | undefined) ?? [];
318
+
319
+ const filtered = reactions.filter((entry) => {
320
+ const matchesUser = entry[userField]?.toString() === userKey;
321
+ if (!matchesUser) {
322
+ return true;
323
+ }
324
+
325
+ if (!reactionType) {
326
+ return false;
327
+ }
328
+
329
+ return entry[typeField] !== reactionType;
330
+ });
331
+
332
+ if (filtered.length !== reactions.length) {
333
+ this.set(field, filtered);
334
+ }
335
+
336
+ return this;
337
+ };
338
+
339
+ schema.methods.hasReaction = function hasReaction(
340
+ this: DocType,
341
+ user: Id,
342
+ reactionType?: string,
343
+ ): boolean {
344
+ const userKey = toStringId(user);
345
+ const reactions =
346
+ (this.get(field) as ReactionEntry<UserField, TypeField, TimestampField>[] | undefined) ?? [];
347
+
348
+ return reactions.some((entry) => {
349
+ if (entry[userField]?.toString() !== userKey) {
350
+ return false;
351
+ }
352
+
353
+ if (reactionType) {
354
+ return entry[typeField] === reactionType;
355
+ }
356
+
357
+ return true;
358
+ });
359
+ };
360
+
361
+ schema.methods.countReactions = function countReactions(
362
+ this: DocType,
363
+ reactionType?: string,
364
+ ): number | ReactionCountSummary {
365
+ const reactions =
366
+ (this.get(field) as ReactionEntry<UserField, TypeField, TimestampField>[] | undefined) ?? [];
367
+
368
+ if (reactionType) {
369
+ ensureAllowedType(reactionType);
370
+ return reactions.filter((entry) => entry[typeField] === reactionType).length;
371
+ }
372
+
373
+ return reactions.reduce<ReactionCountSummary>(
374
+ (accumulator, entry) => {
375
+ const type = entry[typeField];
376
+ accumulator.perType[type] = (accumulator.perType[type] ?? 0) + 1;
377
+ accumulator.total += 1;
378
+ return accumulator;
379
+ },
380
+ { total: 0, perType: {} },
381
+ );
382
+ };
383
+ };
384
+
385
+ export default reactionsMiddleware;