@growth-labs/cms 0.5.1 → 0.5.3
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 +10 -2
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +15 -10
- package/dist/engine/publisher.js.map +1 -1
- package/dist/providers/null.d.ts.map +1 -1
- package/dist/providers/null.js +20 -3
- package/dist/providers/null.js.map +1 -1
- package/dist/providers/types.d.ts +96 -6
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js +20 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/routes/authz-matrix.d.ts.map +1 -1
- package/dist/routes/authz-matrix.js +4 -0
- package/dist/routes/authz-matrix.js.map +1 -1
- package/dist/routes/context.d.ts +1 -1
- package/dist/routes/context.d.ts.map +1 -1
- package/dist/routes/context.js.map +1 -1
- package/dist/routes/subscriptions.d.ts +9 -2
- package/dist/routes/subscriptions.d.ts.map +1 -1
- package/dist/routes/subscriptions.js +196 -21
- package/dist/routes/subscriptions.js.map +1 -1
- package/dist/ui/api/subscriptions.d.ts +1 -1
- package/dist/ui/api/subscriptions.d.ts.map +1 -1
- package/dist/ui/api/subscriptions.js +27 -5
- package/dist/ui/api/subscriptions.js.map +1 -1
- package/dist/ui/screens/SubscriptionsScreen.d.ts +44 -0
- package/dist/ui/screens/SubscriptionsScreen.d.ts.map +1 -1
- package/dist/ui/screens/SubscriptionsScreen.js +262 -54
- package/dist/ui/screens/SubscriptionsScreen.js.map +1 -1
- package/dist/ui/screens/subscriptions-data.d.ts +33 -0
- package/dist/ui/screens/subscriptions-data.d.ts.map +1 -0
- package/dist/ui/screens/subscriptions-data.js +143 -0
- package/dist/ui/screens/subscriptions-data.js.map +1 -0
- package/package.json +1 -1
- package/src/engine/publisher.ts +18 -12
- package/src/providers/null.ts +32 -3
- package/src/providers/types.ts +134 -4
- package/src/routes/authz-matrix.ts +4 -0
- package/src/routes/context.ts +3 -0
- package/src/routes/subscriptions.ts +249 -27
- package/src/ui/api/subscriptions.ts +27 -5
- package/src/ui/screens/SubscriptionsScreen.tsx +631 -116
- package/src/ui/screens/subscriptions-data.ts +193 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ComplimentaryAccessPage, ComplimentaryEmailRetryResult, ComplimentaryEntitlementRow, ComplimentaryGrantResult, ComplimentaryRevokeResult, ProviderResult } from '../../providers/types.js';
|
|
2
|
+
export interface ComplimentaryGrantForm {
|
|
3
|
+
email: string;
|
|
4
|
+
reason: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ResultCopy {
|
|
7
|
+
tone: 'success' | 'warning' | 'info' | 'error';
|
|
8
|
+
message: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function buildComplimentaryGrantPayload(form: ComplimentaryGrantForm): {
|
|
11
|
+
email: string;
|
|
12
|
+
reason: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function buildComplimentaryRevokePayload(entitlement: Pick<ComplimentaryEntitlementRow, 'id'>, reason: string): {
|
|
15
|
+
entitlementId: string;
|
|
16
|
+
reason: string;
|
|
17
|
+
};
|
|
18
|
+
export declare function buildComplimentaryRetryPayload(entitlement: Pick<ComplimentaryEntitlementRow, 'id'>, reason: string): {
|
|
19
|
+
entitlementId: string;
|
|
20
|
+
reason: string;
|
|
21
|
+
};
|
|
22
|
+
export declare function complimentaryGrantSubmitDisabled(form: ComplimentaryGrantForm, submitting: boolean): boolean;
|
|
23
|
+
export declare function describeComplimentaryGrantResult(result: ProviderResult<ComplimentaryGrantResult>): ResultCopy;
|
|
24
|
+
export declare function describeComplimentaryRevokeResult(result: ProviderResult<ComplimentaryRevokeResult>): ResultCopy;
|
|
25
|
+
export declare function describeComplimentaryRetryResult(result: ProviderResult<ComplimentaryEmailRetryResult>): ResultCopy;
|
|
26
|
+
export declare function complimentaryRowActions(row: ComplimentaryEntitlementRow, nowUnixSeconds?: number): {
|
|
27
|
+
canRevoke: boolean;
|
|
28
|
+
canRetryEmail: boolean;
|
|
29
|
+
retryLabel: string;
|
|
30
|
+
};
|
|
31
|
+
export declare function complimentaryPageLabel(page: ComplimentaryAccessPage): string;
|
|
32
|
+
export declare function formatTimestamp(value: number): string;
|
|
33
|
+
//# sourceMappingURL=subscriptions-data.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"subscriptions-data.d.ts","sourceRoot":"","sources":["../../../src/ui/screens/subscriptions-data.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACX,uBAAuB,EACvB,6BAA6B,EAC7B,2BAA2B,EAC3B,wBAAwB,EACxB,yBAAyB,EACzB,cAAc,EACd,MAAM,0BAA0B,CAAA;AAGjC,MAAM,WAAW,sBAAsB;IACtC,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,UAAU;IAC1B,IAAI,EAAE,SAAS,GAAG,SAAS,GAAG,MAAM,GAAG,OAAO,CAAA;IAC9C,OAAO,EAAE,MAAM,CAAA;CACf;AAED,wBAAgB,8BAA8B,CAAC,IAAI,EAAE,sBAAsB;;;EAK1E;AAED,wBAAgB,+BAA+B,CAC9C,WAAW,EAAE,IAAI,CAAC,2BAA2B,EAAE,IAAI,CAAC,EACpD,MAAM,EAAE,MAAM;;;EAMd;AAED,wBAAgB,8BAA8B,CAC7C,WAAW,EAAE,IAAI,CAAC,2BAA2B,EAAE,IAAI,CAAC,EACpD,MAAM,EAAE,MAAM;;;EAMd;AAED,wBAAgB,gCAAgC,CAC/C,IAAI,EAAE,sBAAsB,EAC5B,UAAU,EAAE,OAAO,GACjB,OAAO,CAET;AAED,wBAAgB,gCAAgC,CAC/C,MAAM,EAAE,cAAc,CAAC,wBAAwB,CAAC,GAC9C,UAAU,CAoCZ;AAED,wBAAgB,iCAAiC,CAChD,MAAM,EAAE,cAAc,CAAC,yBAAyB,CAAC,GAC/C,UAAU,CAQZ;AAED,wBAAgB,gCAAgC,CAC/C,MAAM,EAAE,cAAc,CAAC,6BAA6B,CAAC,GACnD,UAAU,CA0BZ;AAED,wBAAgB,uBAAuB,CACtC,GAAG,EAAE,2BAA2B,EAChC,cAAc,SAAgC;;;;EAwB9C;AAED,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,uBAAuB,GAAG,MAAM,CAE5E;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAOrD"}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { MAX_COMPLIMENTARY_UNIX_SECONDS } from '../../providers/types.js';
|
|
2
|
+
export function buildComplimentaryGrantPayload(form) {
|
|
3
|
+
return {
|
|
4
|
+
email: form.email.trim().toLowerCase(),
|
|
5
|
+
reason: form.reason.trim(),
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
export function buildComplimentaryRevokePayload(entitlement, reason) {
|
|
9
|
+
return {
|
|
10
|
+
entitlementId: entitlement.id,
|
|
11
|
+
reason: reason.trim(),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function buildComplimentaryRetryPayload(entitlement, reason) {
|
|
15
|
+
return {
|
|
16
|
+
entitlementId: entitlement.id,
|
|
17
|
+
reason: reason.trim(),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function complimentaryGrantSubmitDisabled(form, submitting) {
|
|
21
|
+
return submitting || form.email.trim().length === 0 || form.reason.trim().length === 0;
|
|
22
|
+
}
|
|
23
|
+
export function describeComplimentaryGrantResult(result) {
|
|
24
|
+
if (result.status === 'empty') {
|
|
25
|
+
return { tone: 'error', message: 'Complimentary access provider is not configured.' };
|
|
26
|
+
}
|
|
27
|
+
if (result.data.outcome === 'already_active') {
|
|
28
|
+
return {
|
|
29
|
+
tone: 'info',
|
|
30
|
+
message: 'Complimentary access was already active. No new email was sent.',
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
switch (result.data.email.status) {
|
|
34
|
+
case 'sent':
|
|
35
|
+
return { tone: 'success', message: 'Access granted and email sent.' };
|
|
36
|
+
case 'failed':
|
|
37
|
+
return {
|
|
38
|
+
tone: 'warning',
|
|
39
|
+
message: 'Access granted, but the email failed. Retry delivery from the row action.',
|
|
40
|
+
};
|
|
41
|
+
case 'unknown':
|
|
42
|
+
return {
|
|
43
|
+
tone: 'warning',
|
|
44
|
+
message: 'Access granted, but email delivery is unknown. Retry delivery from the row action.',
|
|
45
|
+
};
|
|
46
|
+
case 'pending':
|
|
47
|
+
case 'sending':
|
|
48
|
+
return {
|
|
49
|
+
tone: 'info',
|
|
50
|
+
message: 'Access granted and email delivery is in progress.',
|
|
51
|
+
};
|
|
52
|
+
case 'not_attempted':
|
|
53
|
+
return {
|
|
54
|
+
tone: 'warning',
|
|
55
|
+
message: 'Access granted, but no email attempt was recorded.',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function describeComplimentaryRevokeResult(result) {
|
|
60
|
+
if (result.status === 'empty') {
|
|
61
|
+
return { tone: 'error', message: 'Complimentary access provider is not configured.' };
|
|
62
|
+
}
|
|
63
|
+
if (result.data.outcome === 'already_revoked') {
|
|
64
|
+
return { tone: 'info', message: 'Complimentary access was already revoked.' };
|
|
65
|
+
}
|
|
66
|
+
return { tone: 'success', message: 'Complimentary access revoked.' };
|
|
67
|
+
}
|
|
68
|
+
export function describeComplimentaryRetryResult(result) {
|
|
69
|
+
if (result.status === 'empty') {
|
|
70
|
+
return { tone: 'error', message: 'Complimentary access provider is not configured.' };
|
|
71
|
+
}
|
|
72
|
+
if (result.data.outcome === 'throttled') {
|
|
73
|
+
return {
|
|
74
|
+
tone: 'warning',
|
|
75
|
+
message: `Retry is not eligible yet.${eligibleSuffix(result.data.email.nextEligibleAt)}`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (result.data.outcome === 'conflict') {
|
|
79
|
+
return { tone: 'warning', message: 'Another email delivery is already in progress.' };
|
|
80
|
+
}
|
|
81
|
+
switch (result.data.email.status) {
|
|
82
|
+
case 'sent':
|
|
83
|
+
return { tone: 'success', message: 'Email resent.' };
|
|
84
|
+
case 'failed':
|
|
85
|
+
return { tone: 'warning', message: 'Email retry failed. Access remains active.' };
|
|
86
|
+
case 'unknown':
|
|
87
|
+
return { tone: 'warning', message: 'Email retry outcome is unknown. Access remains active.' };
|
|
88
|
+
case 'pending':
|
|
89
|
+
case 'sending':
|
|
90
|
+
return { tone: 'info', message: 'Email retry is in progress.' };
|
|
91
|
+
case 'not_attempted':
|
|
92
|
+
return { tone: 'info', message: 'No email retry was attempted.' };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export function complimentaryRowActions(row, nowUnixSeconds = Math.floor(Date.now() / 1000)) {
|
|
96
|
+
const active = row.revokedAt === null;
|
|
97
|
+
const nextEligibleAt = row.emailNextEligibleAt;
|
|
98
|
+
const nextEligibleAtValid = nextEligibleAt === null || isRenderableUnixSeconds(nextEligibleAt);
|
|
99
|
+
const retryWindowElapsed = nextEligibleAt === null || (nextEligibleAtValid && nextEligibleAt <= nowUnixSeconds);
|
|
100
|
+
const retryLabel = nextEligibleAt === null || retryWindowElapsed
|
|
101
|
+
? 'Retry email'
|
|
102
|
+
: nextEligibleAtValid
|
|
103
|
+
? `Retry after ${formatTimestamp(nextEligibleAt)}`
|
|
104
|
+
: 'Retry timing unavailable';
|
|
105
|
+
const canRetryEmail = active &&
|
|
106
|
+
row.emailRetryable &&
|
|
107
|
+
(row.emailStatus === 'failed' || row.emailStatus === 'unknown') &&
|
|
108
|
+
nextEligibleAtValid &&
|
|
109
|
+
retryWindowElapsed;
|
|
110
|
+
return {
|
|
111
|
+
canRevoke: active,
|
|
112
|
+
canRetryEmail,
|
|
113
|
+
retryLabel,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
export function complimentaryPageLabel(page) {
|
|
117
|
+
return `Page ${page.page} of ${page.totalPages} · ${page.total.toLocaleString('en-US')} records`;
|
|
118
|
+
}
|
|
119
|
+
export function formatTimestamp(value) {
|
|
120
|
+
if (!isRenderableUnixSeconds(value))
|
|
121
|
+
return 'Date unavailable';
|
|
122
|
+
try {
|
|
123
|
+
return new Date(value * 1000).toISOString().slice(0, 16).replace('T', ' ');
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return 'Date unavailable';
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function eligibleSuffix(nextEligibleAt) {
|
|
130
|
+
if (nextEligibleAt === null)
|
|
131
|
+
return '';
|
|
132
|
+
const formatted = formatTimestamp(nextEligibleAt);
|
|
133
|
+
return formatted === 'Date unavailable'
|
|
134
|
+
? ' Retry timing is unavailable.'
|
|
135
|
+
: ` Try again after ${formatted}.`;
|
|
136
|
+
}
|
|
137
|
+
function isRenderableUnixSeconds(value) {
|
|
138
|
+
return (Number.isInteger(value) &&
|
|
139
|
+
value >= 0 &&
|
|
140
|
+
value <= MAX_COMPLIMENTARY_UNIX_SECONDS &&
|
|
141
|
+
Number.isSafeInteger(value * 1000));
|
|
142
|
+
}
|
|
143
|
+
//# sourceMappingURL=subscriptions-data.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"subscriptions-data.js","sourceRoot":"","sources":["../../../src/ui/screens/subscriptions-data.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
package/src/engine/publisher.ts
CHANGED
|
@@ -471,23 +471,29 @@ async function ensureTags(db: D1Database, tags: string[]): Promise<{ id: string;
|
|
|
471
471
|
const results: { id: string; slug: string }[] = []
|
|
472
472
|
|
|
473
473
|
for (const tag of normalized) {
|
|
474
|
-
|
|
474
|
+
// Concurrency-safe upsert. A plain SELECT-then-INSERT races under parallel
|
|
475
|
+
// import shards: two shards can both miss the same shared tag slug and both
|
|
476
|
+
// INSERT it, and the second hits `UNIQUE constraint failed: content_tags.slug`
|
|
477
|
+
// (packages#320). `ON CONFLICT(slug) DO NOTHING` makes the insert idempotent —
|
|
478
|
+
// exactly one row wins, the loser is a no-op — then a single SELECT resolves
|
|
479
|
+
// the winning row's id (ours or the concurrent shard's). content_tag_links is
|
|
480
|
+
// keyed by tag_id, so shards converging on one shared tag id is correct.
|
|
481
|
+
await db
|
|
482
|
+
.prepare(
|
|
483
|
+
'INSERT INTO content_tags (id, slug, label) VALUES (?, ?, ?) ON CONFLICT(slug) DO NOTHING',
|
|
484
|
+
)
|
|
485
|
+
.bind(crypto.randomUUID(), tag, tag)
|
|
486
|
+
.run()
|
|
487
|
+
|
|
488
|
+
const row = await db
|
|
475
489
|
.prepare('SELECT id, slug FROM content_tags WHERE slug = ? LIMIT 1')
|
|
476
490
|
.bind(tag)
|
|
477
491
|
.first<{ id: string; slug: string }>()
|
|
478
492
|
|
|
479
|
-
if (
|
|
480
|
-
|
|
481
|
-
continue
|
|
493
|
+
if (!row) {
|
|
494
|
+
throw new Error(`content tag "${tag}" could not be resolved after upsert`)
|
|
482
495
|
}
|
|
483
|
-
|
|
484
|
-
const id = crypto.randomUUID()
|
|
485
|
-
await db
|
|
486
|
-
.prepare('INSERT INTO content_tags (id, slug, label) VALUES (?, ?, ?)')
|
|
487
|
-
.bind(id, tag, tag)
|
|
488
|
-
.run()
|
|
489
|
-
|
|
490
|
-
results.push({ id, slug: tag })
|
|
496
|
+
results.push(row)
|
|
491
497
|
}
|
|
492
498
|
|
|
493
499
|
return results
|
package/src/providers/null.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
SettingsProvider,
|
|
9
9
|
SubscriptionsProvider,
|
|
10
10
|
} from './types.js'
|
|
11
|
+
import { ProviderHttpError } from './types.js'
|
|
11
12
|
|
|
12
13
|
const empty = <T>(data: T) => ({ status: 'empty' as const, data })
|
|
13
14
|
|
|
@@ -77,9 +78,6 @@ export const nullSubscriptions: SubscriptionsProvider = {
|
|
|
77
78
|
async exportMembers() {
|
|
78
79
|
return empty('')
|
|
79
80
|
},
|
|
80
|
-
async createGift() {
|
|
81
|
-
return empty({ id: '' })
|
|
82
|
-
},
|
|
83
81
|
async detectDuplicates() {
|
|
84
82
|
return empty({
|
|
85
83
|
billingConflicts: [],
|
|
@@ -111,6 +109,37 @@ export const nullSubscriptions: SubscriptionsProvider = {
|
|
|
111
109
|
summary: { redeemed: 0, pendingClaim: 0 },
|
|
112
110
|
})
|
|
113
111
|
},
|
|
112
|
+
async listComplimentaryAccess(query) {
|
|
113
|
+
return empty({
|
|
114
|
+
rows: [],
|
|
115
|
+
total: 0,
|
|
116
|
+
page: query.page,
|
|
117
|
+
pageSize: query.pageSize,
|
|
118
|
+
totalPages: 0,
|
|
119
|
+
summary: { active: 0, revoked: 0, failedEmail: 0, unknownEmail: 0, notAttempted: 0 },
|
|
120
|
+
})
|
|
121
|
+
},
|
|
122
|
+
async grantComplimentaryAccess() {
|
|
123
|
+
throw new ProviderHttpError(
|
|
124
|
+
501,
|
|
125
|
+
'complimentary_provider_unavailable',
|
|
126
|
+
'Complimentary access provider not configured',
|
|
127
|
+
)
|
|
128
|
+
},
|
|
129
|
+
async revokeComplimentaryAccess() {
|
|
130
|
+
throw new ProviderHttpError(
|
|
131
|
+
501,
|
|
132
|
+
'complimentary_provider_unavailable',
|
|
133
|
+
'Complimentary access provider not configured',
|
|
134
|
+
)
|
|
135
|
+
},
|
|
136
|
+
async retryComplimentaryAccessEmail() {
|
|
137
|
+
throw new ProviderHttpError(
|
|
138
|
+
501,
|
|
139
|
+
'complimentary_provider_unavailable',
|
|
140
|
+
'Complimentary access provider not configured',
|
|
141
|
+
)
|
|
142
|
+
},
|
|
114
143
|
}
|
|
115
144
|
|
|
116
145
|
export const nullIdentity: IdentityProvider = {
|
package/src/providers/types.ts
CHANGED
|
@@ -182,6 +182,121 @@ export interface GiftOrdersPage {
|
|
|
182
182
|
}
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
export interface ProviderHttpErrorBody {
|
|
186
|
+
error: string
|
|
187
|
+
code: string
|
|
188
|
+
nextEligibleAt?: number | null
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export class ProviderHttpError extends Error {
|
|
192
|
+
readonly status: number
|
|
193
|
+
readonly code: string
|
|
194
|
+
readonly nextEligibleAt: number | null
|
|
195
|
+
|
|
196
|
+
constructor(status: number, code: string, message: string, nextEligibleAt: number | null = null) {
|
|
197
|
+
super(message)
|
|
198
|
+
this.name = 'ProviderHttpError'
|
|
199
|
+
this.status = status
|
|
200
|
+
this.code = code
|
|
201
|
+
this.nextEligibleAt = nextEligibleAt
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
toBody(): ProviderHttpErrorBody {
|
|
205
|
+
return {
|
|
206
|
+
error: this.message,
|
|
207
|
+
code: this.code,
|
|
208
|
+
...(this.nextEligibleAt === null ? {} : { nextEligibleAt: this.nextEligibleAt }),
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export type ComplimentaryDeliveryStatus = 'pending' | 'sending' | 'sent' | 'failed' | 'unknown'
|
|
214
|
+
export type ComplimentaryEmailStatus = ComplimentaryDeliveryStatus | 'not_attempted'
|
|
215
|
+
export type ComplimentaryEntitlementStatusFilter = 'active' | 'revoked' | 'all'
|
|
216
|
+
export const MAX_COMPLIMENTARY_UNIX_SECONDS = 253_402_300_799
|
|
217
|
+
|
|
218
|
+
export interface ComplimentaryEntitlementRow {
|
|
219
|
+
id: string
|
|
220
|
+
userId: string
|
|
221
|
+
email: string
|
|
222
|
+
grantReason: string
|
|
223
|
+
grantedAt: number
|
|
224
|
+
grantedBySubject: string
|
|
225
|
+
revokedAt: number | null
|
|
226
|
+
revokedBySubject: string | null
|
|
227
|
+
revokeReason: string | null
|
|
228
|
+
emailStatus: ComplimentaryEmailStatus
|
|
229
|
+
emailAttemptCount: number
|
|
230
|
+
emailRetryable: boolean
|
|
231
|
+
emailNextEligibleAt: number | null
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface ComplimentaryAccessQuery {
|
|
235
|
+
q?: string
|
|
236
|
+
status: ComplimentaryEntitlementStatusFilter
|
|
237
|
+
page: number
|
|
238
|
+
pageSize: number
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export interface ComplimentaryAccessPage {
|
|
242
|
+
rows: ComplimentaryEntitlementRow[]
|
|
243
|
+
total: number
|
|
244
|
+
page: number
|
|
245
|
+
pageSize: number
|
|
246
|
+
totalPages: number
|
|
247
|
+
summary: {
|
|
248
|
+
active: number
|
|
249
|
+
revoked: number
|
|
250
|
+
failedEmail: number
|
|
251
|
+
unknownEmail: number
|
|
252
|
+
notAttempted: number
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export interface TrustedActor {
|
|
257
|
+
subject: string
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface ComplimentaryGrantInput {
|
|
261
|
+
email: string
|
|
262
|
+
reason: string
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export interface ComplimentaryGrantResult {
|
|
266
|
+
outcome: 'created' | 'already_active'
|
|
267
|
+
entitlement: ComplimentaryEntitlementRow
|
|
268
|
+
email: {
|
|
269
|
+
status: ComplimentaryEmailStatus
|
|
270
|
+
retryable: boolean
|
|
271
|
+
nextEligibleAt: number | null
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export interface ComplimentaryRevokeInput {
|
|
276
|
+
entitlementId: string
|
|
277
|
+
reason: string
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export interface ComplimentaryRevokeResult {
|
|
281
|
+
outcome: 'revoked' | 'already_revoked'
|
|
282
|
+
entitlement: ComplimentaryEntitlementRow
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export interface ComplimentaryEmailRetryInput {
|
|
286
|
+
entitlementId: string
|
|
287
|
+
reason: string
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export interface ComplimentaryEmailRetryResult {
|
|
291
|
+
outcome: 'queued' | 'sent' | 'failed' | 'unknown' | 'throttled' | 'conflict'
|
|
292
|
+
entitlement: ComplimentaryEntitlementRow
|
|
293
|
+
email: {
|
|
294
|
+
status: ComplimentaryEmailStatus
|
|
295
|
+
retryable: boolean
|
|
296
|
+
nextEligibleAt: number | null
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
185
300
|
export interface SubscriptionsProvider {
|
|
186
301
|
listMembers(
|
|
187
302
|
filter: Record<string, string | undefined>,
|
|
@@ -191,10 +306,6 @@ export interface SubscriptionsProvider {
|
|
|
191
306
|
getPlanMix(): Promise<ProviderResult<Array<{ plan: string; count: number }>>>
|
|
192
307
|
resolvePlanName(variantId: string): string
|
|
193
308
|
exportMembers(fmt: 'csv' | 'json'): Promise<ProviderResult<string>>
|
|
194
|
-
createGift(input: {
|
|
195
|
-
email: string
|
|
196
|
-
planVariantId: string
|
|
197
|
-
}): Promise<ProviderResult<{ id: string }>>
|
|
198
309
|
/**
|
|
199
310
|
* Detect accounts that collide on billing email or canonical (gmail-folded)
|
|
200
311
|
* email — READ-only. Legacy: GET /admin/api/duplicate-accounts.
|
|
@@ -211,6 +322,25 @@ export interface SubscriptionsProvider {
|
|
|
211
322
|
* Legacy: GET /admin/api/gifts.
|
|
212
323
|
*/
|
|
213
324
|
listGifts(query: GiftOrdersQuery): Promise<ProviderResult<GiftOrdersPage>>
|
|
325
|
+
/** List current and historical complimentary access records. */
|
|
326
|
+
listComplimentaryAccess(
|
|
327
|
+
query: ComplimentaryAccessQuery,
|
|
328
|
+
): Promise<ProviderResult<ComplimentaryAccessPage>>
|
|
329
|
+
/** Grant complimentary access; the trusted actor is route-derived, never client-provided. */
|
|
330
|
+
grantComplimentaryAccess(
|
|
331
|
+
input: ComplimentaryGrantInput,
|
|
332
|
+
actor: TrustedActor,
|
|
333
|
+
): Promise<ProviderResult<ComplimentaryGrantResult>>
|
|
334
|
+
/** Revoke complimentary access; idempotent for already-revoked entitlements. */
|
|
335
|
+
revokeComplimentaryAccess(
|
|
336
|
+
input: ComplimentaryRevokeInput,
|
|
337
|
+
actor: TrustedActor,
|
|
338
|
+
): Promise<ProviderResult<ComplimentaryRevokeResult>>
|
|
339
|
+
/** Retry the stored recipient's complimentary-access email; recipient email is not accepted. */
|
|
340
|
+
retryComplimentaryAccessEmail(
|
|
341
|
+
input: ComplimentaryEmailRetryInput,
|
|
342
|
+
actor: TrustedActor,
|
|
343
|
+
): Promise<ProviderResult<ComplimentaryEmailRetryResult>>
|
|
214
344
|
}
|
|
215
345
|
|
|
216
346
|
/** Thin identity seam — the CMS never mints identity (spec §3.3/§12). */
|
|
@@ -45,6 +45,7 @@ export type { Capability }
|
|
|
45
45
|
// publish_others_draft, edit_own_draft (+ media_pipeline; producer too).
|
|
46
46
|
// senior_editor + owner (NOT editor): manage_team (members/invites),
|
|
47
47
|
// manage_api_keys, manage_webhooks, export_subscribers (PII).
|
|
48
|
+
// owner + senior_editor + editor: manage_complimentary_access.
|
|
48
49
|
// owner ONLY: manage_settings — workspace settings PATCH + the privileged
|
|
49
50
|
// LemonSqueezy entitlement sync (importLsEntitlements). Matches the 448e815
|
|
50
51
|
// gate (requireRole('manage_settings')).
|
|
@@ -71,6 +72,9 @@ export const CAPABILITY_MATRIX: Record<Capability, ReadonlySet<CmsRole>> = {
|
|
|
71
72
|
// Subscriber-PII export: owner + senior_editor only (NOT editor). Editors keep
|
|
72
73
|
// full editorial but never touch subscriber email lists.
|
|
73
74
|
export_subscribers: new Set<CmsRole>(['owner', 'senior_editor']),
|
|
75
|
+
// Complimentary access is an editorial admin action by explicit operator
|
|
76
|
+
// decision; it is not subscriber-PII export or owner-only settings.
|
|
77
|
+
manage_complimentary_access: new Set<CmsRole>(['owner', 'senior_editor', 'editor']),
|
|
74
78
|
}
|
|
75
79
|
|
|
76
80
|
// ---------------------------------------------------------------------------
|
package/src/routes/context.ts
CHANGED
|
@@ -35,6 +35,9 @@ export type Capability =
|
|
|
35
35
|
// Subscriber-PII export (CSV/JSON of member emails). Gated to owner +
|
|
36
36
|
// senior_editor — NOT editor (editors keep full editorial but no PII).
|
|
37
37
|
| 'export_subscribers'
|
|
38
|
+
// Complimentary access administration. Explicit operator decision: owner,
|
|
39
|
+
// senior_editor, and editor may grant/revoke/retry; contributor/producer may not.
|
|
40
|
+
| 'manage_complimentary_access'
|
|
38
41
|
|
|
39
42
|
export interface CmsUser {
|
|
40
43
|
subject: string
|