@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
@@ -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,173 @@ 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
+ const entitlementRowSchema = z
69
+ .object({
70
+ id: z.string().uuid(),
71
+ userId: z.string().min(1),
72
+ email: z.string().email(),
73
+ grantReason: z.string().min(1),
74
+ grantedAt: unixSecondsSchema,
75
+ grantedBySubject: z.string().min(1),
76
+ revokedAt: unixSecondsSchema.nullable(),
77
+ revokedBySubject: z.string().min(1).nullable(),
78
+ revokeReason: z.string().min(1).nullable(),
79
+ emailStatus: emailStatusSchema,
80
+ emailAttemptCount: z.number().int().nonnegative(),
81
+ emailRetryable: z.boolean(),
82
+ emailNextEligibleAt: unixSecondsSchema.nullable(),
83
+ })
84
+ .strict()
85
+
86
+ const complimentaryPageSchema: z.ZodType<ComplimentaryAccessPage> = z
87
+ .object({
88
+ rows: z.array(entitlementRowSchema),
89
+ total: z.number().int().nonnegative(),
90
+ page: z.number().int().positive(),
91
+ pageSize: z.number().int().min(1).max(100),
92
+ totalPages: z.number().int().nonnegative(),
93
+ summary: z
94
+ .object({
95
+ active: z.number().int().nonnegative(),
96
+ revoked: z.number().int().nonnegative(),
97
+ failedEmail: z.number().int().nonnegative(),
98
+ unknownEmail: z.number().int().nonnegative(),
99
+ notAttempted: z.number().int().nonnegative(),
100
+ })
101
+ .strict(),
102
+ })
103
+ .strict()
104
+
105
+ const emailResultSchema = z
106
+ .object({
107
+ status: emailStatusSchema,
108
+ retryable: z.boolean(),
109
+ nextEligibleAt: unixSecondsSchema.nullable(),
110
+ })
111
+ .strict()
112
+
113
+ const complimentaryGrantResultSchema: z.ZodType<ComplimentaryGrantResult> = z
114
+ .object({
115
+ outcome: z.enum(['created', 'already_active']),
116
+ entitlement: entitlementRowSchema,
117
+ email: emailResultSchema,
118
+ })
119
+ .strict()
120
+
121
+ const complimentaryRevokeResultSchema: z.ZodType<ComplimentaryRevokeResult> = z
122
+ .object({
123
+ outcome: z.enum(['revoked', 'already_revoked']),
124
+ entitlement: entitlementRowSchema,
125
+ })
126
+ .strict()
127
+
128
+ const complimentaryRetryResultSchema: z.ZodType<ComplimentaryEmailRetryResult> = z
129
+ .object({
130
+ outcome: z.enum(['queued', 'sent', 'failed', 'unknown', 'throttled', 'conflict']),
131
+ entitlement: entitlementRowSchema,
132
+ email: emailResultSchema,
133
+ })
134
+ .strict()
135
+
136
+ const emailInputSchema = z.string().trim().toLowerCase().email().max(254)
137
+ const reasonSchema = z.string().trim().min(1).max(500)
138
+ const complimentaryQuerySchema = z.string().trim().max(254)
139
+ const entitlementIdSchema = z.string().trim().uuid()
140
+
141
+ const grantSchema = z
142
+ .object({
143
+ email: emailInputSchema,
144
+ reason: reasonSchema,
145
+ })
146
+ .strict()
147
+
148
+ const entitlementActionSchema = z
149
+ .object({
150
+ entitlementId: entitlementIdSchema,
151
+ reason: reasonSchema,
152
+ })
153
+ .strict()
154
+
155
+ function providerResultSchema<T>(data: z.ZodType<T>): z.ZodType<ProviderResult<T>> {
156
+ return z.object({ status: providerStatusSchema, data }).strict() as z.ZodType<ProviderResult<T>>
157
+ }
158
+
159
+ function parsePositiveInteger(value: string | null, fallback: number): number {
160
+ if (value === null) return fallback
161
+ const parsed = Number(value)
162
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
163
+ }
164
+
165
+ type StrictJsonResult<T> = { ok: true; data: T } | { ok: false; response: Response }
166
+
167
+ async function readStrictJson<T>(
168
+ ctx: RouteContext,
169
+ schema: z.ZodType<T>,
170
+ ): Promise<StrictJsonResult<T>> {
171
+ let body: unknown
172
+ try {
173
+ body = await ctx.request.json()
174
+ } catch {
175
+ return { ok: false, response: json({ error: 'Invalid JSON body' }, 400) }
176
+ }
177
+ const parsed = schema.safeParse(body)
178
+ if (!parsed.success) return { ok: false, response: json({ error: 'Invalid request body' }, 400) }
179
+ return { ok: true, data: parsed.data }
180
+ }
181
+
182
+ async function requireComplimentaryAccess(
183
+ config: SubscriptionsRouteConfig,
184
+ ctx: RouteContext,
185
+ ): Promise<Response | null> {
186
+ if (!config.authz.requireRole) {
187
+ return json({ error: 'Role authorization is required' }, 500)
188
+ }
189
+ return config.authz.requireRole('manage_complimentary_access' satisfies Capability, ctx)
190
+ }
191
+
192
+ async function trustedActor(config: SubscriptionsRouteConfig, ctx: RouteContext) {
193
+ const user = config.authz.getUser ? await config.authz.getUser(ctx) : null
194
+ if (!user?.subject) return null
195
+ return { subject: user.subject }
196
+ }
197
+
198
+ async function providerJson<T>(
199
+ call: () => Promise<ProviderResult<T>>,
200
+ schema: z.ZodType<ProviderResult<T>>,
201
+ ): Promise<Response> {
202
+ try {
203
+ const result = await call()
204
+ const parsed = schema.safeParse(result)
205
+ if (!parsed.success) return json({ error: 'Invalid provider response' }, 502)
206
+ return json(parsed.data)
207
+ } catch (err) {
208
+ if (err instanceof ProviderHttpError) return json(err.toBody(), err.status)
209
+ throw err
210
+ }
211
+ }
212
+
41
213
  export function createSubscriptionsRoutes(
42
214
  config: SubscriptionsRouteConfig,
43
215
  ): SubscriptionsRouteHandlers {
@@ -93,30 +265,6 @@ export function createSubscriptionsRoutes(
93
265
  return json(await providers.subscriptions.exportMembers(fmt))
94
266
  },
95
267
 
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
268
  // READ — duplicate-account detection. Same publisher gate as the other
121
269
  // read surfaces; the ProviderResult (conflict groups + summary) passes
122
270
  // straight through, empty states preserved.
@@ -143,6 +291,80 @@ export function createSubscriptionsRoutes(
143
291
  return json(await providers.subscriptions.listGifts({ q, status, page, pageSize }))
144
292
  },
145
293
 
294
+ async listComplimentaryAccess(ctx: RouteContext): Promise<Response> {
295
+ const denied = await requireComplimentaryAccess(config, ctx)
296
+ if (denied) return denied
297
+
298
+ const u = new URL(ctx.request.url)
299
+ const qParsed = complimentaryQuerySchema.safeParse(u.searchParams.get('q') ?? '')
300
+ if (!qParsed.success) return json({ error: 'Invalid search query' }, 400)
301
+ const q = qParsed.data || undefined
302
+ const rawStatus = u.searchParams.get('status') ?? 'all'
303
+ const statusParsed = statusFilterSchema.safeParse(rawStatus)
304
+ if (!statusParsed.success) return json({ error: 'Invalid status filter' }, 400)
305
+ const page = parsePositiveInteger(u.searchParams.get('page'), 1)
306
+ const pageSize = Math.min(parsePositiveInteger(u.searchParams.get('pageSize'), 25), 100)
307
+ const query: ComplimentaryAccessQuery = {
308
+ q,
309
+ status: statusParsed.data,
310
+ page,
311
+ pageSize,
312
+ }
313
+
314
+ return providerJson(
315
+ () => providers.subscriptions.listComplimentaryAccess(query),
316
+ providerResultSchema(complimentaryPageSchema),
317
+ )
318
+ },
319
+
320
+ async grantComplimentaryAccess(ctx: RouteContext): Promise<Response> {
321
+ const denied = await requireComplimentaryAccess(config, ctx)
322
+ if (denied) return denied
323
+
324
+ const actor = await trustedActor(config, ctx)
325
+ if (!actor) return json({ error: 'Trusted actor context is required' }, 500)
326
+
327
+ const parsed = await readStrictJson(ctx, grantSchema)
328
+ if (!parsed.ok) return parsed.response
329
+
330
+ return providerJson(
331
+ () => providers.subscriptions.grantComplimentaryAccess(parsed.data, actor),
332
+ providerResultSchema(complimentaryGrantResultSchema),
333
+ )
334
+ },
335
+
336
+ async revokeComplimentaryAccess(ctx: RouteContext): Promise<Response> {
337
+ const denied = await requireComplimentaryAccess(config, ctx)
338
+ if (denied) return denied
339
+
340
+ const actor = await trustedActor(config, ctx)
341
+ if (!actor) return json({ error: 'Trusted actor context is required' }, 500)
342
+
343
+ const parsed = await readStrictJson(ctx, entitlementActionSchema)
344
+ if (!parsed.ok) return parsed.response
345
+
346
+ return providerJson(
347
+ () => providers.subscriptions.revokeComplimentaryAccess(parsed.data, actor),
348
+ providerResultSchema(complimentaryRevokeResultSchema),
349
+ )
350
+ },
351
+
352
+ async retryComplimentaryAccessEmail(ctx: RouteContext): Promise<Response> {
353
+ const denied = await requireComplimentaryAccess(config, ctx)
354
+ if (denied) return denied
355
+
356
+ const actor = await trustedActor(config, ctx)
357
+ if (!actor) return json({ error: 'Trusted actor context is required' }, 500)
358
+
359
+ const parsed = await readStrictJson(ctx, entitlementActionSchema)
360
+ if (!parsed.ok) return parsed.response
361
+
362
+ return providerJson(
363
+ () => providers.subscriptions.retryComplimentaryAccessEmail(parsed.data, actor),
364
+ providerResultSchema(complimentaryRetryResultSchema),
365
+ )
366
+ },
367
+
146
368
  // WRITE (privileged) — trigger the LemonSqueezy entitlement sync. This
147
369
  // mutates host state (creates/updates users + subscriptions), so it gates
148
370
  // 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
  }