dgc-core 2.0.5 → 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 +14 -1
- package/dist/giveaways/eligibility.d.ts +5 -0
- package/dist/giveaways/eligibility.js +24 -0
- package/dist/giveaways/giveaway-repository.d.ts +20 -5
- package/dist/giveaways/giveaway-repository.js +253 -17
- package/dist/giveaways/index.d.ts +1 -0
- package/dist/giveaways/index.js +1 -0
- 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 +22 -2
- 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
package/README.md
CHANGED
|
@@ -60,9 +60,15 @@ adapters stay in the application. Collection and index provisioning stays in
|
|
|
60
60
|
`dgc-db-ops`.
|
|
61
61
|
|
|
62
62
|
Giveaway documents use MongoDB ObjectIds, serialized as 24-character hexadecimal
|
|
63
|
-
strings in APIs; entry IDs use 12
|
|
64
|
-
Firebase `userId` identifies each entrant.
|
|
65
|
-
|
|
63
|
+
strings in APIs; new entry IDs use DGC- plus 12 uppercase letters/digits. Existing 12-digit ticket IDs remain readable.
|
|
64
|
+
Firebase `userId` identifies each entrant. Products have no collector-tier restriction for authenticated
|
|
65
|
+
accounts, including accounts with a missing or unsupported tier. Credits allow only Rookie (1),
|
|
66
|
+
Collector (3) and Elite (0); Free (2), non-members and missing or unsupported tiers cannot enter credits.
|
|
67
|
+
Public `eligibleCollectorTypes` is `null` for unrestricted Products and `[1, 3, 0]` for Credits.
|
|
68
|
+
Entry snapshots preserve known numeric tiers and use `null` for missing or unsupported values.
|
|
69
|
+
Eligible entries have equal chances; credit winner selection uses the saved entry tier while product
|
|
70
|
+
draws include all valid entries. Push-token audience filters belong to the delivery adapter and do not
|
|
71
|
+
restrict entry submission. Administrative entry and winner records contain
|
|
66
72
|
personal information and require authorized access; use `GiveawayPublic` for
|
|
67
73
|
public app responses. `DomainError` and `HttpError` refer to the same constructor.
|
|
68
74
|
|
|
@@ -83,3 +89,11 @@ send notifications or connect to a live database.
|
|
|
83
89
|
## Giveaway MVP v2
|
|
84
90
|
|
|
85
91
|
One ticket per Firebase account, one winner per giveaway, saved random/manual selection and explicit confirmation. Physical address and contact number are collected at claim only. Credit rewards snapshot `creditAmount` and use `fulfillGiveawayCredits` to atomically change Firebase `instantCredits` plus a permanent per-giveaway marker. Status reads reconcile without issuing credits. See dgc-giveaways/docs/api.md and integration.md for contracts and release order.
|
|
92
|
+
|
|
93
|
+
## RevenueCat entitlement verification
|
|
94
|
+
|
|
95
|
+
The shared RevenueCat catalog maps Rookie, Collector and Elite entitlement IDs for project `projb48c5a30`. It contains no API key. The browser-safe catalog and evidence types are available from `dgc-core/types`; the HTTP client is exported only from `dgc-core`.
|
|
96
|
+
|
|
97
|
+
`createRevenueCatClient` accepts server-provided credentials and request limits. `verifyCreditGiveawayEntitlement` checks all eligible tiers using one paginated customer lookup. Null expiry represents non-expiring access; expired entitlements are excluded. Network, rate-limit and malformed-response failures throw a sanitized 503 without exposing credentials. The active entitlements endpoint has no environment filter; configure RevenueCat Sandbox Testing Access and test-account policy for the selected project.
|
|
98
|
+
|
|
99
|
+
Credit winner selection, confirmation and replacement require an injected `checkCreditEligibility(userId)` callback in the optional third `GiveawayRepository` constructor argument. Missing verification is a 503, never a fallback to saved membership. Product operations do not call the verifier. Checks occur outside transactions; reviewed revisions, candidate identity and unexpired evidence are validated at commit. Random selection picks one candidate first, then checks their entitlement. Inactive candidates are retried without replacement up to `creditWinnerMaxAttempts` (default 5; integer 1-100). It never checks the whole entrant pool in advance. The 30-second draw budget remains in place. Transient RevenueCat network/429/5xx failures retry the same user and page up to three attempts with bounded backoff and Retry-After handling, sharing the existing total lookup deadline. Authorization, missing-resource and malformed-response failures are not retried or treated as inactive access. Evidence is stored on selections and winners. Existing confirmed claims and idempotent rewards do not recheck access.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { RevenueCatEntitlementIds } from '../types/revenuecat';
|
|
2
|
+
export declare const REVENUECAT_API_V2 = "https://api.revenuecat.com/v2";
|
|
3
|
+
export declare const DGC_REVENUECAT_PROJECT_ID = "projb48c5a30";
|
|
4
|
+
export declare const DGC_REVENUECAT_ELITE_ENTITLEMENT_ID = "entlb1b4b2ae6b";
|
|
5
|
+
export declare const DGC_REVENUECAT_ENTITLEMENT_IDS: RevenueCatEntitlementIds;
|
|
6
|
+
/** Non-secret catalog IDs are shared; callers can supply a separate test-project catalog. */
|
|
7
|
+
export declare function createRevenueCatEntitlementIds(values?: Partial<RevenueCatEntitlementIds>): RevenueCatEntitlementIds;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DGC_REVENUECAT_ENTITLEMENT_IDS = exports.DGC_REVENUECAT_ELITE_ENTITLEMENT_ID = exports.DGC_REVENUECAT_PROJECT_ID = exports.REVENUECAT_API_V2 = void 0;
|
|
4
|
+
exports.createRevenueCatEntitlementIds = createRevenueCatEntitlementIds;
|
|
5
|
+
exports.REVENUECAT_API_V2 = 'https://api.revenuecat.com/v2';
|
|
6
|
+
exports.DGC_REVENUECAT_PROJECT_ID = 'projb48c5a30';
|
|
7
|
+
exports.DGC_REVENUECAT_ELITE_ENTITLEMENT_ID = 'entlb1b4b2ae6b';
|
|
8
|
+
exports.DGC_REVENUECAT_ENTITLEMENT_IDS = Object.freeze({
|
|
9
|
+
rookie: 'entl74fb3d3bc3',
|
|
10
|
+
collector: 'entl0bea318713',
|
|
11
|
+
elite: exports.DGC_REVENUECAT_ELITE_ENTITLEMENT_ID
|
|
12
|
+
});
|
|
13
|
+
/** Non-secret catalog IDs are shared; callers can supply a separate test-project catalog. */
|
|
14
|
+
function createRevenueCatEntitlementIds(values = {}) {
|
|
15
|
+
const ids = {
|
|
16
|
+
rookie: (values.rookie ?? exports.DGC_REVENUECAT_ENTITLEMENT_IDS.rookie).trim(),
|
|
17
|
+
collector: (values.collector ?? exports.DGC_REVENUECAT_ENTITLEMENT_IDS.collector).trim(),
|
|
18
|
+
elite: (values.elite ?? exports.DGC_REVENUECAT_ENTITLEMENT_IDS.elite).trim()
|
|
19
|
+
};
|
|
20
|
+
if (Object.values(ids).some(id => typeof id !== 'string' || !/^entl[a-zA-Z0-9]+$/.test(id)))
|
|
21
|
+
throw new Error('Configure valid RevenueCat entitlement IDs for Rookie, Collector and Elite');
|
|
22
|
+
if (new Set(Object.values(ids)).size !== 3)
|
|
23
|
+
throw new Error('RevenueCat tier entitlement IDs must be distinct');
|
|
24
|
+
return Object.freeze(ids);
|
|
25
|
+
}
|
package/dist/enums/giveaway.d.ts
CHANGED
|
@@ -39,11 +39,13 @@ export declare enum GiveawayNotificationStatus {
|
|
|
39
39
|
Pending = "pending",
|
|
40
40
|
Delivering = "delivering",
|
|
41
41
|
Sent = "sent",
|
|
42
|
-
Failed = "failed"
|
|
42
|
+
Failed = "failed",
|
|
43
|
+
Skipped = "skipped"
|
|
43
44
|
}
|
|
44
45
|
export declare enum GiveawayNotificationDeliveryStatus {
|
|
45
46
|
Pending = "pending",
|
|
46
|
-
Queued = "queued"
|
|
47
|
+
Queued = "queued",
|
|
48
|
+
Skipped = "skipped"
|
|
47
49
|
}
|
|
48
50
|
export declare enum GiveawayTicketResult {
|
|
49
51
|
Pending = "pending",
|
package/dist/enums/giveaway.js
CHANGED
|
@@ -50,11 +50,13 @@ var GiveawayNotificationStatus;
|
|
|
50
50
|
GiveawayNotificationStatus["Delivering"] = "delivering";
|
|
51
51
|
GiveawayNotificationStatus["Sent"] = "sent";
|
|
52
52
|
GiveawayNotificationStatus["Failed"] = "failed";
|
|
53
|
+
GiveawayNotificationStatus["Skipped"] = "skipped";
|
|
53
54
|
})(GiveawayNotificationStatus || (exports.GiveawayNotificationStatus = GiveawayNotificationStatus = {}));
|
|
54
55
|
var GiveawayNotificationDeliveryStatus;
|
|
55
56
|
(function (GiveawayNotificationDeliveryStatus) {
|
|
56
57
|
GiveawayNotificationDeliveryStatus["Pending"] = "pending";
|
|
57
58
|
GiveawayNotificationDeliveryStatus["Queued"] = "queued";
|
|
59
|
+
GiveawayNotificationDeliveryStatus["Skipped"] = "skipped";
|
|
58
60
|
})(GiveawayNotificationDeliveryStatus || (exports.GiveawayNotificationDeliveryStatus = GiveawayNotificationDeliveryStatus = {}));
|
|
59
61
|
var GiveawayTicketResult;
|
|
60
62
|
(function (GiveawayTicketResult) {
|
|
@@ -56,8 +56,8 @@ export declare function giveawayChanges(existing: Giveaway, updated: Giveaway):
|
|
|
56
56
|
};
|
|
57
57
|
export declare function transition(existing: Giveaway, action: GiveawayAction, revision: number): Giveaway;
|
|
58
58
|
export declare function submitEntry(g: Giveaway, account: UserProfile, raw: unknown): Omit<Entry, '_id'>;
|
|
59
|
-
export declare function confirmWinners(g: Giveaway, entries: Pick<Entry, '_id' | 'userId' | 'giveawayId'>[], alreadyConfirmed: boolean, actor: string, raw: unknown): Omit<Winner, '_id'>[];
|
|
60
|
-
export declare function replaceWinner(g: Giveaway, old: Winner, entry: Pick<Entry, '_id' | 'giveawayId'>, alreadyWinner: boolean, actor: string, reason: string): {
|
|
59
|
+
export declare function confirmWinners(g: Giveaway, entries: Pick<Entry, '_id' | 'userId' | 'giveawayId' | '_collectorType'>[], alreadyConfirmed: boolean, actor: string, raw: unknown): Omit<Winner, '_id'>[];
|
|
60
|
+
export declare function replaceWinner(g: Giveaway, old: Winner, entry: Pick<Entry, '_id' | 'giveawayId' | '_collectorType'>, alreadyWinner: boolean, actor: string, reason: string): {
|
|
61
61
|
previous: Winner;
|
|
62
62
|
replacement: Omit<Winner, '_id'>;
|
|
63
63
|
};
|
package/dist/giveaways/domain.js
CHANGED
|
@@ -17,6 +17,8 @@ const node_util_1 = require("node:util");
|
|
|
17
17
|
const schemas_1 = require("./schemas");
|
|
18
18
|
const http_error_1 = require("../http-error");
|
|
19
19
|
Object.defineProperty(exports, "DomainError", { enumerable: true, get: function () { return http_error_1.HttpError; } });
|
|
20
|
+
const eligibility_1 = require("./eligibility");
|
|
21
|
+
const user_1 = require("../enums/user");
|
|
20
22
|
function effectiveStatus(g, clock = new Date()) {
|
|
21
23
|
if (g.status === giveaway_2.GiveawayStatus.Scheduled) {
|
|
22
24
|
if (new Date(g.closesAt) <= clock)
|
|
@@ -37,6 +39,8 @@ function launchJob(g, now = new Date().toISOString()) {
|
|
|
37
39
|
kind: giveaway_2.GiveawayNotificationKind.Launch,
|
|
38
40
|
eventKey: giveaway_2.GiveawayNotificationKind.Launch,
|
|
39
41
|
audience: giveaway_2.GiveawayNotificationAudience.All,
|
|
42
|
+
category: g.category,
|
|
43
|
+
expiresAt: g.closesAt,
|
|
40
44
|
title: g.launch.title,
|
|
41
45
|
message: g.launch.message,
|
|
42
46
|
status: giveaway_2.GiveawayNotificationStatus.Pending,
|
|
@@ -179,13 +183,18 @@ function submitEntry(g, account, raw) {
|
|
|
179
183
|
new Date(g.startsAt) > now ||
|
|
180
184
|
new Date(g.closesAt) <= now)
|
|
181
185
|
throw new http_error_1.HttpError('This giveaway is not accepting entries', 409);
|
|
186
|
+
if (!(0, eligibility_1.isGiveawayMemberEligible)(g.category, account._collectorType))
|
|
187
|
+
throw new http_error_1.HttpError('Your membership is not eligible for this giveaway', 403);
|
|
182
188
|
const at = now.toISOString();
|
|
183
189
|
return {
|
|
184
190
|
giveawayId: g._id,
|
|
185
191
|
userId: account.userId,
|
|
186
192
|
name: [account.firstName?.trim(), account.lastName?.trim()].filter(Boolean).join(' '),
|
|
187
193
|
email: account.email,
|
|
188
|
-
_collectorType: account._collectorType
|
|
194
|
+
_collectorType: typeof account._collectorType === 'number' &&
|
|
195
|
+
Object.values(user_1.UserCollectorType).includes(account._collectorType)
|
|
196
|
+
? account._collectorType
|
|
197
|
+
: null,
|
|
189
198
|
enteredAt: at,
|
|
190
199
|
rulesAcceptedAt: at,
|
|
191
200
|
rulesSnapshot: g.rules,
|
|
@@ -211,6 +220,8 @@ function confirmWinners(g, entries, alreadyConfirmed, actor, raw) {
|
|
|
211
220
|
const prize = g.prizes.find(prize => prize._id === assignment.prizeId);
|
|
212
221
|
if (!entry || entry.giveawayId !== g._id || !prize)
|
|
213
222
|
throw new http_error_1.HttpError('Invalid entrant or prize');
|
|
223
|
+
if (!(0, eligibility_1.isGiveawayMemberEligible)(g.category, entry._collectorType))
|
|
224
|
+
throw new http_error_1.HttpError('This entry is not eligible for the giveaway prize', 403);
|
|
214
225
|
if (accounts.has(entry.userId))
|
|
215
226
|
throw new http_error_1.HttpError('Each account can win only one prize');
|
|
216
227
|
accounts.add(entry.userId);
|
|
@@ -241,6 +252,8 @@ function replaceWinner(g, old, entry, alreadyWinner, actor, reason) {
|
|
|
241
252
|
throw new http_error_1.HttpError('Winner replacements require a closed giveaway');
|
|
242
253
|
if (old.giveawayId !== g._id || old.replacedAt || entry.giveawayId !== g._id)
|
|
243
254
|
throw new http_error_1.HttpError('Winner or entrant not found', 404);
|
|
255
|
+
if (!(0, eligibility_1.isGiveawayMemberEligible)(g.category, entry._collectorType))
|
|
256
|
+
throw new http_error_1.HttpError('This entry is not eligible for the giveaway prize', 403);
|
|
244
257
|
if (reason.trim().length < 5)
|
|
245
258
|
throw new http_error_1.HttpError('Provide a replacement reason (at least 5 characters)');
|
|
246
259
|
if (old.status !== giveaway_2.GiveawayWinnerStatus.AwaitingClaim || old.claimedAt)
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { GiveawayCategory } from '../enums/giveaway';
|
|
2
|
+
import { UserCollectorType } from '../enums/user';
|
|
3
|
+
/** Null means entry and winner eligibility has no collector-tier restriction. */
|
|
4
|
+
export declare function giveawayEligibleCollectorTypes(category: GiveawayCategory): readonly UserCollectorType[] | null;
|
|
5
|
+
export declare function isGiveawayMemberEligible(category: GiveawayCategory, collectorType: unknown): boolean;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.giveawayEligibleCollectorTypes = giveawayEligibleCollectorTypes;
|
|
4
|
+
exports.isGiveawayMemberEligible = isGiveawayMemberEligible;
|
|
5
|
+
const giveaway_1 = require("../enums/giveaway");
|
|
6
|
+
const user_1 = require("../enums/user");
|
|
7
|
+
const creditMembers = Object.freeze([
|
|
8
|
+
user_1.UserCollectorType.Rookie,
|
|
9
|
+
user_1.UserCollectorType.Collector,
|
|
10
|
+
user_1.UserCollectorType.Elite
|
|
11
|
+
]);
|
|
12
|
+
const noMembers = Object.freeze([]);
|
|
13
|
+
/** Null means entry and winner eligibility has no collector-tier restriction. */
|
|
14
|
+
function giveawayEligibleCollectorTypes(category) {
|
|
15
|
+
if (category === giveaway_1.GiveawayCategory.Products)
|
|
16
|
+
return null;
|
|
17
|
+
if (category === giveaway_1.GiveawayCategory.Credits)
|
|
18
|
+
return creditMembers;
|
|
19
|
+
return noMembers;
|
|
20
|
+
}
|
|
21
|
+
function isGiveawayMemberEligible(category, collectorType) {
|
|
22
|
+
const tiers = giveawayEligibleCollectorTypes(category);
|
|
23
|
+
return tiers === null || (typeof collectorType === 'number' && tiers.includes(collectorType));
|
|
24
|
+
}
|
|
@@ -2,7 +2,12 @@ import { GiveawayAction, GiveawayCategory, GiveawaySelectionMethod, GiveawayStat
|
|
|
2
2
|
import { UserCollectorType } from '../enums/user';
|
|
3
3
|
import type { ClientSession, Db, MongoClient } from 'mongodb';
|
|
4
4
|
import type { GiveawayPreset, GiveawayTicket, GiveawayClaim, PaginatedApiResponse } from '../types';
|
|
5
|
+
import type { GiveawayCreditEligibilityEvidence } from '../types/revenuecat';
|
|
5
6
|
import type { UserProfile, AuditEvent, DashboardData, Entry, EntryRecord, Giveaway, GiveawayDetailData, GiveawaySummary, NotificationJob, NotificationRecord, OverviewData, PageResult, Winner, WinnerRecord } from './types';
|
|
7
|
+
export type GiveawayRepositoryOptions = {
|
|
8
|
+
checkCreditEligibility?: (userId: string) => Promise<GiveawayCreditEligibilityEvidence | null>;
|
|
9
|
+
creditWinnerMaxAttempts?: number;
|
|
10
|
+
};
|
|
6
11
|
type Stored<T extends {
|
|
7
12
|
_id: string;
|
|
8
13
|
}> = Omit<T, '_id'>;
|
|
@@ -15,13 +20,18 @@ export declare function pagination(params: URLSearchParams): {
|
|
|
15
20
|
export declare class GiveawayRepository {
|
|
16
21
|
private readonly db;
|
|
17
22
|
private readonly client;
|
|
18
|
-
|
|
23
|
+
private readonly options;
|
|
24
|
+
constructor(db: Db, client: MongoClient, options?: GiveawayRepositoryOptions);
|
|
19
25
|
private get giveaways();
|
|
20
26
|
private get entries();
|
|
21
27
|
private get winners();
|
|
22
28
|
private get notifications();
|
|
23
29
|
private get marketing();
|
|
24
30
|
private get activity();
|
|
31
|
+
private checkCreditCandidate;
|
|
32
|
+
private assertFreshCreditEvidence;
|
|
33
|
+
private assertVerifiedCreditCandidate;
|
|
34
|
+
private verifiedCreditSelection;
|
|
25
35
|
private transaction;
|
|
26
36
|
private audit;
|
|
27
37
|
getGiveaway(id: string, session?: ClientSession): Promise<Giveaway>;
|
|
@@ -107,11 +117,11 @@ export declare class GiveawayRepository {
|
|
|
107
117
|
confirmWinners(actor: string, id: string, raw: unknown): Promise<{
|
|
108
118
|
_id: string;
|
|
109
119
|
giveawayId: import("../types").GiveawayObjectId;
|
|
120
|
+
category: GiveawayCategory;
|
|
110
121
|
status: GiveawayWinnerStatus;
|
|
111
122
|
claimedAt?: string | undefined;
|
|
112
123
|
userId?: string | undefined;
|
|
113
124
|
creditAmount?: number | undefined;
|
|
114
|
-
category: GiveawayCategory;
|
|
115
125
|
selection?: import("../types").GiveawaySelection | undefined;
|
|
116
126
|
entryId: import("../types").GiveawayEntryId;
|
|
117
127
|
claimDeadline?: string | undefined;
|
|
@@ -123,6 +133,7 @@ export declare class GiveawayRepository {
|
|
|
123
133
|
prizeId: import("../types").GiveawayObjectId;
|
|
124
134
|
confirmedBy: string;
|
|
125
135
|
confirmedAt: string;
|
|
136
|
+
creditEligibility?: GiveawayCreditEligibilityEvidence | undefined;
|
|
126
137
|
rewardError?: string | undefined;
|
|
127
138
|
replacedAt?: string | null | undefined;
|
|
128
139
|
replacementReason?: string | undefined;
|
|
@@ -131,11 +142,11 @@ export declare class GiveawayRepository {
|
|
|
131
142
|
replaceWinner(actor: string, id: string, winnerId: string, replacementEntryId: string, reason: string): Promise<{
|
|
132
143
|
_id: string;
|
|
133
144
|
giveawayId: import("../types").GiveawayObjectId;
|
|
145
|
+
category: GiveawayCategory;
|
|
134
146
|
status: GiveawayWinnerStatus;
|
|
135
147
|
claimedAt?: string | undefined;
|
|
136
148
|
userId?: string | undefined;
|
|
137
149
|
creditAmount?: number | undefined;
|
|
138
|
-
category: GiveawayCategory;
|
|
139
150
|
selection?: import("../types").GiveawaySelection | undefined;
|
|
140
151
|
entryId: import("../types").GiveawayEntryId;
|
|
141
152
|
claimDeadline?: string | undefined;
|
|
@@ -147,6 +158,7 @@ export declare class GiveawayRepository {
|
|
|
147
158
|
prizeId: import("../types").GiveawayObjectId;
|
|
148
159
|
confirmedBy: string;
|
|
149
160
|
confirmedAt: string;
|
|
161
|
+
creditEligibility?: GiveawayCreditEligibilityEvidence | undefined;
|
|
150
162
|
rewardError?: string | undefined;
|
|
151
163
|
replacedAt?: string | null | undefined;
|
|
152
164
|
replacementReason?: string | undefined;
|
|
@@ -157,10 +169,10 @@ export declare class GiveawayRepository {
|
|
|
157
169
|
courier: string;
|
|
158
170
|
tracking: string;
|
|
159
171
|
giveawayId: import("../types").GiveawayObjectId;
|
|
172
|
+
category: GiveawayCategory;
|
|
160
173
|
claimedAt?: string | undefined;
|
|
161
174
|
userId?: string | undefined;
|
|
162
175
|
creditAmount?: number | undefined;
|
|
163
|
-
category: GiveawayCategory;
|
|
164
176
|
selection?: import("../types").GiveawaySelection | undefined;
|
|
165
177
|
entryId: import("../types").GiveawayEntryId;
|
|
166
178
|
claimDeadline?: string | undefined;
|
|
@@ -170,6 +182,7 @@ export declare class GiveawayRepository {
|
|
|
170
182
|
prizeId: import("../types").GiveawayObjectId;
|
|
171
183
|
confirmedBy: string;
|
|
172
184
|
confirmedAt: string;
|
|
185
|
+
creditEligibility?: GiveawayCreditEligibilityEvidence | undefined;
|
|
173
186
|
rewardError?: string | undefined;
|
|
174
187
|
replacedAt?: string | null | undefined;
|
|
175
188
|
replacementReason?: string | undefined;
|
|
@@ -180,6 +193,7 @@ export declare class GiveawayRepository {
|
|
|
180
193
|
selectWinner(actor: string, id: string, raw: unknown): Promise<{
|
|
181
194
|
giveaway: {
|
|
182
195
|
selection: {
|
|
196
|
+
creditEligibility?: GiveawayCreditEligibilityEvidence | undefined;
|
|
183
197
|
entryId: string;
|
|
184
198
|
method: GiveawaySelectionMethod;
|
|
185
199
|
reason: string;
|
|
@@ -243,9 +257,9 @@ export declare class GiveawayRepository {
|
|
|
243
257
|
userId: string;
|
|
244
258
|
creditAmount: number;
|
|
245
259
|
giveawayId: import("../types").GiveawayObjectId;
|
|
260
|
+
category: GiveawayCategory;
|
|
246
261
|
status: GiveawayWinnerStatus;
|
|
247
262
|
claimedAt?: string | undefined;
|
|
248
|
-
category: GiveawayCategory;
|
|
249
263
|
selection?: import("../types").GiveawaySelection | undefined;
|
|
250
264
|
entryId: import("../types").GiveawayEntryId;
|
|
251
265
|
claimDeadline?: string | undefined;
|
|
@@ -257,6 +271,7 @@ export declare class GiveawayRepository {
|
|
|
257
271
|
prizeId: import("../types").GiveawayObjectId;
|
|
258
272
|
confirmedBy: string;
|
|
259
273
|
confirmedAt: string;
|
|
274
|
+
creditEligibility?: GiveawayCreditEligibilityEvidence | undefined;
|
|
260
275
|
rewardError?: string | undefined;
|
|
261
276
|
replacedAt?: string | null | undefined;
|
|
262
277
|
replacementReason?: string | undefined;
|
|
@@ -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 };
|
|
@@ -129,9 +130,10 @@ function filterCategory(filter, params) {
|
|
|
129
130
|
}
|
|
130
131
|
/** Each method reads only its own records. Transactions contain related writes only. */
|
|
131
132
|
class GiveawayRepository {
|
|
132
|
-
constructor(db, client) {
|
|
133
|
+
constructor(db, client, options = {}) {
|
|
133
134
|
this.db = db;
|
|
134
135
|
this.client = client;
|
|
136
|
+
this.options = options;
|
|
135
137
|
}
|
|
136
138
|
get giveaways() {
|
|
137
139
|
return this.db.collection('giveaways');
|
|
@@ -151,6 +153,111 @@ class GiveawayRepository {
|
|
|
151
153
|
get activity() {
|
|
152
154
|
return this.db.collection('audit');
|
|
153
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
|
+
}
|
|
154
261
|
async transaction(operation) {
|
|
155
262
|
const session = this.client.startSession();
|
|
156
263
|
try {
|
|
@@ -579,6 +686,49 @@ class GiveawayRepository {
|
|
|
579
686
|
{ $sort: { _id: -1 } },
|
|
580
687
|
{ $skip: skip },
|
|
581
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
|
+
},
|
|
582
732
|
{
|
|
583
733
|
$lookup: {
|
|
584
734
|
from: 'giveaways',
|
|
@@ -594,10 +744,13 @@ class GiveawayRepository {
|
|
|
594
744
|
$set: {
|
|
595
745
|
giveawayTitle: {
|
|
596
746
|
$ifNull: [{ $first: '$campaign.title' }, '$giveawayId']
|
|
747
|
+
},
|
|
748
|
+
deliveryCounts: {
|
|
749
|
+
$ifNull: [{ $first: '$deliveryTotals' }, { queued: 0, skipped: 0, pending: 0 }]
|
|
597
750
|
}
|
|
598
751
|
}
|
|
599
752
|
},
|
|
600
|
-
{ $unset: ['campaign', 'claimToken'] },
|
|
753
|
+
{ $unset: ['campaign', 'claimToken', 'deliveryTotals'] },
|
|
601
754
|
stringId
|
|
602
755
|
])
|
|
603
756
|
.toArray(),
|
|
@@ -741,6 +894,20 @@ class GiveawayRepository {
|
|
|
741
894
|
}
|
|
742
895
|
async confirmWinners(actor, id, raw) {
|
|
743
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
|
+
}
|
|
744
911
|
return this.transaction(async (session) => {
|
|
745
912
|
const g = await this.getGiveaway(id, session);
|
|
746
913
|
const rows = await this.entries
|
|
@@ -749,17 +916,25 @@ class GiveawayRepository {
|
|
|
749
916
|
_id: { $in: input.assignments.map(a => a.entryId) }
|
|
750
917
|
}, {
|
|
751
918
|
session,
|
|
752
|
-
projection: { _id: 1, userId: 1, giveawayId: 1 }
|
|
919
|
+
projection: { _id: 1, userId: 1, giveawayId: 1, _collectorType: 1 }
|
|
753
920
|
})
|
|
754
921
|
.limit(1000)
|
|
755
922
|
.toArray();
|
|
756
923
|
const entries = rows;
|
|
757
924
|
const existing = await this.winners.findOne({ giveawayId: id, replacedAt: null }, { session, projection: { _id: 1 } });
|
|
758
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;
|
|
759
929
|
await this.updateGiveaway({ ...g, status: giveaway_2.GiveawayStatus.AwaitingClaim, revision: g.revision + 1 }, g.revision, session);
|
|
760
930
|
const result = await this.winners.insertMany(winners.map(winner => ({ ...winner })), { session });
|
|
761
|
-
await this.enqueueEvent(g, giveaway_2.GiveawayNotificationKind.Results, 'results:' + result.insertedIds[0].toHexString(), session);
|
|
762
|
-
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);
|
|
763
938
|
return winners.map((winner, index) => ({
|
|
764
939
|
...winner,
|
|
765
940
|
_id: result.insertedIds[index].toHexString()
|
|
@@ -767,22 +942,52 @@ class GiveawayRepository {
|
|
|
767
942
|
});
|
|
768
943
|
}
|
|
769
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
|
+
}
|
|
770
963
|
return this.transaction(async (session) => {
|
|
771
964
|
const g = await this.getGiveaway(id, session);
|
|
772
965
|
const previousWinner = await this.winners.findOne({ _id: objectId(winnerId), giveawayId: id, replacedAt: null }, { session });
|
|
773
|
-
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 } });
|
|
774
967
|
if (!previousWinner || !entrant)
|
|
775
968
|
throw new domain_1.DomainError('Winner or entrant not found', 404);
|
|
776
969
|
const old = serialize(previousWinner);
|
|
777
970
|
const entry = entrant;
|
|
778
971
|
const existing = await this.winners.findOne({ giveawayId: id, entryId: replacementEntryId, replacedAt: null }, { session, projection: { _id: 1 } });
|
|
779
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
|
+
}
|
|
780
979
|
await this.updateGiveaway({ ...g, status: giveaway_2.GiveawayStatus.AwaitingClaim, revision: g.revision + 1 }, g.revision, session);
|
|
781
980
|
const { _id, ...previousFields } = previous;
|
|
782
981
|
await this.winners.updateOne({ _id: objectId(_id), replacedAt: null }, { $set: previousFields }, { session });
|
|
783
982
|
const result = await this.winners.insertOne({ ...replacement }, { session });
|
|
784
|
-
await this.enqueueEvent(g, giveaway_2.GiveawayNotificationKind.Results, 'results:' + result.insertedId.toHexString(), session);
|
|
785
|
-
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);
|
|
786
991
|
return { ...replacement, _id: result.insertedId.toHexString() };
|
|
787
992
|
});
|
|
788
993
|
}
|
|
@@ -818,7 +1023,7 @@ class GiveawayRepository {
|
|
|
818
1023
|
return winner;
|
|
819
1024
|
});
|
|
820
1025
|
}
|
|
821
|
-
async enqueueEvent(g, kind, eventKey, session) {
|
|
1026
|
+
async enqueueEvent(g, kind, eventKey, session, expiresAt) {
|
|
822
1027
|
const copy = g.reminders?.[kind];
|
|
823
1028
|
if (!copy?.enabled)
|
|
824
1029
|
return;
|
|
@@ -828,6 +1033,11 @@ class GiveawayRepository {
|
|
|
828
1033
|
eventKey,
|
|
829
1034
|
kind,
|
|
830
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(),
|
|
831
1041
|
title: copy.title,
|
|
832
1042
|
message: copy.message,
|
|
833
1043
|
status: giveaway_2.GiveawayNotificationStatus.Pending,
|
|
@@ -837,6 +1047,10 @@ class GiveawayRepository {
|
|
|
837
1047
|
}
|
|
838
1048
|
async selectWinner(actor, id, raw) {
|
|
839
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;
|
|
840
1054
|
return this.transaction(async (session) => {
|
|
841
1055
|
const g = await this.getGiveaway(id, session);
|
|
842
1056
|
if ((0, domain_1.effectiveStatus)(g) !== giveaway_2.GiveawayStatus.Closed)
|
|
@@ -846,29 +1060,47 @@ class GiveawayRepository {
|
|
|
846
1060
|
if (g.selection ||
|
|
847
1061
|
(await this.winners.findOne({ giveawayId: id, replacedAt: null }, { session })))
|
|
848
1062
|
throw new domain_1.DomainError('A winner is already selected. Explicitly reset the selection to change it.', 409);
|
|
849
|
-
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 });
|
|
850
1069
|
if (!count)
|
|
851
1070
|
throw new domain_1.DomainError('This giveaway has no eligible entries', 409);
|
|
852
|
-
const
|
|
853
|
-
|
|
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 })
|
|
854
1075
|
: await this.entries
|
|
855
|
-
.find(
|
|
1076
|
+
.find(eligible, { session })
|
|
856
1077
|
.sort({ _id: 1 })
|
|
857
1078
|
.skip((0, node_crypto_1.randomInt)(count))
|
|
858
1079
|
.limit(1)
|
|
859
1080
|
.next();
|
|
860
1081
|
if (!entry)
|
|
861
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);
|
|
862
1086
|
const selection = {
|
|
863
1087
|
entryId: entry._id,
|
|
864
1088
|
method: input.method,
|
|
865
1089
|
reason: input.method === giveaway_2.GiveawaySelectionMethod.Manual ? input.reason : '',
|
|
866
1090
|
selectedBy: actor,
|
|
867
|
-
selectedAt: new Date().toISOString()
|
|
1091
|
+
selectedAt: new Date().toISOString(),
|
|
1092
|
+
...(verified && { creditEligibility: verified.evidence })
|
|
868
1093
|
};
|
|
869
1094
|
const updated = { ...g, selection, revision: g.revision + 1 };
|
|
870
1095
|
await this.updateGiveaway(updated, g.revision, session);
|
|
871
|
-
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);
|
|
872
1104
|
return { giveaway: updated, entry: normalizeEntry(entry) };
|
|
873
1105
|
});
|
|
874
1106
|
}
|
|
@@ -930,7 +1162,7 @@ class GiveawayRepository {
|
|
|
930
1162
|
category: giveaway_2.GiveawayCategory.Credits,
|
|
931
1163
|
creditAmount: amount,
|
|
932
1164
|
countries: [],
|
|
933
|
-
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.',
|
|
934
1166
|
deliveryInformation: '',
|
|
935
1167
|
revision: 1,
|
|
936
1168
|
createdAt: now,
|
|
@@ -1263,7 +1495,11 @@ class GiveawayRepository {
|
|
|
1263
1495
|
const now = new Date().toISOString();
|
|
1264
1496
|
const updated = await this.notifications.updateOne(filter, successful
|
|
1265
1497
|
? {
|
|
1266
|
-
$set: {
|
|
1498
|
+
$set: {
|
|
1499
|
+
status: giveaway_2.GiveawayNotificationStatus.Sent,
|
|
1500
|
+
sentAt: now,
|
|
1501
|
+
dispatchCompletedAt: now
|
|
1502
|
+
},
|
|
1267
1503
|
$unset: { error: '', claimToken: '' }
|
|
1268
1504
|
}
|
|
1269
1505
|
: {
|
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);
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -36,3 +36,5 @@ __exportStar(require("./validator"), exports);
|
|
|
36
36
|
__exportStar(require("./id"), exports);
|
|
37
37
|
__exportStar(require("./http-error"), exports);
|
|
38
38
|
__exportStar(require("./giveaways"), exports);
|
|
39
|
+
__exportStar(require("./constants/revenuecat"), exports);
|
|
40
|
+
__exportStar(require("./revenuecat"), exports);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { HttpError } from './http-error';
|
|
2
|
+
import type { GiveawayCreditEligibilityEvidence, RevenueCatClient, RevenueCatEntitlementIds } from './types/revenuecat';
|
|
3
|
+
export type RevenueCatClientOptions = {
|
|
4
|
+
apiKey: string;
|
|
5
|
+
projectId: string;
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
totalTimeoutMs?: number;
|
|
8
|
+
maxPages?: number;
|
|
9
|
+
};
|
|
10
|
+
/** Deliberately excludes the original HTTP error, which can contain the API key. */
|
|
11
|
+
export declare class RevenueCatLookupError extends HttpError {
|
|
12
|
+
constructor();
|
|
13
|
+
}
|
|
14
|
+
export declare function createRevenueCatClient(options: RevenueCatClientOptions): RevenueCatClient;
|
|
15
|
+
/** Resolve any eligible entitlement from one customer lookup; never infer Free from API failures. */
|
|
16
|
+
export declare function verifyCreditGiveawayEntitlement(userId: string, client: RevenueCatClient, entitlementIds: RevenueCatEntitlementIds): Promise<GiveawayCreditEligibilityEvidence | null>;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.RevenueCatLookupError = void 0;
|
|
7
|
+
exports.createRevenueCatClient = createRevenueCatClient;
|
|
8
|
+
exports.verifyCreditGiveawayEntitlement = verifyCreditGiveawayEntitlement;
|
|
9
|
+
const axios_1 = __importDefault(require("axios"));
|
|
10
|
+
const revenuecat_1 = require("./constants/revenuecat");
|
|
11
|
+
const user_1 = require("./enums/user");
|
|
12
|
+
const http_error_1 = require("./http-error");
|
|
13
|
+
/** Deliberately excludes the original HTTP error, which can contain the API key. */
|
|
14
|
+
class RevenueCatLookupError extends http_error_1.HttpError {
|
|
15
|
+
constructor() {
|
|
16
|
+
super('Unable to verify RevenueCat entitlement access. Please try again.', 503);
|
|
17
|
+
this.name = 'RevenueCatLookupError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
exports.RevenueCatLookupError = RevenueCatLookupError;
|
|
21
|
+
function positiveInteger(value, maximum) {
|
|
22
|
+
return Number.isInteger(value) && value > 0 && value <= maximum;
|
|
23
|
+
}
|
|
24
|
+
function validUserId(value) {
|
|
25
|
+
return (typeof value === 'string' &&
|
|
26
|
+
value.length > 0 &&
|
|
27
|
+
value.length <= 128 &&
|
|
28
|
+
value.trim() === value &&
|
|
29
|
+
!/[.#$[\]/]/.test(value) &&
|
|
30
|
+
!Array.from(value).some(character => {
|
|
31
|
+
const code = character.charCodeAt(0);
|
|
32
|
+
return code < 32 || code === 127;
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
function parsePage(value) {
|
|
36
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
37
|
+
throw new RevenueCatLookupError();
|
|
38
|
+
const page = value;
|
|
39
|
+
if (!Array.isArray(page.items) ||
|
|
40
|
+
(page.next_page != null && (typeof page.next_page !== 'string' || !page.next_page)))
|
|
41
|
+
throw new RevenueCatLookupError();
|
|
42
|
+
const items = page.items.map((raw) => {
|
|
43
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
44
|
+
throw new RevenueCatLookupError();
|
|
45
|
+
const entitlement = raw;
|
|
46
|
+
if (typeof entitlement.entitlement_id !== 'string' ||
|
|
47
|
+
!/^entl[a-zA-Z0-9]+$/.test(entitlement.entitlement_id) ||
|
|
48
|
+
(entitlement.expires_at !== null &&
|
|
49
|
+
(typeof entitlement.expires_at !== 'number' ||
|
|
50
|
+
!Number.isSafeInteger(entitlement.expires_at) ||
|
|
51
|
+
entitlement.expires_at < 0)))
|
|
52
|
+
throw new RevenueCatLookupError();
|
|
53
|
+
return {
|
|
54
|
+
entitlementId: entitlement.entitlement_id,
|
|
55
|
+
expiresAt: entitlement.expires_at
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
return { items, nextPage: page.next_page ?? null };
|
|
59
|
+
}
|
|
60
|
+
function retryAfterMs(value) {
|
|
61
|
+
if (typeof value === 'number')
|
|
62
|
+
return Number.isFinite(value) && value >= 0 ? value * 1000 : null;
|
|
63
|
+
if (typeof value !== 'string' || !value.trim())
|
|
64
|
+
return null;
|
|
65
|
+
const text = value.trim();
|
|
66
|
+
if (/^\d+$/.test(text)) {
|
|
67
|
+
const seconds = Number(text);
|
|
68
|
+
return Number.isFinite(seconds) ? seconds * 1000 : null;
|
|
69
|
+
}
|
|
70
|
+
const date = Date.parse(text);
|
|
71
|
+
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : null;
|
|
72
|
+
}
|
|
73
|
+
/** Inspect only retry metadata; never retain or log the original transport error. */
|
|
74
|
+
function requestRetryDelay(error, attempt) {
|
|
75
|
+
if (!error || typeof error !== 'object')
|
|
76
|
+
return null;
|
|
77
|
+
const failure = error;
|
|
78
|
+
const response = failure.response && typeof failure.response === 'object'
|
|
79
|
+
? failure.response
|
|
80
|
+
: undefined;
|
|
81
|
+
if (response) {
|
|
82
|
+
if (typeof response.status !== 'number' || ![429, 500, 502, 503, 504].includes(response.status))
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
else if (typeof failure.code !== 'string' ||
|
|
86
|
+
![
|
|
87
|
+
'ECONNRESET',
|
|
88
|
+
'ETIMEDOUT',
|
|
89
|
+
'ECONNABORTED',
|
|
90
|
+
'EAI_AGAIN',
|
|
91
|
+
'ENOTFOUND',
|
|
92
|
+
'ERR_NETWORK',
|
|
93
|
+
'EPIPE',
|
|
94
|
+
'ECONNREFUSED'
|
|
95
|
+
].includes(failure.code))
|
|
96
|
+
return null;
|
|
97
|
+
let headerDelay = null;
|
|
98
|
+
if (response?.headers && typeof response.headers === 'object') {
|
|
99
|
+
const headers = response.headers;
|
|
100
|
+
headerDelay = retryAfterMs(typeof headers.get === 'function'
|
|
101
|
+
? headers.get('retry-after')
|
|
102
|
+
: (headers['retry-after'] ?? headers['Retry-After']));
|
|
103
|
+
}
|
|
104
|
+
const body = response?.data && typeof response.data === 'object'
|
|
105
|
+
? response.data
|
|
106
|
+
: undefined;
|
|
107
|
+
const bodyDelay = typeof body?.backoff_ms === 'number' && Number.isFinite(body.backoff_ms) && body.backoff_ms >= 0
|
|
108
|
+
? body.backoff_ms
|
|
109
|
+
: 0;
|
|
110
|
+
return Math.max(100 * 2 ** attempt, headerDelay ?? 0, bodyDelay);
|
|
111
|
+
}
|
|
112
|
+
async function requestPage(url, apiKey, timeoutMs, deadline) {
|
|
113
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
114
|
+
const remainingMs = deadline - Date.now();
|
|
115
|
+
if (remainingMs <= 0)
|
|
116
|
+
throw new RevenueCatLookupError();
|
|
117
|
+
try {
|
|
118
|
+
return await axios_1.default.get(url.href, {
|
|
119
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
120
|
+
timeout: Math.min(timeoutMs, remainingMs),
|
|
121
|
+
signal: AbortSignal.timeout(remainingMs),
|
|
122
|
+
maxRedirects: 0,
|
|
123
|
+
maxContentLength: 1024 * 1024
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
const delay = attempt < 2 ? requestRetryDelay(error, attempt) : null;
|
|
128
|
+
if (delay === null || delay >= deadline - Date.now())
|
|
129
|
+
throw new RevenueCatLookupError();
|
|
130
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
throw new RevenueCatLookupError();
|
|
134
|
+
}
|
|
135
|
+
function createRevenueCatClient(options) {
|
|
136
|
+
const apiKey = options.apiKey?.trim();
|
|
137
|
+
const projectId = options.projectId?.trim();
|
|
138
|
+
const timeoutMs = options.timeoutMs ?? 15000;
|
|
139
|
+
const totalTimeoutMs = options.totalTimeoutMs ?? 15000;
|
|
140
|
+
const maxPages = options.maxPages ?? 20;
|
|
141
|
+
if (!apiKey || /[\r\n]/.test(apiKey))
|
|
142
|
+
throw new Error('Configure the RevenueCat API key');
|
|
143
|
+
if (!projectId || projectId.length > 255 || !/^[a-zA-Z0-9_-]+$/.test(projectId))
|
|
144
|
+
throw new Error('Configure a valid RevenueCat project ID');
|
|
145
|
+
if (!positiveInteger(timeoutMs, 60000) ||
|
|
146
|
+
!positiveInteger(totalTimeoutMs, 120000) ||
|
|
147
|
+
!positiveInteger(maxPages, 100))
|
|
148
|
+
throw new Error('Configure valid RevenueCat request limits');
|
|
149
|
+
return {
|
|
150
|
+
async getActiveEntitlements(userId) {
|
|
151
|
+
if (!validUserId(userId))
|
|
152
|
+
throw new http_error_1.HttpError('Invalid DGC user ID', 400);
|
|
153
|
+
const endpoint = new URL(`${revenuecat_1.REVENUECAT_API_V2}/projects/${encodeURIComponent(projectId)}/customers/${encodeURIComponent(userId)}/active_entitlements`);
|
|
154
|
+
endpoint.searchParams.set('limit', '100');
|
|
155
|
+
const deadline = Date.now() + totalTimeoutMs;
|
|
156
|
+
const visited = new Set();
|
|
157
|
+
const entitlements = [];
|
|
158
|
+
let url = endpoint;
|
|
159
|
+
try {
|
|
160
|
+
while (url) {
|
|
161
|
+
const remainingMs = deadline - Date.now();
|
|
162
|
+
if (remainingMs <= 0 ||
|
|
163
|
+
visited.size >= maxPages ||
|
|
164
|
+
url.origin !== endpoint.origin ||
|
|
165
|
+
url.pathname !== endpoint.pathname ||
|
|
166
|
+
url.username ||
|
|
167
|
+
url.password ||
|
|
168
|
+
url.hash ||
|
|
169
|
+
visited.has(url.href))
|
|
170
|
+
throw new RevenueCatLookupError();
|
|
171
|
+
visited.add(url.href);
|
|
172
|
+
const response = await requestPage(url, apiKey, timeoutMs, deadline);
|
|
173
|
+
const page = parsePage(response.data);
|
|
174
|
+
entitlements.push(...page.items);
|
|
175
|
+
url = page.nextPage ? new URL(page.nextPage, endpoint) : null;
|
|
176
|
+
}
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
if (now >= deadline)
|
|
179
|
+
throw new RevenueCatLookupError();
|
|
180
|
+
return entitlements.filter(item => item.expiresAt === null || item.expiresAt > now);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// Do not attach request/config/cause or log the customer payload.
|
|
184
|
+
throw new RevenueCatLookupError();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/** Resolve any eligible entitlement from one customer lookup; never infer Free from API failures. */
|
|
190
|
+
async function verifyCreditGiveawayEntitlement(userId, client, entitlementIds) {
|
|
191
|
+
const ids = (0, revenuecat_1.createRevenueCatEntitlementIds)(entitlementIds);
|
|
192
|
+
const active = await client.getActiveEntitlements(userId);
|
|
193
|
+
const checkedAt = new Date().toISOString();
|
|
194
|
+
const now = Date.parse(checkedAt);
|
|
195
|
+
const tiers = [
|
|
196
|
+
[user_1.UserCollectorType.Elite, ids.elite],
|
|
197
|
+
[user_1.UserCollectorType.Collector, ids.collector],
|
|
198
|
+
[user_1.UserCollectorType.Rookie, ids.rookie]
|
|
199
|
+
];
|
|
200
|
+
for (const [collectorType, entitlementId] of tiers) {
|
|
201
|
+
const match = active.find(item => item.entitlementId === entitlementId && (item.expiresAt === null || item.expiresAt > now));
|
|
202
|
+
if (match)
|
|
203
|
+
return { userId, checkedAt, collectorType, ...match };
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
package/dist/types/giveaway.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { GiveawayStatus, GiveawayCategory, GiveawaySelectionMethod, GiveawayWinnerStatus, GiveawayNotificationKind, GiveawayNotificationAudience, GiveawayNotificationStatus, GiveawayTicketResult, GiveawayAction, GiveawayNotificationChannel } from '../enums/giveaway';
|
|
2
2
|
import type { FbUser } from './user';
|
|
3
3
|
import type { UserCollectorType } from '../enums/user';
|
|
4
|
+
import type { GiveawayCreditEligibilityEvidence } from './revenuecat';
|
|
4
5
|
/** API identities are strings. MongoDB ObjectIds serialize as 24 lowercase hexadecimal characters. */
|
|
5
6
|
export type GiveawayObjectId = string;
|
|
6
7
|
/** New tickets use DGC- followed by twelve uppercase letters or digits. Existing numeric IDs remain readable. */
|
|
@@ -16,6 +17,7 @@ export type GiveawaySelection = {
|
|
|
16
17
|
reason: string;
|
|
17
18
|
selectedBy: string;
|
|
18
19
|
selectedAt: string;
|
|
20
|
+
creditEligibility?: GiveawayCreditEligibilityEvidence;
|
|
19
21
|
};
|
|
20
22
|
export type GiveawayPrize = {
|
|
21
23
|
_id: GiveawayObjectId;
|
|
@@ -78,7 +80,7 @@ export type GiveawayEntry = {
|
|
|
78
80
|
contactNumber?: string;
|
|
79
81
|
/** Retained only for historical entry records; new entries collect country at claim. */
|
|
80
82
|
country?: string;
|
|
81
|
-
/** Null
|
|
83
|
+
/** Null when the account tier is missing or unsupported; product entries remain eligible. */
|
|
82
84
|
_collectorType: UserCollectorType | null;
|
|
83
85
|
enteredAt: string;
|
|
84
86
|
rulesAcceptedAt: string;
|
|
@@ -96,6 +98,7 @@ export type GiveawayWinner = {
|
|
|
96
98
|
status: GiveawayWinnerStatus;
|
|
97
99
|
userId?: string;
|
|
98
100
|
selection?: GiveawaySelection;
|
|
101
|
+
creditEligibility?: GiveawayCreditEligibilityEvidence;
|
|
99
102
|
category: GiveawayCategory;
|
|
100
103
|
creditAmount?: number;
|
|
101
104
|
claimDeadline?: string;
|
|
@@ -127,6 +130,14 @@ export type GiveawayNotificationJob = {
|
|
|
127
130
|
/** Stable event key supports replacements without resending a prior event. */
|
|
128
131
|
eventKey?: string;
|
|
129
132
|
audience?: GiveawayNotificationAudience;
|
|
133
|
+
/** Snapshots keep targeting and expiry stable throughout a broadcast. */
|
|
134
|
+
category?: GiveawayCategory;
|
|
135
|
+
expiresAt?: string;
|
|
136
|
+
dispatchAfter?: string;
|
|
137
|
+
dispatchGeneration?: number;
|
|
138
|
+
recipientsQueued?: number;
|
|
139
|
+
recipientsSkipped?: number;
|
|
140
|
+
dispatchCompletedAt?: string;
|
|
130
141
|
status: GiveawayNotificationStatus;
|
|
131
142
|
attempts: number;
|
|
132
143
|
error?: string;
|
|
@@ -173,6 +184,12 @@ export type GiveawayWinnerRecord = GiveawayWinner & {
|
|
|
173
184
|
};
|
|
174
185
|
export type GiveawayNotificationRecord = Omit<GiveawayNotificationJob, 'claimToken'> & {
|
|
175
186
|
giveawayTitle: string;
|
|
187
|
+
/** Queue acceptance counts, not confirmation of device delivery. */
|
|
188
|
+
deliveryCounts?: {
|
|
189
|
+
queued: number;
|
|
190
|
+
skipped: number;
|
|
191
|
+
pending: number;
|
|
192
|
+
};
|
|
176
193
|
};
|
|
177
194
|
export type GiveawayOverviewData = {
|
|
178
195
|
days: {
|
|
@@ -182,8 +199,9 @@ export type GiveawayOverviewData = {
|
|
|
182
199
|
liveGiveaways: GiveawaySummary[];
|
|
183
200
|
needsSelection: Giveaway[];
|
|
184
201
|
};
|
|
185
|
-
export type GiveawayUserProfile = Pick<FbUser, 'email' | 'firstName' | 'lastName'
|
|
202
|
+
export type GiveawayUserProfile = Pick<FbUser, 'email' | 'firstName' | 'lastName'> & {
|
|
186
203
|
userId: string;
|
|
204
|
+
_collectorType?: UserCollectorType | null;
|
|
187
205
|
};
|
|
188
206
|
export type GiveawayPhysicalClaim = {
|
|
189
207
|
fullName: string;
|
|
@@ -279,6 +297,8 @@ export type GiveawayWinnerPreview = {
|
|
|
279
297
|
};
|
|
280
298
|
/** Public app response; excludes admin settings, prize values and personal data. */
|
|
281
299
|
export type GiveawayPublic = Pick<Giveaway, '_id' | 'title' | 'description' | 'image' | 'status' | 'startsAt' | 'closesAt' | 'countries' | 'rules' | 'category' | 'creditAmount' | 'deliveryInformation'> & {
|
|
300
|
+
/** Null permits all authenticated accounts regardless of their stored tier. */
|
|
301
|
+
eligibleCollectorTypes: UserCollectorType[] | null;
|
|
282
302
|
entryCount: number;
|
|
283
303
|
images: string[];
|
|
284
304
|
prizes: Pick<GiveawayPrize, 'name' | 'quantity'>[];
|
package/dist/types/index.d.ts
CHANGED
package/dist/types/index.js
CHANGED
|
@@ -35,3 +35,5 @@ __exportStar(require("./user"), exports);
|
|
|
35
35
|
__exportStar(require("./x-credit"), exports);
|
|
36
36
|
__exportStar(require("./giveaway"), exports);
|
|
37
37
|
__exportStar(require("../enums/giveaway"), exports);
|
|
38
|
+
__exportStar(require("./revenuecat"), exports);
|
|
39
|
+
__exportStar(require("../constants/revenuecat"), exports);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { UserCollectorType } from '../enums/user';
|
|
2
|
+
export type RevenueCatActiveEntitlement = {
|
|
3
|
+
entitlementId: string;
|
|
4
|
+
/** RevenueCat uses milliseconds since epoch; null is non-expiring access. */
|
|
5
|
+
expiresAt: number | null;
|
|
6
|
+
};
|
|
7
|
+
export type RevenueCatEntitlementIds = Readonly<{
|
|
8
|
+
rookie: string;
|
|
9
|
+
collector: string;
|
|
10
|
+
elite: string;
|
|
11
|
+
}>;
|
|
12
|
+
export type RevenueCatClient = {
|
|
13
|
+
getActiveEntitlements(userId: string): Promise<RevenueCatActiveEntitlement[]>;
|
|
14
|
+
};
|
|
15
|
+
export type GiveawayCreditEligibilityEvidence = RevenueCatActiveEntitlement & {
|
|
16
|
+
userId: string;
|
|
17
|
+
checkedAt: string;
|
|
18
|
+
collectorType: UserCollectorType.Rookie | UserCollectorType.Collector | UserCollectorType.Elite;
|
|
19
|
+
};
|