@growth-labs/cms 0.5.2 → 0.5.4

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 +93 -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 +202 -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 +131 -4
  34. package/src/routes/authz-matrix.ts +4 -0
  35. package/src/routes/context.ts +3 -0
  36. package/src/routes/subscriptions.ts +269 -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.4",
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,118 @@ 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
+ email: string
221
+ grantReason: string
222
+ grantedAt: number
223
+ revokedAt: number | null
224
+ revokeReason: string | null
225
+ emailStatus: ComplimentaryEmailStatus
226
+ emailAttemptCount: number
227
+ emailRetryable: boolean
228
+ emailNextEligibleAt: number | null
229
+ }
230
+
231
+ export interface ComplimentaryAccessQuery {
232
+ q?: string
233
+ status: ComplimentaryEntitlementStatusFilter
234
+ page: number
235
+ pageSize: number
236
+ }
237
+
238
+ export interface ComplimentaryAccessPage {
239
+ rows: ComplimentaryEntitlementRow[]
240
+ total: number
241
+ page: number
242
+ pageSize: number
243
+ totalPages: number
244
+ summary: {
245
+ active: number
246
+ revoked: number
247
+ failedEmail: number
248
+ unknownEmail: number
249
+ notAttempted: number
250
+ }
251
+ }
252
+
253
+ export interface TrustedActor {
254
+ subject: string
255
+ }
256
+
257
+ export interface ComplimentaryGrantInput {
258
+ email: string
259
+ reason: string
260
+ }
261
+
262
+ export interface ComplimentaryGrantResult {
263
+ outcome: 'created' | 'already_active'
264
+ entitlement: ComplimentaryEntitlementRow
265
+ email: {
266
+ status: ComplimentaryEmailStatus
267
+ retryable: boolean
268
+ nextEligibleAt: number | null
269
+ }
270
+ }
271
+
272
+ export interface ComplimentaryRevokeInput {
273
+ entitlementId: string
274
+ reason: string
275
+ }
276
+
277
+ export interface ComplimentaryRevokeResult {
278
+ outcome: 'revoked' | 'already_revoked'
279
+ entitlement: ComplimentaryEntitlementRow
280
+ }
281
+
282
+ export interface ComplimentaryEmailRetryInput {
283
+ entitlementId: string
284
+ reason: string
285
+ }
286
+
287
+ export interface ComplimentaryEmailRetryResult {
288
+ outcome: 'queued' | 'sent' | 'failed' | 'unknown' | 'throttled' | 'conflict'
289
+ entitlement: ComplimentaryEntitlementRow
290
+ email: {
291
+ status: ComplimentaryEmailStatus
292
+ retryable: boolean
293
+ nextEligibleAt: number | null
294
+ }
295
+ }
296
+
185
297
  export interface SubscriptionsProvider {
186
298
  listMembers(
187
299
  filter: Record<string, string | undefined>,
@@ -191,10 +303,6 @@ export interface SubscriptionsProvider {
191
303
  getPlanMix(): Promise<ProviderResult<Array<{ plan: string; count: number }>>>
192
304
  resolvePlanName(variantId: string): string
193
305
  exportMembers(fmt: 'csv' | 'json'): Promise<ProviderResult<string>>
194
- createGift(input: {
195
- email: string
196
- planVariantId: string
197
- }): Promise<ProviderResult<{ id: string }>>
198
306
  /**
199
307
  * Detect accounts that collide on billing email or canonical (gmail-folded)
200
308
  * email — READ-only. Legacy: GET /admin/api/duplicate-accounts.
@@ -211,6 +319,25 @@ export interface SubscriptionsProvider {
211
319
  * Legacy: GET /admin/api/gifts.
212
320
  */
213
321
  listGifts(query: GiftOrdersQuery): Promise<ProviderResult<GiftOrdersPage>>
322
+ /** List current and historical complimentary access records. */
323
+ listComplimentaryAccess(
324
+ query: ComplimentaryAccessQuery,
325
+ ): Promise<ProviderResult<ComplimentaryAccessPage>>
326
+ /** Grant complimentary access; the trusted actor is route-derived, never client-provided. */
327
+ grantComplimentaryAccess(
328
+ input: ComplimentaryGrantInput,
329
+ actor: TrustedActor,
330
+ ): Promise<ProviderResult<ComplimentaryGrantResult>>
331
+ /** Revoke complimentary access; idempotent for already-revoked entitlements. */
332
+ revokeComplimentaryAccess(
333
+ input: ComplimentaryRevokeInput,
334
+ actor: TrustedActor,
335
+ ): Promise<ProviderResult<ComplimentaryRevokeResult>>
336
+ /** Retry the stored recipient's complimentary-access email; recipient email is not accepted. */
337
+ retryComplimentaryAccessEmail(
338
+ input: ComplimentaryEmailRetryInput,
339
+ actor: TrustedActor,
340
+ ): Promise<ProviderResult<ComplimentaryEmailRetryResult>>
214
341
  }
215
342
 
216
343
  /** 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