@growth-labs/cms 0.5.2 → 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.
Files changed (39) hide show
  1. package/README.md +10 -2
  2. package/dist/providers/null.d.ts.map +1 -1
  3. package/dist/providers/null.js +20 -3
  4. package/dist/providers/null.js.map +1 -1
  5. package/dist/providers/types.d.ts +96 -6
  6. package/dist/providers/types.d.ts.map +1 -1
  7. package/dist/providers/types.js +20 -1
  8. package/dist/providers/types.js.map +1 -1
  9. package/dist/routes/authz-matrix.d.ts.map +1 -1
  10. package/dist/routes/authz-matrix.js +4 -0
  11. package/dist/routes/authz-matrix.js.map +1 -1
  12. package/dist/routes/context.d.ts +1 -1
  13. package/dist/routes/context.d.ts.map +1 -1
  14. package/dist/routes/context.js.map +1 -1
  15. package/dist/routes/subscriptions.d.ts +9 -2
  16. package/dist/routes/subscriptions.d.ts.map +1 -1
  17. package/dist/routes/subscriptions.js +196 -21
  18. package/dist/routes/subscriptions.js.map +1 -1
  19. package/dist/ui/api/subscriptions.d.ts +1 -1
  20. package/dist/ui/api/subscriptions.d.ts.map +1 -1
  21. package/dist/ui/api/subscriptions.js +27 -5
  22. package/dist/ui/api/subscriptions.js.map +1 -1
  23. package/dist/ui/screens/SubscriptionsScreen.d.ts +44 -0
  24. package/dist/ui/screens/SubscriptionsScreen.d.ts.map +1 -1
  25. package/dist/ui/screens/SubscriptionsScreen.js +262 -54
  26. package/dist/ui/screens/SubscriptionsScreen.js.map +1 -1
  27. package/dist/ui/screens/subscriptions-data.d.ts +33 -0
  28. package/dist/ui/screens/subscriptions-data.d.ts.map +1 -0
  29. package/dist/ui/screens/subscriptions-data.js +143 -0
  30. package/dist/ui/screens/subscriptions-data.js.map +1 -0
  31. package/package.json +1 -1
  32. package/src/providers/null.ts +32 -3
  33. package/src/providers/types.ts +134 -4
  34. package/src/routes/authz-matrix.ts +4 -0
  35. package/src/routes/context.ts +3 -0
  36. package/src/routes/subscriptions.ts +249 -27
  37. package/src/ui/api/subscriptions.ts +27 -5
  38. package/src/ui/screens/SubscriptionsScreen.tsx +631 -116
  39. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growth-labs/cms",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "growth-labs-cms-migrations": "./dist/cli/migrations.js"
@@ -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 = {
@@ -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
  // ---------------------------------------------------------------------------
@@ -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