@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
@@ -1,6 +1,6 @@
1
1
  // src/routes/subscriptions.ts — Subscriptions route factory (P5 Task 6 + admin parity).
2
2
  // All handlers are SubscriptionsProvider passthrough: member list+filters,
3
- // KPI cards, plan-mix, CSV/JSON export, gift subscription, plus the three
3
+ // KPI cards, plan-mix, CSV/JSON export, complimentary access admin, plus the three
4
4
  // admin-parity tools the legacy Fronts /admin had — duplicate-account detection,
5
5
  // gift-order tracking, and the LemonSqueezy entitlement sync (a privileged WRITE).
6
6
  // Each handler returns the ProviderResult {status, data} envelope unchanged —
@@ -13,9 +13,23 @@
13
13
  // NOT editor), falling back to requirePublisher when the role seam is absent.
14
14
  // importLsEntitlements is a privileged WRITE → requireRole('manage_settings')
15
15
  // (owner-only), falling back to requireAdmin when the role seam is absent.
16
+ // complimentary access list/grant/revoke/retry → requireRole('manage_complimentary_access')
17
+ // (owner + senior_editor + editor). Mutations derive the actor from authz.getUser;
18
+ // actor-like fields in the browser body are rejected.
16
19
 
20
+ import { z } from 'zod'
17
21
  import { nullProviders } from '../providers/null.js'
18
- import type { CmsProviders } from '../providers/types.js'
22
+ import {
23
+ type CmsProviders,
24
+ type ComplimentaryAccessPage,
25
+ type ComplimentaryAccessQuery,
26
+ type ComplimentaryEmailRetryResult,
27
+ type ComplimentaryGrantResult,
28
+ type ComplimentaryRevokeResult,
29
+ MAX_COMPLIMENTARY_UNIX_SECONDS,
30
+ ProviderHttpError,
31
+ type ProviderResult,
32
+ } from '../providers/types.js'
19
33
  import type { Authz, Capability, RouteContext } from './context.js'
20
34
  import { json } from './context.js'
21
35
 
@@ -29,15 +43,193 @@ export interface SubscriptionsRouteHandlers {
29
43
  kpis(ctx: RouteContext): Promise<Response>
30
44
  planMix(ctx: RouteContext): Promise<Response>
31
45
  exportMembers(ctx: RouteContext): Promise<Response>
32
- gift(ctx: RouteContext): Promise<Response>
33
46
  /** READ — duplicate-account conflict groups (billing + canonical email). */
34
47
  detectDuplicates(ctx: RouteContext): Promise<Response>
35
48
  /** READ — paginated gift-order claim/delivery tracking. */
36
49
  listGifts(ctx: RouteContext): Promise<Response>
50
+ /** READ — paginated current + historical complimentary access audit. */
51
+ listComplimentaryAccess(ctx: RouteContext): Promise<Response>
52
+ /** WRITE — grant complimentary access. */
53
+ grantComplimentaryAccess(ctx: RouteContext): Promise<Response>
54
+ /** WRITE — revoke complimentary access. */
55
+ revokeComplimentaryAccess(ctx: RouteContext): Promise<Response>
56
+ /** WRITE — retry the stored recipient's delivery email. */
57
+ retryComplimentaryAccessEmail(ctx: RouteContext): Promise<Response>
37
58
  /** WRITE (privileged) — trigger the LemonSqueezy entitlement sync. */
38
59
  importLsEntitlements(ctx: RouteContext): Promise<Response>
39
60
  }
40
61
 
62
+ const providerStatusSchema = z.enum(['live', 'partial', 'empty'])
63
+ const deliveryStatusSchema = z.enum(['pending', 'sending', 'sent', 'failed', 'unknown'])
64
+ const emailStatusSchema = z.union([deliveryStatusSchema, z.literal('not_attempted')])
65
+ const statusFilterSchema = z.enum(['active', 'revoked', 'all'])
66
+ const unixSecondsSchema = z.number().int().nonnegative().max(MAX_COMPLIMENTARY_UNIX_SECONDS)
67
+
68
+ function stripInternalEntitlementIdentifiers(value: unknown): unknown {
69
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return value
70
+ const publicValue = { ...value } as Record<string, unknown>
71
+ delete publicValue.userId
72
+ delete publicValue.grantedBySubject
73
+ delete publicValue.revokedBySubject
74
+ return publicValue
75
+ }
76
+
77
+ const entitlementRowSchema = z.preprocess(
78
+ stripInternalEntitlementIdentifiers,
79
+ z
80
+ .object({
81
+ id: z.string().uuid(),
82
+ email: z.string().email(),
83
+ grantReason: z.string().min(1),
84
+ grantedAt: unixSecondsSchema,
85
+ revokedAt: unixSecondsSchema.nullable(),
86
+ revokeReason: z.string().min(1).nullable(),
87
+ emailStatus: emailStatusSchema,
88
+ emailAttemptCount: z.number().int().nonnegative(),
89
+ emailRetryable: z.boolean(),
90
+ emailNextEligibleAt: unixSecondsSchema.nullable(),
91
+ })
92
+ .strict(),
93
+ )
94
+
95
+ const complimentaryPageSchema: z.ZodType<ComplimentaryAccessPage, z.ZodTypeDef, unknown> = z
96
+ .object({
97
+ rows: z.array(entitlementRowSchema),
98
+ total: z.number().int().nonnegative(),
99
+ page: z.number().int().positive(),
100
+ pageSize: z.number().int().min(1).max(100),
101
+ totalPages: z.number().int().nonnegative(),
102
+ summary: z
103
+ .object({
104
+ active: z.number().int().nonnegative(),
105
+ revoked: z.number().int().nonnegative(),
106
+ failedEmail: z.number().int().nonnegative(),
107
+ unknownEmail: z.number().int().nonnegative(),
108
+ notAttempted: z.number().int().nonnegative(),
109
+ })
110
+ .strict(),
111
+ })
112
+ .strict()
113
+
114
+ const emailResultSchema = z
115
+ .object({
116
+ status: emailStatusSchema,
117
+ retryable: z.boolean(),
118
+ nextEligibleAt: unixSecondsSchema.nullable(),
119
+ })
120
+ .strict()
121
+
122
+ const complimentaryGrantResultSchema: z.ZodType<ComplimentaryGrantResult, z.ZodTypeDef, unknown> = z
123
+ .object({
124
+ outcome: z.enum(['created', 'already_active']),
125
+ entitlement: entitlementRowSchema,
126
+ email: emailResultSchema,
127
+ })
128
+ .strict()
129
+
130
+ const complimentaryRevokeResultSchema: z.ZodType<ComplimentaryRevokeResult, z.ZodTypeDef, unknown> =
131
+ z
132
+ .object({
133
+ outcome: z.enum(['revoked', 'already_revoked']),
134
+ entitlement: entitlementRowSchema,
135
+ })
136
+ .strict()
137
+
138
+ const complimentaryRetryResultSchema: z.ZodType<
139
+ ComplimentaryEmailRetryResult,
140
+ z.ZodTypeDef,
141
+ unknown
142
+ > = z
143
+ .object({
144
+ outcome: z.enum(['queued', 'sent', 'failed', 'unknown', 'throttled', 'conflict']),
145
+ entitlement: entitlementRowSchema,
146
+ email: emailResultSchema,
147
+ })
148
+ .strict()
149
+
150
+ const emailInputSchema = z.string().trim().toLowerCase().email().max(254)
151
+ const reasonSchema = z.string().trim().min(1).max(500)
152
+ const complimentaryQuerySchema = z.string().trim().max(254)
153
+ const entitlementIdSchema = z.string().trim().uuid()
154
+
155
+ const grantSchema = z
156
+ .object({
157
+ email: emailInputSchema,
158
+ reason: reasonSchema,
159
+ })
160
+ .strict()
161
+
162
+ const entitlementActionSchema = z
163
+ .object({
164
+ entitlementId: entitlementIdSchema,
165
+ reason: reasonSchema,
166
+ })
167
+ .strict()
168
+
169
+ function providerResultSchema<T>(
170
+ data: z.ZodType<T, z.ZodTypeDef, unknown>,
171
+ ): z.ZodType<ProviderResult<T>, z.ZodTypeDef, unknown> {
172
+ return z.object({ status: providerStatusSchema, data }).strict() as z.ZodType<
173
+ ProviderResult<T>,
174
+ z.ZodTypeDef,
175
+ unknown
176
+ >
177
+ }
178
+
179
+ function parsePositiveInteger(value: string | null, fallback: number): number {
180
+ if (value === null) return fallback
181
+ const parsed = Number(value)
182
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
183
+ }
184
+
185
+ type StrictJsonResult<T> = { ok: true; data: T } | { ok: false; response: Response }
186
+
187
+ async function readStrictJson<T>(
188
+ ctx: RouteContext,
189
+ schema: z.ZodType<T>,
190
+ ): Promise<StrictJsonResult<T>> {
191
+ let body: unknown
192
+ try {
193
+ body = await ctx.request.json()
194
+ } catch {
195
+ return { ok: false, response: json({ error: 'Invalid JSON body' }, 400) }
196
+ }
197
+ const parsed = schema.safeParse(body)
198
+ if (!parsed.success) return { ok: false, response: json({ error: 'Invalid request body' }, 400) }
199
+ return { ok: true, data: parsed.data }
200
+ }
201
+
202
+ async function requireComplimentaryAccess(
203
+ config: SubscriptionsRouteConfig,
204
+ ctx: RouteContext,
205
+ ): Promise<Response | null> {
206
+ if (!config.authz.requireRole) {
207
+ return json({ error: 'Role authorization is required' }, 500)
208
+ }
209
+ return config.authz.requireRole('manage_complimentary_access' satisfies Capability, ctx)
210
+ }
211
+
212
+ async function trustedActor(config: SubscriptionsRouteConfig, ctx: RouteContext) {
213
+ const user = config.authz.getUser ? await config.authz.getUser(ctx) : null
214
+ if (!user?.subject) return null
215
+ return { subject: user.subject }
216
+ }
217
+
218
+ async function providerJson<T>(
219
+ call: () => Promise<ProviderResult<T>>,
220
+ schema: z.ZodType<ProviderResult<T>, z.ZodTypeDef, unknown>,
221
+ ): Promise<Response> {
222
+ try {
223
+ const result = await call()
224
+ const parsed = schema.safeParse(result)
225
+ if (!parsed.success) return json({ error: 'Invalid provider response' }, 502)
226
+ return json(parsed.data)
227
+ } catch (err) {
228
+ if (err instanceof ProviderHttpError) return json(err.toBody(), err.status)
229
+ throw err
230
+ }
231
+ }
232
+
41
233
  export function createSubscriptionsRoutes(
42
234
  config: SubscriptionsRouteConfig,
43
235
  ): SubscriptionsRouteHandlers {
@@ -93,30 +285,6 @@ export function createSubscriptionsRoutes(
93
285
  return json(await providers.subscriptions.exportMembers(fmt))
94
286
  },
95
287
 
96
- async gift(ctx: RouteContext): Promise<Response> {
97
- const denied = await config.authz.requirePublisher(ctx)
98
- if (denied) return denied
99
-
100
- let body: unknown
101
- try {
102
- body = await ctx.request.json()
103
- } catch {
104
- return json({ error: 'Invalid JSON body' }, 400)
105
- }
106
-
107
- const { email, planVariantId } =
108
- typeof body === 'object' && body !== null ? (body as Record<string, unknown>) : {}
109
-
110
- if (typeof email !== 'string' || !email) {
111
- return json({ error: 'email is required' }, 400)
112
- }
113
- if (typeof planVariantId !== 'string' || !planVariantId) {
114
- return json({ error: 'planVariantId is required' }, 400)
115
- }
116
-
117
- return json(await providers.subscriptions.createGift({ email, planVariantId }))
118
- },
119
-
120
288
  // READ — duplicate-account detection. Same publisher gate as the other
121
289
  // read surfaces; the ProviderResult (conflict groups + summary) passes
122
290
  // straight through, empty states preserved.
@@ -143,6 +311,80 @@ export function createSubscriptionsRoutes(
143
311
  return json(await providers.subscriptions.listGifts({ q, status, page, pageSize }))
144
312
  },
145
313
 
314
+ async listComplimentaryAccess(ctx: RouteContext): Promise<Response> {
315
+ const denied = await requireComplimentaryAccess(config, ctx)
316
+ if (denied) return denied
317
+
318
+ const u = new URL(ctx.request.url)
319
+ const qParsed = complimentaryQuerySchema.safeParse(u.searchParams.get('q') ?? '')
320
+ if (!qParsed.success) return json({ error: 'Invalid search query' }, 400)
321
+ const q = qParsed.data || undefined
322
+ const rawStatus = u.searchParams.get('status') ?? 'all'
323
+ const statusParsed = statusFilterSchema.safeParse(rawStatus)
324
+ if (!statusParsed.success) return json({ error: 'Invalid status filter' }, 400)
325
+ const page = parsePositiveInteger(u.searchParams.get('page'), 1)
326
+ const pageSize = Math.min(parsePositiveInteger(u.searchParams.get('pageSize'), 25), 100)
327
+ const query: ComplimentaryAccessQuery = {
328
+ q,
329
+ status: statusParsed.data,
330
+ page,
331
+ pageSize,
332
+ }
333
+
334
+ return providerJson(
335
+ () => providers.subscriptions.listComplimentaryAccess(query),
336
+ providerResultSchema(complimentaryPageSchema),
337
+ )
338
+ },
339
+
340
+ async grantComplimentaryAccess(ctx: RouteContext): Promise<Response> {
341
+ const denied = await requireComplimentaryAccess(config, ctx)
342
+ if (denied) return denied
343
+
344
+ const actor = await trustedActor(config, ctx)
345
+ if (!actor) return json({ error: 'Trusted actor context is required' }, 500)
346
+
347
+ const parsed = await readStrictJson(ctx, grantSchema)
348
+ if (!parsed.ok) return parsed.response
349
+
350
+ return providerJson(
351
+ () => providers.subscriptions.grantComplimentaryAccess(parsed.data, actor),
352
+ providerResultSchema(complimentaryGrantResultSchema),
353
+ )
354
+ },
355
+
356
+ async revokeComplimentaryAccess(ctx: RouteContext): Promise<Response> {
357
+ const denied = await requireComplimentaryAccess(config, ctx)
358
+ if (denied) return denied
359
+
360
+ const actor = await trustedActor(config, ctx)
361
+ if (!actor) return json({ error: 'Trusted actor context is required' }, 500)
362
+
363
+ const parsed = await readStrictJson(ctx, entitlementActionSchema)
364
+ if (!parsed.ok) return parsed.response
365
+
366
+ return providerJson(
367
+ () => providers.subscriptions.revokeComplimentaryAccess(parsed.data, actor),
368
+ providerResultSchema(complimentaryRevokeResultSchema),
369
+ )
370
+ },
371
+
372
+ async retryComplimentaryAccessEmail(ctx: RouteContext): Promise<Response> {
373
+ const denied = await requireComplimentaryAccess(config, ctx)
374
+ if (denied) return denied
375
+
376
+ const actor = await trustedActor(config, ctx)
377
+ if (!actor) return json({ error: 'Trusted actor context is required' }, 500)
378
+
379
+ const parsed = await readStrictJson(ctx, entitlementActionSchema)
380
+ if (!parsed.ok) return parsed.response
381
+
382
+ return providerJson(
383
+ () => providers.subscriptions.retryComplimentaryAccessEmail(parsed.data, actor),
384
+ providerResultSchema(complimentaryRetryResultSchema),
385
+ )
386
+ },
387
+
146
388
  // WRITE (privileged) — trigger the LemonSqueezy entitlement sync. This
147
389
  // mutates host state (creates/updates users + subscriptions), so it gates
148
390
  // on requireRole('manage_settings') (owner-only per the capability matrix)
@@ -1,7 +1,7 @@
1
1
  // src/ui/api/subscriptions.ts — Astro endpoint adapter for /admin/api/subscriptions.
2
- // GET dispatches to members / kpis / planMix / exportMembers / duplicates / gifts
3
- // based on ?type=. POST routes to gift (default) or the LemonSqueezy import sync
4
- // (?type=import-ls) a privileged WRITE gated on requireRole('manage_settings').
2
+ // GET dispatches to members / kpis / planMix / exportMembers / duplicates / gifts /
3
+ // complimentary based on ?type=. POST routes LemonSqueezy import sync
4
+ // (?type=import-ls) or complimentary grant/revoke/retry actions.
5
5
  // Providers (SubscriptionsProvider) are read from c.locals.cmsProviders — the host
6
6
  // injects real impls at cutover (P8); null impl (empty states) is the default.
7
7
 
@@ -42,6 +42,8 @@ export const GET = (c: APIContext) => {
42
42
  return routes.detectDuplicates(ctx)
43
43
  case 'gifts':
44
44
  return routes.listGifts(ctx)
45
+ case 'complimentary':
46
+ return routes.listComplimentaryAccess(ctx)
45
47
  default:
46
48
  return routes.members(ctx)
47
49
  }
@@ -50,7 +52,27 @@ export const GET = (c: APIContext) => {
50
52
  export const POST = (c: APIContext) => {
51
53
  const routes = createSubscriptionsRoutes(subscriptionsConfig(c))
52
54
  const ctx = toCtx(c)
53
- const type = new URL(c.request.url).searchParams.get('type')
55
+ const url = new URL(c.request.url)
56
+ const type = url.searchParams.get('type')
54
57
  if (type === 'import-ls') return routes.importLsEntitlements(ctx)
55
- return routes.gift(ctx)
58
+ if (type === 'complimentary') {
59
+ const action = url.searchParams.get('action')
60
+ switch (action) {
61
+ case 'grant':
62
+ return routes.grantComplimentaryAccess(ctx)
63
+ case 'revoke':
64
+ return routes.revokeComplimentaryAccess(ctx)
65
+ case 'retry-email':
66
+ return routes.retryComplimentaryAccessEmail(ctx)
67
+ default:
68
+ return new Response(JSON.stringify({ error: 'Invalid complimentary action' }), {
69
+ status: 400,
70
+ headers: { 'Content-Type': 'application/json' },
71
+ })
72
+ }
73
+ }
74
+ return new Response(JSON.stringify({ error: 'Invalid subscription action' }), {
75
+ status: 400,
76
+ headers: { 'Content-Type': 'application/json' },
77
+ })
56
78
  }