@stacksjs/defaults 0.74.2 → 0.74.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 (40) hide show
  1. package/ai/skills/stacks-auto-imports/SKILL.md +1 -1
  2. package/ai/skills/stacks-buddy/SKILL.md +53 -3
  3. package/ai/skills/stacks-cloud/SKILL.md +83 -11
  4. package/ai/skills/stacks-commerce/SKILL.md +1 -1
  5. package/ai/skills/stacks-composables/SKILL.md +1 -1
  6. package/ai/skills/stacks-dashboard/SKILL.md +2 -2
  7. package/ai/skills/stacks-deploy/SKILL.md +97 -24
  8. package/ai/skills/stacks-orm/SKILL.md +1 -1
  9. package/ai/skills/stacks-types/SKILL.md +1 -1
  10. package/ai/skills/stacks-writing-for-agents/SKILL.md +1 -1
  11. package/app/Actions/Buddy/CommandsAction.ts +1 -10
  12. package/app/Actions/Dashboard/Analytics/WebAnalyticsAction.ts +2 -7
  13. package/app/Actions/Dashboard/Analytics/web-analytics-provider.ts +103 -0
  14. package/app/Actions/Dashboard/Infrastructure/LogIndexAction.ts +24 -84
  15. package/app/Actions/Dashboard/Infrastructure/log-provider.ts +189 -0
  16. package/app/Actions/Dashboard/Marketing/AbandonedCartCampaignAction.ts +51 -0
  17. package/app/Actions/Dashboard/Marketing/AbandonedCartIndexAction.ts +85 -0
  18. package/app/Actions/Dashboard/Marketing/abandoned-cart-records.test.ts +316 -0
  19. package/app/Actions/Dashboard/Marketing/abandoned-cart-records.ts +429 -0
  20. package/app/Actions/Dashboard/dashboard-provider.ts +170 -0
  21. package/app/Actions/Monitoring/ErrorGroupAction.ts +2 -4
  22. package/app/Actions/Monitoring/ErrorIndexAction.ts +2 -4
  23. package/app/Actions/Monitoring/ErrorShowAction.ts +4 -4
  24. package/app/Actions/Monitoring/ErrorStatsAction.ts +2 -4
  25. package/app/Actions/Monitoring/ErrorTimelineAction.ts +2 -4
  26. package/app/Actions/Monitoring/error-provider.ts +130 -0
  27. package/app/Models/EmailIdempotency.ts +4 -1
  28. package/app/Models/EmailSuppression.ts +4 -1
  29. package/app/Models/EmailWebhookEvent.ts +4 -1
  30. package/app/Models/Request.ts +4 -1
  31. package/functions/api-url.test.ts +61 -0
  32. package/functions/api-url.ts +19 -2
  33. package/ide/vscode/package.json +1 -1
  34. package/package.json +2 -2
  35. package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +240 -0
  36. package/resources/components/Dashboard/Marketing/AbandonedCartsTable.stx +96 -0
  37. package/resources/components/Dashboard/Marketing/RecoveryCampaignDialog.stx +145 -0
  38. package/resources/functions/dashboard/sidebar.ts +1 -0
  39. package/routes/dashboard-api.ts +5 -0
  40. package/views/dashboard/marketing/abandoned-carts/index.stx +10 -0
@@ -1,16 +1,35 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
+ import type { DashboardLogQuery, DashboardLogRange } from './log-provider'
2
3
  import { Action } from '@stacksjs/actions'
3
- import { db } from '@stacksjs/database'
4
4
  import { dashboardRequestValue } from '../dashboard-request'
5
5
  import { dashboardOperationalError } from '../dashboard-response'
6
- import { DASHBOARD_LOG_TYPES, normalizeDashboardLog, summarizeDashboardLogTypes } from './log-dashboard'
7
-
8
- const RANGES = ['1', '7', '30', '90', 'all'] as const
6
+ import { DASHBOARD_LOG_TYPES } from './log-dashboard'
7
+ import { DASHBOARD_LOG_RANGES, readDashboardLogs } from './log-provider'
9
8
 
10
9
  function queryValue(request: RequestInstance, key: string): string {
11
10
  return dashboardRequestValue(request, key)
12
11
  }
13
12
 
13
+ /** Reads the filters off the request. Unknown values fall back rather than fail. */
14
+ function logQuery(request: RequestInstance): DashboardLogQuery {
15
+ const requestedType = queryValue(request, 'type').toLowerCase()
16
+ const requestedRange = queryValue(request, 'range').toLowerCase()
17
+
18
+ return {
19
+ page: Math.max(1, Number.parseInt(queryValue(request, 'page') || '1', 10) || 1),
20
+ perPage: Math.min(100, Math.max(10, Number.parseInt(queryValue(request, 'per_page') || '25', 10) || 25)),
21
+ search: queryValue(request, 'search'),
22
+ type: DASHBOARD_LOG_TYPES.includes(requestedType as typeof DASHBOARD_LOG_TYPES[number])
23
+ ? requestedType
24
+ : '',
25
+ source: queryValue(request, 'source').toLowerCase(),
26
+ project: queryValue(request, 'project'),
27
+ range: DASHBOARD_LOG_RANGES.includes(requestedRange as DashboardLogRange)
28
+ ? requestedRange as DashboardLogRange
29
+ : '30',
30
+ }
31
+ }
32
+
14
33
  export default new Action({
15
34
  name: 'LogIndexAction',
16
35
  description: 'Returns filtered, paginated Log model records for the dashboard.',
@@ -19,86 +38,7 @@ export default new Action({
19
38
 
20
39
  async handle(request: RequestInstance) {
21
40
  try {
22
- const requestedPage = Math.max(1, Number.parseInt(queryValue(request, 'page') || '1', 10) || 1)
23
- const perPage = Math.min(100, Math.max(10, Number.parseInt(queryValue(request, 'per_page') || '25', 10) || 25))
24
- const search = queryValue(request, 'search')
25
- const requestedType = queryValue(request, 'type').toLowerCase()
26
- const type = DASHBOARD_LOG_TYPES.includes(requestedType as typeof DASHBOARD_LOG_TYPES[number])
27
- ? requestedType
28
- : ''
29
- const source = queryValue(request, 'source').toLowerCase()
30
- const project = queryValue(request, 'project')
31
- const requestedRange = queryValue(request, 'range').toLowerCase()
32
- const range = RANGES.includes(requestedRange as typeof RANGES[number]) ? requestedRange : '30'
33
- const cutoff = range === 'all' ? 0 : Date.now() - Number(range) * 86400000
34
- const searchPattern = `%${search}%`
35
-
36
- const applyFilters = <T extends {
37
- where: (...args: any[]) => T
38
- whereAny: (columns: string[], operator: string, value: unknown) => T
39
- }>(query: T): T => {
40
- let filtered = query
41
- if (cutoff)
42
- filtered = filtered.where('timestamp', '>=', cutoff)
43
- if (type)
44
- filtered = filtered.where('type', '=', type)
45
- if (source)
46
- filtered = filtered.where('source', '=', source)
47
- if (project)
48
- filtered = filtered.where('project', '=', project)
49
- if (search)
50
- filtered = filtered.whereAny(['message', 'project', 'file', 'stacktrace'], 'like', searchPattern)
51
- return filtered
52
- }
53
-
54
- const [countRow, typeRows, sourceRows, projectRows] = await Promise.all([
55
- applyFilters(db.selectFrom('logs').select(db.fn.count('id').as('count')))
56
- .executeTakeFirst() as Promise<{ count: number | string } | undefined>,
57
- applyFilters(db.selectFrom('logs').select(['type', db.fn.count('id').as('count')]))
58
- .groupBy('type')
59
- .execute() as Promise<Array<{ type: string, count: number | string }>>,
60
- db.selectFrom('logs').select('source').distinct().orderBy('source', 'asc').limit(100).execute() as Promise<Array<{ source: string }>>,
61
- db.selectFrom('logs').select('project').distinct().orderBy('project', 'asc').limit(500).execute() as Promise<Array<{ project: string }>>,
62
- ])
63
-
64
- const total = Number(countRow?.count || 0)
65
- const totalPages = Math.max(1, Math.ceil(total / perPage))
66
- const page = Math.min(requestedPage, totalPages)
67
- const rows = await applyFilters(db.selectFrom('logs').select([
68
- 'id',
69
- 'timestamp',
70
- 'type',
71
- 'source',
72
- 'message',
73
- 'project',
74
- 'stacktrace',
75
- 'file',
76
- 'created_at',
77
- 'updated_at',
78
- ]))
79
- .orderBy('timestamp', 'desc')
80
- .orderBy('id', 'desc')
81
- .limit(perPage)
82
- .offset((page - 1) * perPage)
83
- .execute()
84
-
85
- return {
86
- logs: rows.map(normalizeDashboardLog),
87
- summary: summarizeDashboardLogTypes(typeRows, total),
88
- pagination: {
89
- page,
90
- perPage,
91
- total,
92
- totalPages,
93
- },
94
- options: {
95
- sources: sourceRows.map(row => row.source).filter(Boolean),
96
- projects: projectRows.map(row => row.project).filter(Boolean),
97
- types: [...DASHBOARD_LOG_TYPES],
98
- ranges: [...RANGES],
99
- },
100
- generatedAt: new Date().toISOString(),
101
- }
41
+ return await readDashboardLogs(logQuery(request))
102
42
  }
103
43
  catch (error) {
104
44
  return dashboardOperationalError(error, 'Logs could not be loaded.', 'LogIndexAction')
@@ -0,0 +1,189 @@
1
+ import type { DashboardProviderUnavailable } from '../dashboard-provider'
2
+ import type { DashboardLogRecord, DashboardLogSummary } from './log-dashboard'
3
+ import { db } from '@stacksjs/database'
4
+ import { HQ_READ_UNAVAILABLE, readThroughProvider, resolveDashboardDriver } from '../dashboard-provider'
5
+ import { DASHBOARD_LOG_TYPES, normalizeDashboardLog, summarizeDashboardLogTypes } from './log-dashboard'
6
+
7
+ /**
8
+ * Where the Logs section reads from.
9
+ *
10
+ * The local provider is the query that used to sit inside `LogIndexAction`,
11
+ * moved without changing a line of its logic: the same five statements against
12
+ * the `logs` table, the same filters, the same ordering, and the same clamp of
13
+ * a requested page down to the last page that exists.
14
+ *
15
+ * The payload deliberately reuses `DashboardLogRecord` and
16
+ * `DashboardLogSummary` from `log-dashboard.ts`. Three stx components import
17
+ * those same types, so a provider that returns the wrong shape is a compile
18
+ * error against the declaration the components themselves consume rather than
19
+ * a surprise at runtime.
20
+ */
21
+
22
+ export const DASHBOARD_LOG_RANGES = ['1', '7', '30', '90', 'all'] as const
23
+
24
+ export type DashboardLogRange = typeof DASHBOARD_LOG_RANGES[number]
25
+
26
+ export interface DashboardLogQuery {
27
+ page: number
28
+ perPage: number
29
+ search: string
30
+ /** One of {@link DASHBOARD_LOG_TYPES}, or empty for every level. */
31
+ type: string
32
+ source: string
33
+ project: string
34
+ range: DashboardLogRange
35
+ }
36
+
37
+ export interface DashboardLogPagination {
38
+ page: number
39
+ perPage: number
40
+ total: number
41
+ totalPages: number
42
+ }
43
+
44
+ export interface DashboardLogOptions {
45
+ sources: string[]
46
+ projects: string[]
47
+ types: string[]
48
+ ranges: string[]
49
+ }
50
+
51
+ export interface DashboardLogPayload extends Partial<DashboardProviderUnavailable> {
52
+ logs: DashboardLogRecord[]
53
+ summary: DashboardLogSummary
54
+ pagination: DashboardLogPagination
55
+ options: DashboardLogOptions
56
+ generatedAt: string
57
+ }
58
+
59
+ export interface DashboardLogsProvider {
60
+ logs: (query: DashboardLogQuery) => Promise<DashboardLogPayload>
61
+ }
62
+
63
+ /** An empty page of logs, carrying the reason there is nothing to show. */
64
+ export function emptyLogPayload(reason: string, query?: DashboardLogQuery): DashboardLogPayload {
65
+ return {
66
+ logs: [],
67
+ summary: { total: 0, error: 0, warning: 0, info: 0, success: 0 },
68
+ pagination: {
69
+ page: 1,
70
+ perPage: query?.perPage ?? 25,
71
+ total: 0,
72
+ totalPages: 1,
73
+ },
74
+ options: {
75
+ sources: [],
76
+ projects: [],
77
+ types: [...DASHBOARD_LOG_TYPES],
78
+ ranges: [...DASHBOARD_LOG_RANGES],
79
+ },
80
+ generatedAt: new Date().toISOString(),
81
+ unavailable: reason,
82
+ }
83
+ }
84
+
85
+ function isLogPayload(payload: unknown): boolean {
86
+ if (!payload || typeof payload !== 'object')
87
+ return false
88
+ const candidate = payload as Partial<DashboardLogPayload>
89
+ return Array.isArray(candidate.logs)
90
+ && Boolean(candidate.summary)
91
+ && Boolean(candidate.pagination)
92
+ && Boolean(candidate.options)
93
+ }
94
+
95
+ /**
96
+ * This application's own `logs` table.
97
+ *
98
+ * Not wrapped in a catch. A failure here is a local database failure, and the
99
+ * action has always answered that with its operational error, which is the
100
+ * behaviour every existing caller and test expects.
101
+ */
102
+ export const localLogsProvider: DashboardLogsProvider = {
103
+ async logs(query: DashboardLogQuery): Promise<DashboardLogPayload> {
104
+ const cutoff = query.range === 'all' ? 0 : Date.now() - Number(query.range) * 86400000
105
+ const searchPattern = `%${query.search}%`
106
+
107
+ const applyFilters = <T extends {
108
+ where: (...args: any[]) => T
109
+ whereAny: (columns: string[], operator: string, value: unknown) => T
110
+ }>(builder: T): T => {
111
+ let filtered = builder
112
+ if (cutoff)
113
+ filtered = filtered.where('timestamp', '>=', cutoff)
114
+ if (query.type)
115
+ filtered = filtered.where('type', '=', query.type)
116
+ if (query.source)
117
+ filtered = filtered.where('source', '=', query.source)
118
+ if (query.project)
119
+ filtered = filtered.where('project', '=', query.project)
120
+ if (query.search)
121
+ filtered = filtered.whereAny(['message', 'project', 'file', 'stacktrace'], 'like', searchPattern)
122
+ return filtered
123
+ }
124
+
125
+ const [countRow, typeRows, sourceRows, projectRows] = await Promise.all([
126
+ applyFilters(db.selectFrom('logs').select(db.fn.count('id').as('count')))
127
+ .executeTakeFirst() as Promise<{ count: number | string } | undefined>,
128
+ applyFilters(db.selectFrom('logs').select(['type', db.fn.count('id').as('count')]))
129
+ .groupBy('type')
130
+ .execute() as Promise<Array<{ type: string, count: number | string }>>,
131
+ db.selectFrom('logs').select('source').distinct().orderBy('source', 'asc').limit(100).execute() as Promise<Array<{ source: string }>>,
132
+ db.selectFrom('logs').select('project').distinct().orderBy('project', 'asc').limit(500).execute() as Promise<Array<{ project: string }>>,
133
+ ])
134
+
135
+ const total = Number(countRow?.count || 0)
136
+ const totalPages = Math.max(1, Math.ceil(total / query.perPage))
137
+ const page = Math.min(query.page, totalPages)
138
+ const rows = await applyFilters(db.selectFrom('logs').select([
139
+ 'id',
140
+ 'timestamp',
141
+ 'type',
142
+ 'source',
143
+ 'message',
144
+ 'project',
145
+ 'stacktrace',
146
+ 'file',
147
+ 'created_at',
148
+ 'updated_at',
149
+ ]))
150
+ .orderBy('timestamp', 'desc')
151
+ .orderBy('id', 'desc')
152
+ .limit(query.perPage)
153
+ .offset((page - 1) * query.perPage)
154
+ .execute()
155
+
156
+ return {
157
+ logs: rows.map(normalizeDashboardLog),
158
+ summary: summarizeDashboardLogTypes(typeRows, total),
159
+ pagination: {
160
+ page,
161
+ perPage: query.perPage,
162
+ total,
163
+ totalPages,
164
+ },
165
+ options: {
166
+ sources: sourceRows.map(row => row.source).filter(Boolean),
167
+ projects: projectRows.map(row => row.project).filter(Boolean),
168
+ types: [...DASHBOARD_LOG_TYPES],
169
+ ranges: [...DASHBOARD_LOG_RANGES],
170
+ },
171
+ generatedAt: new Date().toISOString(),
172
+ }
173
+ },
174
+ }
175
+
176
+ /** Reads the Logs section through whichever provider config selects. */
177
+ export async function readDashboardLogs(query: DashboardLogQuery): Promise<DashboardLogPayload> {
178
+ const driver = await resolveDashboardDriver('logs')
179
+
180
+ if (driver.name === 'local')
181
+ return localLogsProvider.logs(query)
182
+
183
+ return readThroughProvider(
184
+ 'logs',
185
+ reason => emptyLogPayload(reason, query),
186
+ async () => emptyLogPayload(HQ_READ_UNAVAILABLE, query),
187
+ isLogPayload,
188
+ )
189
+ }
@@ -0,0 +1,51 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action } from '@stacksjs/actions'
3
+ import { config } from '@stacksjs/config'
4
+ import { Campaign } from '@stacksjs/orm'
5
+ import { response } from '@stacksjs/router'
6
+ import { recoveryCampaignWriteData, validateRecoveryCampaign } from './abandoned-cart-records'
7
+ import { marketingModelError } from './marketing-response'
8
+
9
+ /**
10
+ * Write the campaign that goes after the cold carts.
11
+ *
12
+ * It is an ordinary Campaign row - the send pipeline, the delivery reporting
13
+ * and the campaigns screen all work on it unchanged. What makes it a recovery
14
+ * campaign is the segment stored on it, which names the trigger and the rules
15
+ * it was written with, so the campaign still says who it was for long after
16
+ * those carts have converted or been swept.
17
+ *
18
+ * Created as a draft, or scheduled if a time was given. Nothing sends from
19
+ * here: delivery goes through `CampaignSendAction` like every other campaign,
20
+ * so there is one place where mail actually leaves.
21
+ */
22
+ export default new Action({
23
+ name: 'AbandonedCartCampaignAction',
24
+ description: 'Creates a cart-recovery campaign aimed at customers who left a cart behind.',
25
+ method: 'POST',
26
+ model: Campaign,
27
+
28
+ async handle(request: RequestInstance) {
29
+ const data = recoveryCampaignWriteData(
30
+ await request.all(),
31
+ String(config.commerce?.currency || 'USD').toUpperCase(),
32
+ )
33
+
34
+ const validationError = validateRecoveryCampaign(data)
35
+ if (validationError)
36
+ return response.json({ message: validationError }, 422)
37
+
38
+ try {
39
+ const campaign = await Campaign.create({
40
+ ...data,
41
+ audience_size: 0,
42
+ sent_count: 0,
43
+ })
44
+
45
+ return response.json({ id: campaign.get('id') }, 201)
46
+ }
47
+ catch (error) {
48
+ return marketingModelError(error, 'Recovery campaign could not be created.', 'AbandonedCartCampaignAction')
49
+ }
50
+ },
51
+ })
@@ -0,0 +1,85 @@
1
+ import { Action } from '@stacksjs/actions'
2
+ import { config } from '@stacksjs/config'
3
+ import { db } from '@stacksjs/database'
4
+ import { dashboardOperationalError } from '../dashboard-response'
5
+ import { normalizeAbandonedCarts } from './abandoned-cart-records'
6
+
7
+ /**
8
+ * Every cart that was filled and then left, and whatever has been done about it.
9
+ *
10
+ * Read straight from `carts` rather than from a rollup: the number a shop acts
11
+ * on has to be the number in the table, and a cached one drifts the moment
12
+ * somebody checks out.
13
+ *
14
+ * `converted` carts are fetched alongside the cold ones because recovery is
15
+ * only measurable as the difference between them - a dashboard that shows
16
+ * only what is still abandoned can say how much money is sitting there and
17
+ * never how much of it came back.
18
+ */
19
+ export default new Action({
20
+ name: 'AbandonedCartIndexAction',
21
+ description: 'Returns abandoned carts, their recovery campaigns, and what those campaigns brought back.',
22
+ method: 'GET',
23
+ apiResponse: true,
24
+
25
+ async handle() {
26
+ try {
27
+ const carts = await db
28
+ .selectFrom('carts')
29
+ .selectAll()
30
+ .where('status', 'in', ['abandoned', 'expired', 'converted'])
31
+ .orderBy('updated_at', 'desc')
32
+ .limit(500)
33
+ .execute()
34
+
35
+ const cartIds = carts.map(cart => Number(cart.id)).filter(Number.isFinite)
36
+ const customerIds = [...new Set(
37
+ carts.map(cart => Number(cart.customer_id)).filter(id => Number.isFinite(id) && id > 0),
38
+ )]
39
+
40
+ /*
41
+ * Both of these are `in` lookups on the carts already fetched, so an
42
+ * empty page asks nothing rather than selecting every row in the table
43
+ * behind an `in ()` that some drivers read as "no filter".
44
+ */
45
+ const [items, customers, campaigns] = await Promise.all([
46
+ cartIds.length > 0
47
+ ? db.selectFrom('cart_items')
48
+ .select(['cart_id', 'quantity', 'product_name'])
49
+ .where('cart_id', 'in', cartIds)
50
+ .execute()
51
+ : Promise.resolve([]),
52
+ customerIds.length > 0
53
+ ? db.selectFrom('customers')
54
+ .select(['id', 'name', 'email'])
55
+ .where('id', 'in', customerIds)
56
+ .execute()
57
+ : Promise.resolve([]),
58
+ db.selectFrom('campaigns')
59
+ .selectAll()
60
+ .whereNotNull('segment_definition')
61
+ .orderBy('id', 'desc')
62
+ .limit(200)
63
+ .execute(),
64
+ ])
65
+
66
+ // Only the sends belonging to those campaigns matter, and there is no
67
+ // point reading a newsletter's hundred thousand rows to find out.
68
+ const campaignIds = campaigns.map(campaign => Number(campaign.id)).filter(Number.isFinite)
69
+ const sends = campaignIds.length > 0
70
+ ? await db.selectFrom('campaign_sends')
71
+ .select(['campaign_id', 'recipient', 'sent_at', 'created_at'])
72
+ .where('campaign_id', 'in', campaignIds)
73
+ .where('status', 'in', ['sent', 'delivered'])
74
+ .execute()
75
+ : []
76
+
77
+ return normalizeAbandonedCarts(carts, items, customers, campaigns, sends, {
78
+ defaultCurrency: String(config.commerce?.currency || 'USD').toUpperCase(),
79
+ })
80
+ }
81
+ catch (error) {
82
+ return dashboardOperationalError(error, 'Abandoned carts could not be loaded.', 'AbandonedCartIndexAction')
83
+ }
84
+ },
85
+ })