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.
@@ -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
- { $or: [{ countries: country }, { countries: { $size: 0 } }] }
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, winners.length + ' prize assignments', session);
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, old._id + ': ' + reason.trim(), session);
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 count = await this.entries.countDocuments({ giveawayId: id }, { session });
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 entry = input.method === giveaway_2.GiveawaySelectionMethod.Manual
844
- ? await this.entries.findOne({ giveawayId: id, _id: input.entryId }, { session })
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({ giveawayId: id }, { session })
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, entry._id + (selection.reason ? ': ' + selection.reason : ''), session);
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. All members have equal chances. One winner. The winner must claim within the stated claim window.',
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 claim = winner.category === giveaway_2.GiveawayCategory.Products ? schemas_1.physicalClaimSchema.parse(raw) : undefined;
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: { status: giveaway_2.GiveawayNotificationStatus.Sent, sentAt: now },
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
- 'Address 1',
1274
- 'Address 2',
1275
- 'City',
1276
- 'County / State',
1277
- 'Postcode / ZIP',
1278
- 'Country',
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
- e.address?.line1 || '',
1295
- e.address?.line2 || '',
1296
- e.address?.city || '',
1297
- e.address?.region || '',
1298
- e.address?.postalCode || '',
1299
- e.address?.country || '',
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,
@@ -2,6 +2,7 @@
2
2
  export * from './domain';
3
3
  export * from './api-pagination';
4
4
  export * from './entry-id';
5
+ export * from './eligibility';
5
6
  export * from './giveaway-repository';
6
7
  export * from './schemas';
7
8
  export * from './credit-award';
@@ -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);