dgc-core 2.0.4 → 2.0.7
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 +17 -3
- package/dist/constants/revenuecat.d.ts +7 -0
- package/dist/constants/revenuecat.js +25 -0
- package/dist/enums/giveaway.d.ts +4 -2
- package/dist/enums/giveaway.js +2 -0
- package/dist/giveaways/domain.d.ts +2 -2
- package/dist/giveaways/domain.js +17 -6
- package/dist/giveaways/eligibility.d.ts +5 -0
- package/dist/giveaways/eligibility.js +24 -0
- package/dist/giveaways/giveaway-repository.d.ts +23 -6
- package/dist/giveaways/giveaway-repository.js +324 -40
- package/dist/giveaways/index.d.ts +1 -0
- package/dist/giveaways/index.js +1 -0
- package/dist/giveaways/schemas.d.ts +173 -8
- package/dist/giveaways/schemas.js +7 -5
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/revenuecat.d.ts +16 -0
- package/dist/revenuecat.js +206 -0
- package/dist/types/giveaway.d.ts +35 -4
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +2 -0
- package/dist/types/revenuecat.d.ts +19 -0
- package/dist/types/revenuecat.js +2 -0
- package/package.json +1 -1
|
@@ -9,6 +9,7 @@ const node_crypto_1 = require("node:crypto");
|
|
|
9
9
|
const mongodb_1 = require("mongodb");
|
|
10
10
|
const entry_id_1 = require("./entry-id");
|
|
11
11
|
const api_pagination_1 = require("./api-pagination");
|
|
12
|
+
const eligibility_1 = require("./eligibility");
|
|
12
13
|
const domain_1 = require("./domain");
|
|
13
14
|
const schemas_1 = require("./schemas");
|
|
14
15
|
const projection = { entryVersion: 0 };
|
|
@@ -30,6 +31,9 @@ function normalizeEntry(record) {
|
|
|
30
31
|
delete fields.username;
|
|
31
32
|
return { ...fields, name: record.name ?? legacy.fullName ?? '' };
|
|
32
33
|
}
|
|
34
|
+
function normalizeCountries(record) {
|
|
35
|
+
return record.category === giveaway_2.GiveawayCategory.Credits ? { ...record, countries: [] } : record;
|
|
36
|
+
}
|
|
33
37
|
function pagination(params) {
|
|
34
38
|
const integer = (key, fallback, max) => {
|
|
35
39
|
const value = Number(params.get(key));
|
|
@@ -126,9 +130,10 @@ function filterCategory(filter, params) {
|
|
|
126
130
|
}
|
|
127
131
|
/** Each method reads only its own records. Transactions contain related writes only. */
|
|
128
132
|
class GiveawayRepository {
|
|
129
|
-
constructor(db, client) {
|
|
133
|
+
constructor(db, client, options = {}) {
|
|
130
134
|
this.db = db;
|
|
131
135
|
this.client = client;
|
|
136
|
+
this.options = options;
|
|
132
137
|
}
|
|
133
138
|
get giveaways() {
|
|
134
139
|
return this.db.collection('giveaways');
|
|
@@ -148,6 +153,111 @@ class GiveawayRepository {
|
|
|
148
153
|
get activity() {
|
|
149
154
|
return this.db.collection('audit');
|
|
150
155
|
}
|
|
156
|
+
async checkCreditCandidate(entry) {
|
|
157
|
+
const check = this.options.checkCreditEligibility;
|
|
158
|
+
if (!check)
|
|
159
|
+
throw new domain_1.DomainError('RevenueCat entitlement verification is not configured', 503);
|
|
160
|
+
if (typeof entry.userId !== 'string' ||
|
|
161
|
+
!entry.userId ||
|
|
162
|
+
entry.userId.length > 128 ||
|
|
163
|
+
/[.#$[\]/]/.test(entry.userId))
|
|
164
|
+
throw new domain_1.DomainError('The entrant has no valid DGC account identity', 503);
|
|
165
|
+
let evidence;
|
|
166
|
+
try {
|
|
167
|
+
evidence = await check(entry.userId);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
throw new domain_1.DomainError('Unable to verify active membership. Try again.', 503);
|
|
171
|
+
}
|
|
172
|
+
if (evidence === null)
|
|
173
|
+
return null;
|
|
174
|
+
if (!evidence ||
|
|
175
|
+
evidence.userId !== entry.userId ||
|
|
176
|
+
typeof evidence.entitlementId !== 'string' ||
|
|
177
|
+
!evidence.entitlementId.trim() ||
|
|
178
|
+
evidence.entitlementId.length > 255 ||
|
|
179
|
+
!(0, eligibility_1.isGiveawayMemberEligible)(giveaway_2.GiveawayCategory.Credits, evidence.collectorType) ||
|
|
180
|
+
typeof evidence.checkedAt !== 'string' ||
|
|
181
|
+
!Number.isFinite(Date.parse(evidence.checkedAt)) ||
|
|
182
|
+
Date.parse(evidence.checkedAt) > Date.now() + 5000 ||
|
|
183
|
+
(evidence.expiresAt !== null &&
|
|
184
|
+
(typeof evidence.expiresAt !== 'number' || !Number.isFinite(evidence.expiresAt))))
|
|
185
|
+
throw new domain_1.DomainError('Unable to verify active membership. Try again.', 503);
|
|
186
|
+
this.assertFreshCreditEvidence(evidence);
|
|
187
|
+
return {
|
|
188
|
+
userId: evidence.userId,
|
|
189
|
+
checkedAt: evidence.checkedAt,
|
|
190
|
+
entitlementId: evidence.entitlementId,
|
|
191
|
+
collectorType: evidence.collectorType,
|
|
192
|
+
expiresAt: evidence.expiresAt
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
assertFreshCreditEvidence(evidence) {
|
|
196
|
+
if (Date.now() - Date.parse(evidence.checkedAt) > 60000 ||
|
|
197
|
+
(evidence.expiresAt !== null && evidence.expiresAt <= Date.now()))
|
|
198
|
+
throw new domain_1.DomainError('Entitlement verification expired. Review and try again.', 409);
|
|
199
|
+
}
|
|
200
|
+
assertVerifiedCreditCandidate(g, reviewed, entry, verified) {
|
|
201
|
+
if (g.category !== reviewed.category || g.revision !== reviewed.revision)
|
|
202
|
+
throw new domain_1.DomainError('Giveaway changed. Review the selection again.', 409);
|
|
203
|
+
if (g.category !== giveaway_2.GiveawayCategory.Credits)
|
|
204
|
+
return;
|
|
205
|
+
if (!verified || verified.entryId !== entry._id || verified.evidence.userId !== entry.userId)
|
|
206
|
+
throw new domain_1.DomainError('Active membership must be verified before awarding credits', 503);
|
|
207
|
+
this.assertFreshCreditEvidence(verified.evidence);
|
|
208
|
+
}
|
|
209
|
+
async verifiedCreditSelection(g, input) {
|
|
210
|
+
if ((0, domain_1.effectiveStatus)(g) !== giveaway_2.GiveawayStatus.Closed)
|
|
211
|
+
throw new domain_1.DomainError('Close entries before selecting a winner', 409);
|
|
212
|
+
if (g.revision !== input.revision)
|
|
213
|
+
throw new domain_1.DomainError('Giveaway changed. Refresh before selecting.', 409);
|
|
214
|
+
if (g.selection ||
|
|
215
|
+
(await this.winners.findOne({ giveawayId: g._id, replacedAt: null }, { projection: { _id: 1 } })))
|
|
216
|
+
throw new domain_1.DomainError('A winner is already selected. Explicitly reset the selection to change it.', 409);
|
|
217
|
+
const maxAttempts = this.options.creditWinnerMaxAttempts ?? 5;
|
|
218
|
+
if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 100)
|
|
219
|
+
throw new domain_1.DomainError('Credit winner attempts must be an integer between 1 and 100', 503);
|
|
220
|
+
const tiers = (0, eligibility_1.giveawayEligibleCollectorTypes)(giveaway_2.GiveawayCategory.Credits);
|
|
221
|
+
const rejected = [];
|
|
222
|
+
const deadline = Date.now() + 30000;
|
|
223
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
224
|
+
if (Date.now() >= deadline)
|
|
225
|
+
throw new domain_1.DomainError('Membership verification is taking too long. Try the draw again.', 503);
|
|
226
|
+
const eligible = {
|
|
227
|
+
giveawayId: g._id,
|
|
228
|
+
_collectorType: { $in: [...tiers] },
|
|
229
|
+
...(rejected.length > 0 && { _id: { $nin: rejected } })
|
|
230
|
+
};
|
|
231
|
+
const count = await this.entries.countDocuments(eligible, {});
|
|
232
|
+
if (!count)
|
|
233
|
+
throw new domain_1.DomainError('This giveaway has no entries with an active eligible entitlement', 409);
|
|
234
|
+
const entry = input.method === giveaway_2.GiveawaySelectionMethod.Manual
|
|
235
|
+
? await this.entries.findOne({ ...eligible, _id: input.entryId }, {})
|
|
236
|
+
: await this.entries
|
|
237
|
+
.find(eligible, {
|
|
238
|
+
projection: { _id: 1, userId: 1, giveawayId: 1, _collectorType: 1 }
|
|
239
|
+
})
|
|
240
|
+
.sort({ _id: 1 })
|
|
241
|
+
.skip((0, node_crypto_1.randomInt)(count))
|
|
242
|
+
.limit(1)
|
|
243
|
+
.next();
|
|
244
|
+
if (!entry)
|
|
245
|
+
throw new domain_1.DomainError('Select a valid entry in this giveaway', 422);
|
|
246
|
+
if (!(0, eligibility_1.isGiveawayMemberEligible)(g.category, entry._collectorType))
|
|
247
|
+
throw new domain_1.DomainError('This entry is not eligible for the giveaway prize', 403);
|
|
248
|
+
const evidence = await this.checkCreditCandidate(entry);
|
|
249
|
+
if (Date.now() >= deadline)
|
|
250
|
+
throw new domain_1.DomainError('Membership verification is taking too long. Try the draw again.', 503);
|
|
251
|
+
if (evidence)
|
|
252
|
+
return { entryId: entry._id, evidence, skipped: rejected.length };
|
|
253
|
+
if (input.method === giveaway_2.GiveawaySelectionMethod.Manual)
|
|
254
|
+
throw new domain_1.DomainError('This entrant has no active Rookie, Collector or Elite entitlement', 422);
|
|
255
|
+
rejected.push(entry._id);
|
|
256
|
+
if (count === 1)
|
|
257
|
+
throw new domain_1.DomainError('This giveaway has no entries with an active eligible entitlement', 409);
|
|
258
|
+
}
|
|
259
|
+
throw new domain_1.DomainError(`No verified winner found after checking ${maxAttempts} candidate${maxAttempts === 1 ? '' : 's'}. Try the draw again.`, 503);
|
|
260
|
+
}
|
|
151
261
|
async transaction(operation) {
|
|
152
262
|
const session = this.client.startSession();
|
|
153
263
|
try {
|
|
@@ -173,7 +283,7 @@ class GiveawayRepository {
|
|
|
173
283
|
const giveaway = await this.giveaways.findOne({ _id: objectId(id) }, { session, projection });
|
|
174
284
|
if (!giveaway)
|
|
175
285
|
throw new domain_1.DomainError('Giveaway not found', 404);
|
|
176
|
-
return serialize(giveaway);
|
|
286
|
+
return normalizeCountries(serialize(giveaway));
|
|
177
287
|
}
|
|
178
288
|
async getPublicGiveaway(id) {
|
|
179
289
|
const g = await this.getGiveaway(id);
|
|
@@ -224,7 +334,7 @@ class GiveawayRepository {
|
|
|
224
334
|
totalCount: total,
|
|
225
335
|
data: rows.map(row => {
|
|
226
336
|
const g = row;
|
|
227
|
-
return { ...g, status: (0, domain_1.effectiveStatus)(g) };
|
|
337
|
+
return { ...normalizeCountries(g), status: (0, domain_1.effectiveStatus)(g) };
|
|
228
338
|
})
|
|
229
339
|
};
|
|
230
340
|
}
|
|
@@ -240,7 +350,13 @@ class GiveawayRepository {
|
|
|
240
350
|
if (country && country !== 'all')
|
|
241
351
|
filter.$and = [
|
|
242
352
|
...(filter.$and || []),
|
|
243
|
-
{
|
|
353
|
+
{
|
|
354
|
+
$or: [
|
|
355
|
+
{ category: giveaway_2.GiveawayCategory.Credits },
|
|
356
|
+
{ countries: country },
|
|
357
|
+
{ countries: { $size: 0 } }
|
|
358
|
+
]
|
|
359
|
+
}
|
|
244
360
|
];
|
|
245
361
|
const sort = params.get('sort') || 'recent';
|
|
246
362
|
const order = sort === 'closing'
|
|
@@ -259,7 +375,7 @@ class GiveawayRepository {
|
|
|
259
375
|
this.giveaways.countDocuments(filter)
|
|
260
376
|
]);
|
|
261
377
|
return {
|
|
262
|
-
data: rows.map(g => ({ ...g, status: (0, domain_1.effectiveStatus)(g) })),
|
|
378
|
+
data: rows.map(g => ({ ...normalizeCountries(g), status: (0, domain_1.effectiveStatus)(g) })),
|
|
263
379
|
total,
|
|
264
380
|
page,
|
|
265
381
|
limit
|
|
@@ -570,6 +686,49 @@ class GiveawayRepository {
|
|
|
570
686
|
{ $sort: { _id: -1 } },
|
|
571
687
|
{ $skip: skip },
|
|
572
688
|
{ $limit: limit },
|
|
689
|
+
{
|
|
690
|
+
$lookup: {
|
|
691
|
+
from: 'notification_deliveries',
|
|
692
|
+
let: { notification: { $toString: '$_id' } },
|
|
693
|
+
pipeline: [
|
|
694
|
+
{ $match: { $expr: { $eq: ['$notificationId', '$$notification'] } } },
|
|
695
|
+
{
|
|
696
|
+
$group: {
|
|
697
|
+
_id: null,
|
|
698
|
+
queued: {
|
|
699
|
+
$sum: {
|
|
700
|
+
$cond: [
|
|
701
|
+
{ $eq: ['$status', giveaway_2.GiveawayNotificationDeliveryStatus.Queued] },
|
|
702
|
+
1,
|
|
703
|
+
0
|
|
704
|
+
]
|
|
705
|
+
}
|
|
706
|
+
},
|
|
707
|
+
skipped: {
|
|
708
|
+
$sum: {
|
|
709
|
+
$cond: [
|
|
710
|
+
{ $eq: ['$status', giveaway_2.GiveawayNotificationDeliveryStatus.Skipped] },
|
|
711
|
+
1,
|
|
712
|
+
0
|
|
713
|
+
]
|
|
714
|
+
}
|
|
715
|
+
},
|
|
716
|
+
pending: {
|
|
717
|
+
$sum: {
|
|
718
|
+
$cond: [
|
|
719
|
+
{ $eq: ['$status', giveaway_2.GiveawayNotificationDeliveryStatus.Pending] },
|
|
720
|
+
1,
|
|
721
|
+
0
|
|
722
|
+
]
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
},
|
|
727
|
+
{ $project: { _id: 0, queued: 1, skipped: 1, pending: 1 } }
|
|
728
|
+
],
|
|
729
|
+
as: 'deliveryTotals'
|
|
730
|
+
}
|
|
731
|
+
},
|
|
573
732
|
{
|
|
574
733
|
$lookup: {
|
|
575
734
|
from: 'giveaways',
|
|
@@ -585,10 +744,13 @@ class GiveawayRepository {
|
|
|
585
744
|
$set: {
|
|
586
745
|
giveawayTitle: {
|
|
587
746
|
$ifNull: [{ $first: '$campaign.title' }, '$giveawayId']
|
|
747
|
+
},
|
|
748
|
+
deliveryCounts: {
|
|
749
|
+
$ifNull: [{ $first: '$deliveryTotals' }, { queued: 0, skipped: 0, pending: 0 }]
|
|
588
750
|
}
|
|
589
751
|
}
|
|
590
752
|
},
|
|
591
|
-
{ $unset: ['campaign', 'claimToken'] },
|
|
753
|
+
{ $unset: ['campaign', 'claimToken', 'deliveryTotals'] },
|
|
592
754
|
stringId
|
|
593
755
|
])
|
|
594
756
|
.toArray(),
|
|
@@ -732,6 +894,20 @@ class GiveawayRepository {
|
|
|
732
894
|
}
|
|
733
895
|
async confirmWinners(actor, id, raw) {
|
|
734
896
|
const input = schemas_1.assignmentsSchema.parse(raw);
|
|
897
|
+
const reviewed = await this.getGiveaway(id);
|
|
898
|
+
let verified;
|
|
899
|
+
if (reviewed.category === giveaway_2.GiveawayCategory.Credits) {
|
|
900
|
+
const rows = await this.entries
|
|
901
|
+
.find({ giveawayId: id, _id: { $in: input.assignments.map(a => a.entryId) } }, { projection: { _id: 1, userId: 1, giveawayId: 1, _collectorType: 1 } })
|
|
902
|
+
.limit(1)
|
|
903
|
+
.toArray();
|
|
904
|
+
const existing = await this.winners.findOne({ giveawayId: id, replacedAt: null }, { projection: { _id: 1 } });
|
|
905
|
+
(0, domain_1.confirmWinners)(reviewed, rows, !!existing, actor, input);
|
|
906
|
+
const evidence = await this.checkCreditCandidate(rows[0]);
|
|
907
|
+
if (!evidence)
|
|
908
|
+
throw new domain_1.DomainError('This entrant has no active Rookie, Collector or Elite entitlement', 422);
|
|
909
|
+
verified = { entryId: rows[0]._id, evidence, skipped: 0 };
|
|
910
|
+
}
|
|
735
911
|
return this.transaction(async (session) => {
|
|
736
912
|
const g = await this.getGiveaway(id, session);
|
|
737
913
|
const rows = await this.entries
|
|
@@ -740,17 +916,25 @@ class GiveawayRepository {
|
|
|
740
916
|
_id: { $in: input.assignments.map(a => a.entryId) }
|
|
741
917
|
}, {
|
|
742
918
|
session,
|
|
743
|
-
projection: { _id: 1, userId: 1, giveawayId: 1 }
|
|
919
|
+
projection: { _id: 1, userId: 1, giveawayId: 1, _collectorType: 1 }
|
|
744
920
|
})
|
|
745
921
|
.limit(1000)
|
|
746
922
|
.toArray();
|
|
747
923
|
const entries = rows;
|
|
748
924
|
const existing = await this.winners.findOne({ giveawayId: id, replacedAt: null }, { session, projection: { _id: 1 } });
|
|
749
925
|
const winners = (0, domain_1.confirmWinners)(g, entries, !!existing, actor, input);
|
|
926
|
+
this.assertVerifiedCreditCandidate(g, reviewed, entries[0], verified);
|
|
927
|
+
if (verified)
|
|
928
|
+
winners[0].creditEligibility = verified.evidence;
|
|
750
929
|
await this.updateGiveaway({ ...g, status: giveaway_2.GiveawayStatus.AwaitingClaim, revision: g.revision + 1 }, g.revision, session);
|
|
751
930
|
const result = await this.winners.insertMany(winners.map(winner => ({ ...winner })), { session });
|
|
752
|
-
await this.enqueueEvent(g, giveaway_2.GiveawayNotificationKind.Results, 'results:' + result.insertedIds[0].toHexString(), session);
|
|
753
|
-
await this.audit(actor, 'Winners confirmed', id,
|
|
931
|
+
await this.enqueueEvent(g, giveaway_2.GiveawayNotificationKind.Results, 'results:' + result.insertedIds[0].toHexString(), session, winners[0].claimDeadline);
|
|
932
|
+
await this.audit(actor, 'Winners confirmed', id, verified
|
|
933
|
+
? JSON.stringify({
|
|
934
|
+
prizeAssignments: winners.length,
|
|
935
|
+
creditEligibility: verified.evidence
|
|
936
|
+
})
|
|
937
|
+
: winners.length + ' prize assignments', session);
|
|
754
938
|
return winners.map((winner, index) => ({
|
|
755
939
|
...winner,
|
|
756
940
|
_id: result.insertedIds[index].toHexString()
|
|
@@ -758,22 +942,52 @@ class GiveawayRepository {
|
|
|
758
942
|
});
|
|
759
943
|
}
|
|
760
944
|
async replaceWinner(actor, id, winnerId, replacementEntryId, reason) {
|
|
945
|
+
const reviewed = await this.getGiveaway(id);
|
|
946
|
+
let verified;
|
|
947
|
+
if (reviewed.category === giveaway_2.GiveawayCategory.Credits) {
|
|
948
|
+
const previous = await this.winners.findOne({
|
|
949
|
+
_id: objectId(winnerId),
|
|
950
|
+
giveawayId: id,
|
|
951
|
+
replacedAt: null
|
|
952
|
+
});
|
|
953
|
+
const entry = await this.entries.findOne({ _id: schemas_1.entryIdSchema.parse(replacementEntryId), giveawayId: id }, { projection: { _id: 1, userId: 1, giveawayId: 1, _collectorType: 1 } });
|
|
954
|
+
if (!previous || !entry)
|
|
955
|
+
throw new domain_1.DomainError('Winner or entrant not found', 404);
|
|
956
|
+
const existing = await this.winners.findOne({ giveawayId: id, entryId: replacementEntryId, replacedAt: null }, { projection: { _id: 1 } });
|
|
957
|
+
(0, domain_1.replaceWinner)(reviewed, serialize(previous), entry, !!existing, actor, reason);
|
|
958
|
+
const evidence = await this.checkCreditCandidate(entry);
|
|
959
|
+
if (!evidence)
|
|
960
|
+
throw new domain_1.DomainError('This entrant has no active Rookie, Collector or Elite entitlement', 422);
|
|
961
|
+
verified = { entryId: entry._id, evidence, skipped: 0 };
|
|
962
|
+
}
|
|
761
963
|
return this.transaction(async (session) => {
|
|
762
964
|
const g = await this.getGiveaway(id, session);
|
|
763
965
|
const previousWinner = await this.winners.findOne({ _id: objectId(winnerId), giveawayId: id, replacedAt: null }, { session });
|
|
764
|
-
const entrant = await this.entries.findOne({ _id: schemas_1.entryIdSchema.parse(replacementEntryId), giveawayId: id }, { session, projection: { _id: 1, giveawayId: 1 } });
|
|
966
|
+
const entrant = await this.entries.findOne({ _id: schemas_1.entryIdSchema.parse(replacementEntryId), giveawayId: id }, { session, projection: { _id: 1, userId: 1, giveawayId: 1, _collectorType: 1 } });
|
|
765
967
|
if (!previousWinner || !entrant)
|
|
766
968
|
throw new domain_1.DomainError('Winner or entrant not found', 404);
|
|
767
969
|
const old = serialize(previousWinner);
|
|
768
970
|
const entry = entrant;
|
|
769
971
|
const existing = await this.winners.findOne({ giveawayId: id, entryId: replacementEntryId, replacedAt: null }, { session, projection: { _id: 1 } });
|
|
770
972
|
const { previous, replacement } = (0, domain_1.replaceWinner)(g, old, entry, !!existing, actor, reason);
|
|
973
|
+
this.assertVerifiedCreditCandidate(g, reviewed, entry, verified);
|
|
974
|
+
replacement.userId = entry.userId;
|
|
975
|
+
if (verified) {
|
|
976
|
+
replacement.creditEligibility = verified.evidence;
|
|
977
|
+
replacement.selection.creditEligibility = verified.evidence;
|
|
978
|
+
}
|
|
771
979
|
await this.updateGiveaway({ ...g, status: giveaway_2.GiveawayStatus.AwaitingClaim, revision: g.revision + 1 }, g.revision, session);
|
|
772
980
|
const { _id, ...previousFields } = previous;
|
|
773
981
|
await this.winners.updateOne({ _id: objectId(_id), replacedAt: null }, { $set: previousFields }, { session });
|
|
774
982
|
const result = await this.winners.insertOne({ ...replacement }, { session });
|
|
775
|
-
await this.enqueueEvent(g, giveaway_2.GiveawayNotificationKind.Results, 'results:' + result.insertedId.toHexString(), session);
|
|
776
|
-
await this.audit(actor, 'Winner replaced', id,
|
|
983
|
+
await this.enqueueEvent(g, giveaway_2.GiveawayNotificationKind.Results, 'results:' + result.insertedId.toHexString(), session, replacement.claimDeadline);
|
|
984
|
+
await this.audit(actor, 'Winner replaced', id, verified
|
|
985
|
+
? JSON.stringify({
|
|
986
|
+
previousWinnerId: old._id,
|
|
987
|
+
reason: reason.trim(),
|
|
988
|
+
creditEligibility: verified.evidence
|
|
989
|
+
})
|
|
990
|
+
: old._id + ': ' + reason.trim(), session);
|
|
777
991
|
return { ...replacement, _id: result.insertedId.toHexString() };
|
|
778
992
|
});
|
|
779
993
|
}
|
|
@@ -809,7 +1023,7 @@ class GiveawayRepository {
|
|
|
809
1023
|
return winner;
|
|
810
1024
|
});
|
|
811
1025
|
}
|
|
812
|
-
async enqueueEvent(g, kind, eventKey, session) {
|
|
1026
|
+
async enqueueEvent(g, kind, eventKey, session, expiresAt) {
|
|
813
1027
|
const copy = g.reminders?.[kind];
|
|
814
1028
|
if (!copy?.enabled)
|
|
815
1029
|
return;
|
|
@@ -819,6 +1033,11 @@ class GiveawayRepository {
|
|
|
819
1033
|
eventKey,
|
|
820
1034
|
kind,
|
|
821
1035
|
audience: giveaway_2.GiveawayNotificationAudience.Entrants,
|
|
1036
|
+
category: g.category,
|
|
1037
|
+
expiresAt: kind === giveaway_2.GiveawayNotificationKind.Closing
|
|
1038
|
+
? g.closesAt
|
|
1039
|
+
: expiresAt ||
|
|
1040
|
+
new Date(Date.now() + (g.claimWindowDays || 14) * 86400000).toISOString(),
|
|
822
1041
|
title: copy.title,
|
|
823
1042
|
message: copy.message,
|
|
824
1043
|
status: giveaway_2.GiveawayNotificationStatus.Pending,
|
|
@@ -828,6 +1047,10 @@ class GiveawayRepository {
|
|
|
828
1047
|
}
|
|
829
1048
|
async selectWinner(actor, id, raw) {
|
|
830
1049
|
const input = schemas_1.selectionSchema.parse(raw);
|
|
1050
|
+
const reviewed = await this.getGiveaway(id);
|
|
1051
|
+
const verified = reviewed.category === giveaway_2.GiveawayCategory.Credits
|
|
1052
|
+
? await this.verifiedCreditSelection(reviewed, input)
|
|
1053
|
+
: undefined;
|
|
831
1054
|
return this.transaction(async (session) => {
|
|
832
1055
|
const g = await this.getGiveaway(id, session);
|
|
833
1056
|
if ((0, domain_1.effectiveStatus)(g) !== giveaway_2.GiveawayStatus.Closed)
|
|
@@ -837,29 +1060,47 @@ class GiveawayRepository {
|
|
|
837
1060
|
if (g.selection ||
|
|
838
1061
|
(await this.winners.findOne({ giveawayId: id, replacedAt: null }, { session })))
|
|
839
1062
|
throw new domain_1.DomainError('A winner is already selected. Explicitly reset the selection to change it.', 409);
|
|
840
|
-
const
|
|
1063
|
+
const collectorTypes = (0, eligibility_1.giveawayEligibleCollectorTypes)(g.category);
|
|
1064
|
+
const eligible = {
|
|
1065
|
+
giveawayId: id,
|
|
1066
|
+
...(collectorTypes !== null && { _collectorType: { $in: [...collectorTypes] } })
|
|
1067
|
+
};
|
|
1068
|
+
const count = verified ? 1 : await this.entries.countDocuments(eligible, { session });
|
|
841
1069
|
if (!count)
|
|
842
1070
|
throw new domain_1.DomainError('This giveaway has no eligible entries', 409);
|
|
843
|
-
const
|
|
844
|
-
|
|
1071
|
+
const candidateId = verified?.entryId ||
|
|
1072
|
+
(input.method === giveaway_2.GiveawaySelectionMethod.Manual ? input.entryId : undefined);
|
|
1073
|
+
const entry = candidateId
|
|
1074
|
+
? await this.entries.findOne({ ...eligible, _id: candidateId }, { session })
|
|
845
1075
|
: await this.entries
|
|
846
|
-
.find(
|
|
1076
|
+
.find(eligible, { session })
|
|
847
1077
|
.sort({ _id: 1 })
|
|
848
1078
|
.skip((0, node_crypto_1.randomInt)(count))
|
|
849
1079
|
.limit(1)
|
|
850
1080
|
.next();
|
|
851
1081
|
if (!entry)
|
|
852
1082
|
throw new domain_1.DomainError('Select a valid entry in this giveaway', 422);
|
|
1083
|
+
if (!(0, eligibility_1.isGiveawayMemberEligible)(g.category, entry._collectorType))
|
|
1084
|
+
throw new domain_1.DomainError('This entry is not eligible for the giveaway prize', 403);
|
|
1085
|
+
this.assertVerifiedCreditCandidate(g, reviewed, entry, verified);
|
|
853
1086
|
const selection = {
|
|
854
1087
|
entryId: entry._id,
|
|
855
1088
|
method: input.method,
|
|
856
1089
|
reason: input.method === giveaway_2.GiveawaySelectionMethod.Manual ? input.reason : '',
|
|
857
1090
|
selectedBy: actor,
|
|
858
|
-
selectedAt: new Date().toISOString()
|
|
1091
|
+
selectedAt: new Date().toISOString(),
|
|
1092
|
+
...(verified && { creditEligibility: verified.evidence })
|
|
859
1093
|
};
|
|
860
1094
|
const updated = { ...g, selection, revision: g.revision + 1 };
|
|
861
1095
|
await this.updateGiveaway(updated, g.revision, session);
|
|
862
|
-
await this.audit(actor, 'Winner selected: ' + input.method, id,
|
|
1096
|
+
await this.audit(actor, 'Winner selected: ' + input.method, id, verified
|
|
1097
|
+
? JSON.stringify({
|
|
1098
|
+
entryId: entry._id,
|
|
1099
|
+
reason: selection.reason,
|
|
1100
|
+
creditEligibility: verified.evidence,
|
|
1101
|
+
skippedIneligibleEntries: verified.skipped
|
|
1102
|
+
})
|
|
1103
|
+
: entry._id + (selection.reason ? ': ' + selection.reason : ''), session);
|
|
863
1104
|
return { giveaway: updated, entry: normalizeEntry(entry) };
|
|
864
1105
|
});
|
|
865
1106
|
}
|
|
@@ -895,7 +1136,7 @@ class GiveawayRepository {
|
|
|
895
1136
|
.findOne({ _id: objectId(id) }, { session });
|
|
896
1137
|
if (!preset)
|
|
897
1138
|
throw new domain_1.DomainError('Preset not found', 404);
|
|
898
|
-
return serialize(preset);
|
|
1139
|
+
return normalizeCountries(serialize(preset));
|
|
899
1140
|
}
|
|
900
1141
|
async listPresets() {
|
|
901
1142
|
return (await this.db
|
|
@@ -903,7 +1144,7 @@ class GiveawayRepository {
|
|
|
903
1144
|
.find({})
|
|
904
1145
|
.sort({ name: 1 })
|
|
905
1146
|
.limit(100)
|
|
906
|
-
.toArray()).map(serialize);
|
|
1147
|
+
.toArray()).map(preset => normalizeCountries(serialize(preset)));
|
|
907
1148
|
}
|
|
908
1149
|
async installCreditPresets(actor) {
|
|
909
1150
|
const now = new Date().toISOString();
|
|
@@ -921,7 +1162,7 @@ class GiveawayRepository {
|
|
|
921
1162
|
category: giveaway_2.GiveawayCategory.Credits,
|
|
922
1163
|
creditAmount: amount,
|
|
923
1164
|
countries: [],
|
|
924
|
-
rules: 'One ticket per DGC account.
|
|
1165
|
+
rules: 'One ticket per eligible DGC account. Eligible members have equal chances. One winner. The winner must claim within the stated claim window.',
|
|
925
1166
|
deliveryInformation: '',
|
|
926
1167
|
revision: 1,
|
|
927
1168
|
createdAt: now,
|
|
@@ -1006,7 +1247,7 @@ class GiveawayRepository {
|
|
|
1006
1247
|
};
|
|
1007
1248
|
}
|
|
1008
1249
|
async getUserClaim(giveawayId, userId) {
|
|
1009
|
-
const entry = await this.entries.findOne({ giveawayId, userId }, { projection: { _id: 1 } });
|
|
1250
|
+
const entry = await this.entries.findOne({ giveawayId, userId }, { projection: { _id: 1, name: 1, fullName: 1 } });
|
|
1010
1251
|
if (!entry)
|
|
1011
1252
|
throw new domain_1.DomainError('Winning entry not found', 404);
|
|
1012
1253
|
const winner = await this.winners.findOne({ giveawayId, entryId: entry._id, replacedAt: null });
|
|
@@ -1027,7 +1268,14 @@ class GiveawayRepository {
|
|
|
1027
1268
|
tracking: value.tracking,
|
|
1028
1269
|
dispatchedAt: value.dispatchedAt,
|
|
1029
1270
|
creditedAt: value.creditedAt,
|
|
1030
|
-
claimExpired: !value.claimedAt && !!value.claimDeadline && new Date(value.claimDeadline) <= new Date()
|
|
1271
|
+
claimExpired: !value.claimedAt && !!value.claimDeadline && new Date(value.claimDeadline) <= new Date(),
|
|
1272
|
+
...(value.category === giveaway_2.GiveawayCategory.Products
|
|
1273
|
+
? {
|
|
1274
|
+
defaults: {
|
|
1275
|
+
fullName: value.claim?.fullName ?? normalizeEntry(entry).name
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
: {})
|
|
1031
1279
|
};
|
|
1032
1280
|
}
|
|
1033
1281
|
async claimPrize(giveawayId, userId, raw) {
|
|
@@ -1036,7 +1284,7 @@ class GiveawayRepository {
|
|
|
1036
1284
|
const entry = await this.entries.findOne({ giveawayId, userId }, { session });
|
|
1037
1285
|
const winner = entry &&
|
|
1038
1286
|
(await this.winners.findOne({ giveawayId, entryId: entry._id, replacedAt: null }, { session }));
|
|
1039
|
-
if (!winner)
|
|
1287
|
+
if (!entry || !winner)
|
|
1040
1288
|
throw new domain_1.DomainError('Winning entry not found', 404);
|
|
1041
1289
|
if (!Object.values(giveaway_2.GiveawayCategory).includes(winner.category))
|
|
1042
1290
|
throw new domain_1.DomainError('Invalid giveaway category', 409);
|
|
@@ -1048,7 +1296,15 @@ class GiveawayRepository {
|
|
|
1048
1296
|
throw new domain_1.DomainError('The claim window has closed', 409);
|
|
1049
1297
|
if (winner.category === giveaway_2.GiveawayCategory.Credits)
|
|
1050
1298
|
schemas_1.creditClaimSchema.parse(raw);
|
|
1051
|
-
const
|
|
1299
|
+
const input = winner.category === giveaway_2.GiveawayCategory.Products ? schemas_1.physicalClaimSchema.parse(raw) : undefined;
|
|
1300
|
+
const claim = input
|
|
1301
|
+
? {
|
|
1302
|
+
...input,
|
|
1303
|
+
fullName: schemas_1.physicalClaimSchema.shape.fullName
|
|
1304
|
+
.unwrap()
|
|
1305
|
+
.parse(input.fullName ?? normalizeEntry(entry).name)
|
|
1306
|
+
}
|
|
1307
|
+
: undefined;
|
|
1052
1308
|
if (claim && g.countries.length && !g.countries.includes(claim.address.country))
|
|
1053
1309
|
throw new domain_1.DomainError('This delivery country is not eligible', 422);
|
|
1054
1310
|
const fields = {
|
|
@@ -1060,6 +1316,17 @@ class GiveawayRepository {
|
|
|
1060
1316
|
...(claim ? { claim } : {})
|
|
1061
1317
|
};
|
|
1062
1318
|
await this.winners.updateOne({ _id: winner._id, replacedAt: null }, { $set: fields }, { session });
|
|
1319
|
+
if (claim) {
|
|
1320
|
+
const savedEntry = await this.entries.updateOne({ _id: entry._id, giveawayId, userId }, {
|
|
1321
|
+
$set: {
|
|
1322
|
+
deliveryName: claim.fullName,
|
|
1323
|
+
address: claim.address,
|
|
1324
|
+
contactNumber: claim.contactNumber
|
|
1325
|
+
}
|
|
1326
|
+
}, { session });
|
|
1327
|
+
if (!savedEntry.matchedCount)
|
|
1328
|
+
throw new domain_1.DomainError('Winning entry changed. Please check your claim status.', 409);
|
|
1329
|
+
}
|
|
1063
1330
|
await this.updateGiveaway({ ...g, revision: g.revision + 1 }, g.revision, session);
|
|
1064
1331
|
await this.audit(userId, 'Prize claimed', giveawayId, winner._id.toHexString(), session);
|
|
1065
1332
|
return { ...serialize(winner), ...fields };
|
|
@@ -1228,7 +1495,11 @@ class GiveawayRepository {
|
|
|
1228
1495
|
const now = new Date().toISOString();
|
|
1229
1496
|
const updated = await this.notifications.updateOne(filter, successful
|
|
1230
1497
|
? {
|
|
1231
|
-
$set: {
|
|
1498
|
+
$set: {
|
|
1499
|
+
status: giveaway_2.GiveawayNotificationStatus.Sent,
|
|
1500
|
+
sentAt: now,
|
|
1501
|
+
dispatchCompletedAt: now
|
|
1502
|
+
},
|
|
1232
1503
|
$unset: { error: '', claimToken: '' }
|
|
1233
1504
|
}
|
|
1234
1505
|
: {
|
|
@@ -1245,7 +1516,8 @@ class GiveawayRepository {
|
|
|
1245
1516
|
});
|
|
1246
1517
|
}
|
|
1247
1518
|
async exportEntries(actor, id, marketingOnly) {
|
|
1248
|
-
await this.getGiveaway(id);
|
|
1519
|
+
const giveaway = await this.getGiveaway(id);
|
|
1520
|
+
const includeDelivery = giveaway.category === giveaway_2.GiveawayCategory.Products;
|
|
1249
1521
|
await this.audit(actor, marketingOnly ? 'Marketing opt-ins export started' : 'Entrants export started', id);
|
|
1250
1522
|
const pipeline = [
|
|
1251
1523
|
{
|
|
@@ -1270,12 +1542,18 @@ class GiveawayRepository {
|
|
|
1270
1542
|
: [
|
|
1271
1543
|
'Name',
|
|
1272
1544
|
'Email',
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1545
|
+
...(includeDelivery
|
|
1546
|
+
? [
|
|
1547
|
+
'Delivery name',
|
|
1548
|
+
'Address 1',
|
|
1549
|
+
'Address 2',
|
|
1550
|
+
'City',
|
|
1551
|
+
'County / State',
|
|
1552
|
+
'Postcode / ZIP',
|
|
1553
|
+
'Country',
|
|
1554
|
+
'Contact number'
|
|
1555
|
+
]
|
|
1556
|
+
: []),
|
|
1279
1557
|
'User ID',
|
|
1280
1558
|
'Collector type',
|
|
1281
1559
|
'Entry time',
|
|
@@ -1291,12 +1569,18 @@ class GiveawayRepository {
|
|
|
1291
1569
|
: [
|
|
1292
1570
|
e.name,
|
|
1293
1571
|
e.email,
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1572
|
+
...(includeDelivery
|
|
1573
|
+
? [
|
|
1574
|
+
e.deliveryName || '',
|
|
1575
|
+
e.address?.line1 || '',
|
|
1576
|
+
e.address?.line2 || '',
|
|
1577
|
+
e.address?.city || '',
|
|
1578
|
+
e.address?.region || '',
|
|
1579
|
+
e.address?.postalCode || '',
|
|
1580
|
+
e.address?.country || e.country || '',
|
|
1581
|
+
e.contactNumber || ''
|
|
1582
|
+
]
|
|
1583
|
+
: []),
|
|
1300
1584
|
e.userId,
|
|
1301
1585
|
e._collectorType === null ? 'Unknown' : user_1.UserCollectorType[e._collectorType],
|
|
1302
1586
|
e.enteredAt,
|
package/dist/giveaways/index.js
CHANGED
|
@@ -18,6 +18,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
18
18
|
__exportStar(require("./domain"), exports);
|
|
19
19
|
__exportStar(require("./api-pagination"), exports);
|
|
20
20
|
__exportStar(require("./entry-id"), exports);
|
|
21
|
+
__exportStar(require("./eligibility"), exports);
|
|
21
22
|
__exportStar(require("./giveaway-repository"), exports);
|
|
22
23
|
__exportStar(require("./schemas"), exports);
|
|
23
24
|
__exportStar(require("./credit-award"), exports);
|