@stacksjs/defaults 0.70.356 → 0.70.358

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 (73) hide show
  1. package/ai/skills/stacks-browse/SKILL.md +5 -1
  2. package/ai/skills/stacks-browse/scripts/browse.ts +61 -6
  3. package/ai/skills/stacks-orm/SKILL.md +6 -0
  4. package/app/Actions/Dashboard/Actions/GetActions.ts +15 -9
  5. package/app/Actions/Dashboard/Analytics/EventStoreAction.ts +21 -9
  6. package/app/Actions/Dashboard/Commerce/ProductUnitDefaultAction.ts +12 -3
  7. package/app/Actions/Dashboard/Commerce/TaxRateDefaultAction.ts +12 -3
  8. package/app/Actions/Dashboard/Content/AuthorDestroyAction.ts +23 -9
  9. package/app/Actions/Dashboard/Content/AuthorIndexAction.ts +32 -26
  10. package/app/Actions/Dashboard/Content/AuthorStoreAction.ts +44 -24
  11. package/app/Actions/Dashboard/Content/AuthorUpdateAction.ts +48 -26
  12. package/app/Actions/Dashboard/Content/CategoryDestroyAction.ts +27 -8
  13. package/app/Actions/Dashboard/Content/CategoryIndexAction.ts +21 -15
  14. package/app/Actions/Dashboard/Content/CategoryStoreAction.ts +47 -29
  15. package/app/Actions/Dashboard/Content/CommentDestroyAction.ts +17 -4
  16. package/app/Actions/Dashboard/Content/CommentIndexAction.ts +23 -17
  17. package/app/Actions/Dashboard/Content/CommentUpdateAction.ts +30 -12
  18. package/app/Actions/Dashboard/Content/ContentDashboardAction.ts +43 -37
  19. package/app/Actions/Dashboard/Content/PageDestroyAction.ts +17 -4
  20. package/app/Actions/Dashboard/Content/PageIndexAction.ts +23 -17
  21. package/app/Actions/Dashboard/Content/PageStoreAction.ts +33 -18
  22. package/app/Actions/Dashboard/Content/PageUpdateAction.ts +40 -22
  23. package/app/Actions/Dashboard/Content/PostDestroyAction.ts +24 -11
  24. package/app/Actions/Dashboard/Content/PostIndexAction.ts +55 -49
  25. package/app/Actions/Dashboard/Content/PostStoreAction.ts +43 -33
  26. package/app/Actions/Dashboard/Content/PostUpdateAction.ts +46 -34
  27. package/app/Actions/Dashboard/Content/TagDestroyAction.ts +27 -8
  28. package/app/Actions/Dashboard/Content/TagIndexAction.ts +31 -25
  29. package/app/Actions/Dashboard/Content/TagStoreAction.ts +44 -23
  30. package/app/Actions/Dashboard/Content/content-input.ts +4 -4
  31. package/app/Actions/Dashboard/Content/post-input.ts +60 -8
  32. package/app/Actions/Dashboard/DashboardHealthAction.ts +34 -14
  33. package/app/Actions/Dashboard/DashboardHomeAction.test.ts +1 -1
  34. package/app/Actions/Dashboard/DashboardHomeAction.ts +31 -4
  35. package/app/Actions/Dashboard/DashboardStatsAction.ts +6 -1
  36. package/app/Actions/Dashboard/Email/InboxSendAction.ts +14 -7
  37. package/app/Actions/Dashboard/Infrastructure/CommandIndexAction.ts +33 -27
  38. package/app/Actions/Dashboard/Infrastructure/EnvironmentIndexAction.ts +10 -4
  39. package/app/Actions/Dashboard/Infrastructure/EnvironmentUpdateAction.ts +8 -1
  40. package/app/Actions/Dashboard/Infrastructure/RequestIndexAction.ts +24 -18
  41. package/app/Actions/Dashboard/Infrastructure/ServerIndexAction.ts +15 -9
  42. package/app/Actions/Dashboard/Infrastructure/ServerShowAction.ts +9 -2
  43. package/app/Actions/Dashboard/Marketing/CampaignCancelAction.ts +23 -5
  44. package/app/Actions/Dashboard/Marketing/CampaignDestroyAction.ts +26 -17
  45. package/app/Actions/Dashboard/Marketing/CampaignIndexAction.ts +49 -43
  46. package/app/Actions/Dashboard/Marketing/CampaignScheduleAction.ts +31 -25
  47. package/app/Actions/Dashboard/Marketing/CampaignSendAction.ts +28 -22
  48. package/app/Actions/Dashboard/Marketing/CampaignStoreAction.ts +15 -10
  49. package/app/Actions/Dashboard/Marketing/CampaignUpdateAction.ts +19 -12
  50. package/app/Actions/Dashboard/Marketing/ListDestroyAction.ts +34 -25
  51. package/app/Actions/Dashboard/Marketing/ListIndexAction.ts +32 -26
  52. package/app/Actions/Dashboard/Marketing/ListStoreAction.ts +31 -18
  53. package/app/Actions/Dashboard/Marketing/ListUpdateAction.ts +35 -19
  54. package/app/Actions/Dashboard/Marketing/SocialPostDestroyAction.ts +15 -5
  55. package/app/Actions/Dashboard/Marketing/SocialPostIndexAction.ts +11 -5
  56. package/app/Actions/Dashboard/Marketing/SocialPostStoreAction.ts +19 -13
  57. package/app/Actions/Dashboard/Marketing/SocialPostUpdateAction.ts +22 -12
  58. package/app/Actions/Dashboard/Marketing/campaign-delivery.ts +34 -0
  59. package/app/Actions/Dashboard/Marketing/marketing-list-records.test.ts +10 -0
  60. package/app/Actions/Dashboard/Marketing/marketing-list-records.ts +10 -0
  61. package/app/Actions/Dashboard/Marketing/marketing-response.ts +28 -0
  62. package/app/Actions/Dashboard/Marketing/social-post-records.test.ts +13 -0
  63. package/app/Actions/Dashboard/Marketing/social-post-records.ts +22 -4
  64. package/app/Actions/Dashboard/Models/GetModels.ts +14 -7
  65. package/app/Actions/Dashboard/Models/GetSubscriberCount.ts +8 -1
  66. package/app/Actions/Dashboard/Models/GetUserCount.ts +8 -1
  67. package/app/Actions/Dashboard/Releases/ReleaseIndexAction.ts +11 -5
  68. package/app/Actions/Dashboard/Settings/MailSettingsGetAction.ts +10 -4
  69. package/app/Actions/Dashboard/Settings/MailSettingsUpdateAction.ts +8 -1
  70. package/ide/vscode/package.json +1 -1
  71. package/package.json +1 -1
  72. package/resources/components/Dashboard/UI/Modal.stx +60 -10
  73. package/views/dashboard/stores/auth.ts +18 -2
@@ -100,13 +100,17 @@ Extracts headings, links (`text -> href`), buttons, forms (action + field count)
100
100
  ```bash
101
101
  bun storage/framework/defaults/ai/skills/stacks-browse/scripts/browse.ts scenario <url> \
102
102
  --step '{"action":"click","selector":"button[data-open]"}' \
103
+ --step '{"action":"focus","selector":"input[name=name]"}' \
103
104
  --step '{"action":"fill","selector":"input[name=name]","value":"Example"}' \
104
105
  --step '{"action":"click","selector":"button[type=submit]"}' \
105
106
  --step '{"action":"assert","selector":"main","text":"Saved"}' \
107
+ --step '{"action":"assert","selector":"[role=dialog]","absent":true}' \
106
108
  --out storage/framework/runtime/shots/scenario.png
107
109
  ```
108
110
 
109
- Steps run in order within one isolated browser page, preserving reactive STX state and SPA navigation. Supported actions are `click`, `fill`, `press`, `wait`, and `assert`. Each step is a JSON object passed through a repeatable `--step` flag. A click step may include `text` to select the matching control from its CSS selector. The command reports every completed step, the final URL and page text, console exceptions, failed requests, and an optional screenshot. It exits nonzero when the scenario assertion, browser console, or network fails.
111
+ Steps run in order within one isolated browser page, preserving reactive STX state and SPA navigation. Supported actions are `click`, `fill`, `focus`, `press`, `wait`, and `assert`. Each step is a JSON object passed through a repeatable `--step` flag. Click and focus steps may include `text` to select the matching control from their CSS selector. The command reports every completed step, the final URL and page text, console messages, console exceptions, failed requests, and an optional screenshot. It exits nonzero when the scenario assertion, browser console, or network fails.
112
+ Use `{"action":"assert","selector":"...","absent":true}` to verify that an element is missing or hidden after an interaction.
113
+ Use `{"action":"assert","selector":"button","text":"Save","focused":true}` to verify keyboard focus and focus restoration.
110
114
 
111
115
  ### Crawl (whole-site browser audit)
112
116
  ```bash
@@ -497,12 +497,14 @@ function parseViewport(value: string | undefined): { w: number, h: number } {
497
497
  return { w, h }
498
498
  }
499
499
 
500
- type ScenarioAction = 'assert' | 'click' | 'fill' | 'press' | 'wait'
500
+ type ScenarioAction = 'assert' | 'click' | 'fill' | 'focus' | 'press' | 'wait'
501
501
 
502
502
  interface ScenarioStep {
503
503
  action: ScenarioAction
504
504
  selector?: string
505
505
  text?: string
506
+ absent?: boolean
507
+ focused?: boolean
506
508
  value?: string
507
509
  key?: string
508
510
  ms?: number
@@ -522,14 +524,18 @@ function parseScenarioStep(value: string): ScenarioStep {
522
524
  throw new TypeError('Each scenario step must be a JSON object.')
523
525
 
524
526
  const step = parsed as Record<string, unknown>
525
- if (!['assert', 'click', 'fill', 'press', 'wait'].includes(String(step.action)))
527
+ if (!['assert', 'click', 'fill', 'focus', 'press', 'wait'].includes(String(step.action)))
526
528
  throw new TypeError(`Unsupported scenario action: ${String(step.action)}`)
527
529
 
528
530
  const action = step.action as ScenarioAction
529
- if (['assert', 'click', 'fill'].includes(action) && typeof step.selector !== 'string')
531
+ if (['assert', 'click', 'fill', 'focus'].includes(action) && typeof step.selector !== 'string')
530
532
  throw new TypeError(`Scenario action "${action}" requires a selector.`)
531
533
  if (action === 'fill' && typeof step.value !== 'string')
532
534
  throw new TypeError('Scenario action "fill" requires a string value.')
535
+ if (step.absent !== undefined && (action !== 'assert' || typeof step.absent !== 'boolean'))
536
+ throw new TypeError('Scenario "absent" is a boolean supported only by assert actions.')
537
+ if (step.focused !== undefined && (action !== 'assert' || typeof step.focused !== 'boolean'))
538
+ throw new TypeError('Scenario "focused" is a boolean supported only by assert actions.')
533
539
  if (action === 'press' && typeof step.key !== 'string')
534
540
  throw new TypeError('Scenario action "press" requires a key.')
535
541
  if (action === 'wait' && (!Number.isFinite(step.ms) || Number(step.ms) < 0 || Number(step.ms) > 30_000))
@@ -557,23 +563,36 @@ async function runScenarioStep(cdp: Cdp, step: ScenarioStep): Promise<Record<str
557
563
  const step = ${JSON.stringify(step)}
558
564
  const candidates = Array.from(document.querySelectorAll(step.selector))
559
565
  const normalizedText = (element) => (element.innerText || element.textContent || '').replace(/\\s+/g, ' ').trim()
560
- const element = step.action === 'click' && step.text !== undefined
566
+ const element = (step.action === 'click' || step.action === 'focus' || step.action === 'assert') && step.text !== undefined
561
567
  ? candidates.find(candidate => normalizedText(candidate) === step.text)
562
568
  || candidates.find(candidate => normalizedText(candidate).includes(step.text))
563
569
  : candidates[0]
570
+ if (!element && step.action === 'assert' && step.absent)
571
+ return { ok: true, action: step.action, selector: step.selector, absent: true }
564
572
  if (!element)
565
573
  return { ok: false, error: 'Element not found', selector: step.selector }
566
574
 
567
575
  const rect = element.getBoundingClientRect()
568
576
  const style = getComputedStyle(element)
569
577
  const visible = rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'
578
+ if (step.action === 'assert' && step.absent) {
579
+ if (visible)
580
+ return { ok: false, error: 'Expected element to be absent', selector: step.selector }
581
+ return { ok: true, action: step.action, selector: step.selector, absent: true }
582
+ }
570
583
  if (!visible)
571
584
  return { ok: false, error: 'Element is not visible', selector: step.selector }
572
585
 
573
586
  if (step.action === 'click') {
574
587
  if (element.disabled || element.getAttribute('aria-disabled') === 'true')
575
588
  return { ok: false, error: 'Element is disabled', selector: step.selector }
576
- element.click()
589
+ }
590
+ else if (step.action === 'focus') {
591
+ if (typeof element.focus !== 'function')
592
+ return { ok: false, error: 'Element cannot receive focus', selector: step.selector }
593
+ element.focus()
594
+ if (document.activeElement !== element)
595
+ return { ok: false, error: 'Element did not receive focus', selector: step.selector }
577
596
  }
578
597
  else if (step.action === 'fill') {
579
598
  if (!('value' in element))
@@ -593,12 +612,19 @@ async function runScenarioStep(cdp: Cdp, step: ScenarioStep): Promise<Record<str
593
612
  return { ok: false, error: 'Expected text was not found', selector: step.selector, expected: step.text, actual: content.slice(0, 240) }
594
613
  }
595
614
 
615
+ if (step.action === 'assert' && step.focused !== undefined) {
616
+ const focused = document.activeElement === element
617
+ if (focused !== step.focused)
618
+ return { ok: false, error: step.focused ? 'Expected element to be focused' : 'Expected element not to be focused', selector: step.selector }
619
+ }
620
+
596
621
  return {
597
622
  ok: true,
598
623
  action: step.action,
599
624
  selector: step.selector,
600
625
  tag: element.tagName.toLowerCase(),
601
626
  text: normalizedText(element).slice(0, 160),
627
+ clickPoint: step.action === 'click' ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } : undefined,
602
628
  }
603
629
  })()`,
604
630
  returnByValue: true,
@@ -606,6 +632,12 @@ async function runScenarioStep(cdp: Cdp, step: ScenarioStep): Promise<Record<str
606
632
  const value = result.result?.value as Record<string, unknown> | undefined
607
633
  if (!value?.ok)
608
634
  throw new Error(`Scenario ${step.action} failed: ${String(value?.error || 'unknown error')} (${step.selector || step.key || ''})`)
635
+ if (step.action === 'click') {
636
+ const point = value.clickPoint as { x: number, y: number }
637
+ await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: point.x, y: point.y, button: 'left', clickCount: 1 })
638
+ await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: point.x, y: point.y, button: 'left', clickCount: 1 })
639
+ delete value.clickPoint
640
+ }
609
641
  return value
610
642
  }
611
643
 
@@ -741,6 +773,20 @@ async function main() {
741
773
  const viewport = parseViewport(typeof flags.viewport === 'string' ? flags.viewport : undefined)
742
774
  const state = await gotoAndInstrument(cdp, url, { viewport, cookies, settleMs, scheme })
743
775
  const results: Record<string, unknown>[] = []
776
+ await cdp.send('Page.bringToFront')
777
+ await cdp.send('Runtime.evaluate', {
778
+ expression: `(() => {
779
+ window.__browseFocusHistory = []
780
+ const describe = (element) => element ? {
781
+ tag: element.tagName?.toLowerCase() || null,
782
+ role: element.getAttribute?.('role') || null,
783
+ ariaLabel: element.getAttribute?.('aria-label') || null,
784
+ text: (element.innerText || element.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 80),
785
+ } : null
786
+ document.addEventListener('focusin', event => window.__browseFocusHistory.push({ event: 'focusin', target: describe(event.target) }), true)
787
+ document.addEventListener('focusout', event => window.__browseFocusHistory.push({ event: 'focusout', target: describe(event.target), related: describe(event.relatedTarget) }), true)
788
+ })()`,
789
+ })
744
790
 
745
791
  for (const step of steps) {
746
792
  results.push(await runScenarioStep(cdp, step))
@@ -752,7 +798,15 @@ async function main() {
752
798
  expression: `({
753
799
  url: location.href,
754
800
  title: document.title,
755
- activeElement: document.activeElement?.tagName?.toLowerCase() || null,
801
+ focusHistory: window.__browseFocusHistory || [],
802
+ activeElement: document.activeElement ? {
803
+ tag: document.activeElement.tagName?.toLowerCase() || null,
804
+ id: document.activeElement.id || null,
805
+ role: document.activeElement.getAttribute?.('role') || null,
806
+ ariaLabel: document.activeElement.getAttribute?.('aria-label') || null,
807
+ ref: document.activeElement.getAttribute?.('data-stx-ref') || null,
808
+ text: (document.activeElement.innerText || document.activeElement.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 120),
809
+ } : null,
756
810
  bodyText: (document.body.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 500),
757
811
  })`,
758
812
  returnByValue: true,
@@ -770,6 +824,7 @@ async function main() {
770
824
  steps: results,
771
825
  final: finalState.result?.value,
772
826
  screenshot,
827
+ consoleMessages: state.console,
773
828
  consoleErrors: state.consoleErrors,
774
829
  failedRequests,
775
830
  }, null, 2))
@@ -196,6 +196,12 @@ await createUser('Alice', 'alice@example.com') // auto-wrapped
196
196
 
197
197
  Both `transaction()` and `savepoint()` delegate to `db.transaction()` and `db.savepoint()` from `@stacksjs/database`.
198
198
 
199
+ ### Transaction executor boundary
200
+
201
+ Every query that must commit or roll back together must use the callback handle (`tx` or `sp`), including validation reads, pivot writes, and the final readback. Do not mix `Model.find()`, `Model.create()`, instance `update()` / `delete()`, or instance relation calls into a raw query-builder transaction. The model executor is a separate execution surface and is not rebound to the callback handle. On SQLite it may use a separate connection, so it cannot observe an uncommitted row written through `tx`.
202
+
203
+ `runInTransactionScope()` buffers supported side effects until commit, but it does not rebind model queries. For a transaction-backed custom action, use the model definition as the schema and relationship source of truth, then execute the complete persistence workflow through `tx`. Read the created or updated row through `tx` before returning so a readback failure also rolls back the mutation.
204
+
199
205
  ## Trait Methods (traits/)
200
206
 
201
207
  ### Taggable (when `traits.taggable: true`)
@@ -1,22 +1,28 @@
1
1
  import { Action } from '@stacksjs/actions'
2
2
  import process from 'node:process'
3
3
  import { discoverActionSources } from '../Source/source-inventory'
4
+ import { dashboardOperationalError } from '../dashboard-response'
4
5
 
5
6
  export default new Action({
6
7
  name: 'GetActions',
7
8
  description: 'Lists application and framework Actions from their native source files.',
8
9
  method: 'GET',
9
10
  async handle() {
10
- const items = await discoverActionSources(process.cwd())
11
+ try {
12
+ const items = await discoverActionSources(process.cwd())
11
13
 
12
- return {
13
- items,
14
- stats: {
15
- total: items.length,
16
- application: items.filter(item => item.origin === 'Application').length,
17
- framework: items.filter(item => item.origin === 'Framework').length,
18
- writes: items.filter(item => !['ANY', 'GET', 'HEAD', 'OPTIONS'].includes(item.method || 'ANY')).length,
19
- },
14
+ return {
15
+ items,
16
+ stats: {
17
+ total: items.length,
18
+ application: items.filter(item => item.origin === 'Application').length,
19
+ framework: items.filter(item => item.origin === 'Framework').length,
20
+ writes: items.filter(item => !['ANY', 'GET', 'HEAD', 'OPTIONS'].includes(item.method || 'ANY')).length,
21
+ },
22
+ }
23
+ }
24
+ catch (error) {
25
+ return dashboardOperationalError(error, 'Action sources could not be loaded.', 'GetActions')
20
26
  }
21
27
  },
22
28
  })
@@ -1,7 +1,8 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
- import { AnalyticsEvent } from '@stacksjs/orm'
3
+ import { AnalyticsEvent, ModelValidationError } from '@stacksjs/orm'
4
4
  import { response } from '@stacksjs/router'
5
+ import { dashboardOperationalError } from '../dashboard-response'
5
6
 
6
7
  function token(value: unknown, fallback: string): string {
7
8
  const normalized = String(value || '')
@@ -46,14 +47,25 @@ export default new Action({
46
47
  if (properties.length > 10_000)
47
48
  return response.json({ message: 'Properties must be 10,000 characters or fewer.' }, 422)
48
49
 
49
- await AnalyticsEvent.create({
50
- name,
51
- category,
52
- path,
53
- value,
54
- currency,
55
- properties,
56
- })
50
+ try {
51
+ await AnalyticsEvent.create({
52
+ name,
53
+ category,
54
+ path,
55
+ value,
56
+ currency,
57
+ properties,
58
+ })
59
+ }
60
+ catch (error) {
61
+ if (error instanceof ModelValidationError) {
62
+ return response.json({
63
+ message: 'Validation failed.',
64
+ errors: error.errors,
65
+ }, 422)
66
+ }
67
+ return dashboardOperationalError(error, 'Analytics event could not be recorded.', 'EventStoreAction', 500)
68
+ }
57
69
 
58
70
  return response.json({ success: true }, 201)
59
71
  },
@@ -2,6 +2,7 @@ import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
3
  import { products } from '@stacksjs/commerce'
4
4
  import { response } from '@stacksjs/router'
5
+ import { dashboardOperationalError } from '../dashboard-response'
5
6
 
6
7
  export default new Action({
7
8
  name: 'ProductUnitDefaultAction',
@@ -11,11 +12,19 @@ export default new Action({
11
12
 
12
13
  async handle(request: RequestInstance) {
13
14
  const id = Number(request.getParam('id'))
15
+ if (!Number.isSafeInteger(id) || id <= 0)
16
+ return response.json({ error: 'A valid product unit id is required.' }, 400)
17
+ if (typeof request.get('isDefault') !== 'boolean')
18
+ return response.json({ error: 'isDefault must be a boolean.' }, 422)
14
19
  const isDefault = request.boolean('isDefault')
15
- if (!Number.isFinite(id) || id <= 0)
16
- return response.notFound({ error: 'Product unit not found' })
17
20
 
18
- const updated = await products.units.updateDefaultStatus(id, isDefault)
21
+ let updated
22
+ try {
23
+ updated = await products.units.updateDefaultStatus(id, isDefault)
24
+ }
25
+ catch (error) {
26
+ return dashboardOperationalError(error, 'Product unit default could not be updated.', 'ProductUnitDefaultAction', 500)
27
+ }
19
28
  if (!updated)
20
29
  return response.notFound({ error: 'Product unit not found' })
21
30
 
@@ -2,6 +2,7 @@ import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
3
  import { tax } from '@stacksjs/commerce'
4
4
  import { response } from '@stacksjs/router'
5
+ import { dashboardOperationalError } from '../dashboard-response'
5
6
 
6
7
  export default new Action({
7
8
  name: 'TaxRateDefaultAction',
@@ -11,11 +12,19 @@ export default new Action({
11
12
 
12
13
  async handle(request: RequestInstance) {
13
14
  const id = Number(request.getParam('id'))
15
+ if (!Number.isSafeInteger(id) || id <= 0)
16
+ return response.json({ error: 'A valid tax rate id is required.' }, 400)
17
+ if (typeof request.get('isDefault') !== 'boolean')
18
+ return response.json({ error: 'isDefault must be a boolean.' }, 422)
14
19
  const isDefault = request.boolean('isDefault')
15
- if (!Number.isFinite(id) || id <= 0)
16
- return response.notFound({ error: 'Tax rate not found' })
17
20
 
18
- const updated = await tax.updateDefaultStatus(id, isDefault)
21
+ let updated
22
+ try {
23
+ updated = await tax.updateDefaultStatus(id, isDefault)
24
+ }
25
+ catch (error) {
26
+ return dashboardOperationalError(error, 'Tax rate default could not be updated.', 'TaxRateDefaultAction', 500)
27
+ }
19
28
  if (!updated)
20
29
  return response.notFound({ error: 'Tax rate not found' })
21
30
 
@@ -1,7 +1,9 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
3
  import { db } from '@stacksjs/database'
4
+ import { transaction } from '@stacksjs/orm'
4
5
  import { response } from '@stacksjs/router'
6
+ import { dashboardOperationalError } from '../dashboard-response'
5
7
  import { rowExists, rowId, timestamp } from './content-input'
6
8
 
7
9
  /**
@@ -22,17 +24,29 @@ export default new Action({
22
24
  if (!id)
23
25
  return response.json({ message: 'A valid author id is required.' }, 422)
24
26
 
25
- if (!await rowExists('authors', id))
26
- return response.json({ message: 'Author not found.' }, 404)
27
+ try {
28
+ const deleted = await transaction(async (rawTrx) => {
29
+ const trx = rawTrx as unknown as typeof db
30
+ if (!await rowExists('authors', id, trx))
31
+ return false
27
32
 
28
- await db
29
- .updateTable('posts')
30
- .set({ author_id: null, updated_at: timestamp() } as any)
31
- .where('author_id', '=', id)
32
- .execute()
33
+ await trx
34
+ .updateTable('posts')
35
+ .set({ author_id: null, updated_at: timestamp() } as any)
36
+ .where('author_id', '=', id)
37
+ .execute()
33
38
 
34
- await db.deleteFrom('authors').where('id', '=', id).execute()
39
+ await trx.deleteFrom('authors').where('id', '=', id).execute()
40
+ return true
41
+ })
35
42
 
36
- return response.json({ message: 'Author deleted.', id })
43
+ if (!deleted)
44
+ return response.json({ message: 'Author not found.' }, 404)
45
+
46
+ return response.json({ message: 'Author deleted.', id })
47
+ }
48
+ catch (error) {
49
+ return dashboardOperationalError(error, 'Author could not be deleted.', 'AuthorDestroyAction', 500)
50
+ }
37
51
  },
38
52
  })
@@ -1,5 +1,6 @@
1
1
  import { Action } from '@stacksjs/actions'
2
2
  import { db } from '@stacksjs/database'
3
+ import { dashboardOperationalError } from '../dashboard-response'
3
4
 
4
5
  interface AuthorRow {
5
6
  id: number
@@ -30,36 +31,41 @@ export default new Action({
30
31
  method: 'GET',
31
32
  apiResponse: true,
32
33
  async handle() {
33
- const [rows, postAuthors] = await Promise.all([
34
- db.selectFrom('authors').selectAll().orderBy('created_at', 'desc').execute() as unknown as Promise<AuthorRow[]>,
35
- db.selectFrom('posts').select(['author_id']).execute() as unknown as Promise<Array<{ author_id: number | null }>>,
36
- ])
34
+ try {
35
+ const [rows, postAuthors] = await Promise.all([
36
+ db.selectFrom('authors').selectAll().orderBy('created_at', 'desc').execute() as unknown as Promise<AuthorRow[]>,
37
+ db.selectFrom('posts').select(['author_id']).execute() as unknown as Promise<Array<{ author_id: number | null }>>,
38
+ ])
37
39
 
38
- const postCounts = new Map<number, number>()
39
- for (const post of postAuthors) {
40
- if (post.author_id == null)
41
- continue
40
+ const postCounts = new Map<number, number>()
41
+ for (const post of postAuthors) {
42
+ if (post.author_id == null)
43
+ continue
42
44
 
43
- const id = Number(post.author_id)
44
- postCounts.set(id, (postCounts.get(id) || 0) + 1)
45
- }
45
+ const id = Number(post.author_id)
46
+ postCounts.set(id, (postCounts.get(id) || 0) + 1)
47
+ }
46
48
 
47
- const authors = rows.map(row => ({
48
- id: Number(row.id),
49
- name: String(row.name || ''),
50
- email: String(row.email || ''),
51
- bio: String(row.bio || ''),
52
- avatar: String(row.avatar || ''),
53
- postCount: postCounts.get(Number(row.id)) || 0,
54
- created_at: row.created_at || null,
55
- updated_at: row.updated_at || null,
56
- }))
49
+ const authors = rows.map(row => ({
50
+ id: Number(row.id),
51
+ name: String(row.name || ''),
52
+ email: String(row.email || ''),
53
+ bio: String(row.bio || ''),
54
+ avatar: String(row.avatar || ''),
55
+ postCount: postCounts.get(Number(row.id)) || 0,
56
+ created_at: row.created_at || null,
57
+ updated_at: row.updated_at || null,
58
+ }))
57
59
 
58
- // Posts whose author no longer exists are not counted: the stat means
59
- // "posts reachable from this list", which is what the page's Assigned Posts
60
- // tile claims to show.
61
- const assignedPostCount = authors.reduce((sum, author) => sum + author.postCount, 0)
60
+ // Posts whose author no longer exists are not counted: the stat means
61
+ // "posts reachable from this list", which is what the page's Assigned Posts
62
+ // tile claims to show.
63
+ const assignedPostCount = authors.reduce((sum, author) => sum + author.postCount, 0)
62
64
 
63
- return { authors, assignedPostCount }
65
+ return { authors, assignedPostCount }
66
+ }
67
+ catch (error) {
68
+ return dashboardOperationalError(error, 'Authors could not be loaded.', 'AuthorIndexAction')
69
+ }
64
70
  },
65
71
  })
@@ -1,8 +1,10 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
3
  import { db } from '@stacksjs/database'
4
+ import { isUniqueViolation, transaction } from '@stacksjs/orm'
4
5
  import { response } from '@stacksjs/router'
5
6
  import { randomUUIDv7 } from 'bun'
7
+ import { dashboardOperationalError } from '../dashboard-response'
6
8
  import { parseAuthorInput } from './author-input'
7
9
  import { findRow, insertedId, timestamp } from './content-input'
8
10
 
@@ -25,35 +27,53 @@ export default new Action({
25
27
  if ('message' in input)
26
28
  return response.json({ message: input.message }, 422)
27
29
 
28
- const duplicate = await db
29
- .selectFrom('authors')
30
- .select(['id'])
31
- .where('email', '=', input.data.email)
32
- .executeTakeFirst()
30
+ try {
31
+ const author = await transaction(async (rawTrx) => {
32
+ const trx = rawTrx as unknown as typeof db
33
+ const duplicate = await trx
34
+ .selectFrom('authors')
35
+ .select(['id'])
36
+ .where('email', '=', input.data.email)
37
+ .executeTakeFirst()
33
38
 
34
- if (duplicate)
35
- return response.json({ message: 'An author with that email already exists.' }, 422)
39
+ if (duplicate)
40
+ return null
36
41
 
37
- const now = timestamp()
42
+ const now = timestamp()
38
43
 
39
- const result = await db
40
- .insertInto('authors')
41
- .values({
42
- uuid: randomUUIDv7(),
43
- name: input.data.name,
44
- email: input.data.email,
45
- bio: input.data.bio || null,
46
- avatar: input.data.avatar || null,
47
- created_at: now,
48
- updated_at: now,
49
- } as any)
50
- .executeTakeFirst()
44
+ const result = await trx
45
+ .insertInto('authors')
46
+ .values({
47
+ uuid: randomUUIDv7(),
48
+ name: input.data.name,
49
+ email: input.data.email,
50
+ bio: input.data.bio || null,
51
+ avatar: input.data.avatar || null,
52
+ created_at: now,
53
+ updated_at: now,
54
+ } as any)
55
+ .executeTakeFirst()
51
56
 
52
- const id = insertedId(result)
57
+ const id = insertedId(result)
53
58
 
54
- if (!id)
55
- return response.json({ message: 'Could not create author.' }, 500)
59
+ if (!id)
60
+ throw new Error('Author insert did not return an id.')
56
61
 
57
- return response.json(await findRow('authors', id), 201)
62
+ const created = await findRow('authors', id, trx)
63
+ if (!created)
64
+ throw new Error('Created author could not be loaded.')
65
+ return created
66
+ })
67
+
68
+ if (!author)
69
+ return response.json({ message: 'An author with that email already exists.' }, 422)
70
+
71
+ return response.json(author, 201)
72
+ }
73
+ catch (error) {
74
+ if (isUniqueViolation(error))
75
+ return response.json({ message: 'An author with that email already exists.' }, 422)
76
+ return dashboardOperationalError(error, 'Author could not be created.', 'AuthorStoreAction', 500)
77
+ }
58
78
  },
59
79
  })
@@ -1,7 +1,9 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
3
  import { db } from '@stacksjs/database'
4
+ import { isUniqueViolation, transaction } from '@stacksjs/orm'
4
5
  import { response } from '@stacksjs/router'
6
+ import { dashboardOperationalError } from '../dashboard-response'
5
7
  import { parseAuthorInput } from './author-input'
6
8
  import { findRow, rowExists, rowId, timestamp } from './content-input'
7
9
 
@@ -15,36 +17,56 @@ export default new Action({
15
17
  if (!id)
16
18
  return response.json({ message: 'A valid author id is required.' }, 422)
17
19
 
18
- if (!await rowExists('authors', id))
19
- return response.json({ message: 'Author not found.' }, 404)
20
-
21
20
  const input = parseAuthorInput(request)
22
21
 
23
22
  if ('message' in input)
24
23
  return response.json({ message: input.message }, 422)
25
24
 
26
- const duplicate = await db
27
- .selectFrom('authors')
28
- .select(['id'])
29
- .where('email', '=', input.data.email)
30
- .where('id', '!=', id)
31
- .executeTakeFirst()
32
-
33
- if (duplicate)
34
- return response.json({ message: 'An author with that email already exists.' }, 422)
35
-
36
- await db
37
- .updateTable('authors')
38
- .set({
39
- name: input.data.name,
40
- email: input.data.email,
41
- bio: input.data.bio || null,
42
- avatar: input.data.avatar || null,
43
- updated_at: timestamp(),
44
- } as any)
45
- .where('id', '=', id)
46
- .execute()
47
-
48
- return response.json(await findRow('authors', id))
25
+ try {
26
+ const result = await transaction(async (rawTrx) => {
27
+ const trx = rawTrx as unknown as typeof db
28
+ if (!await rowExists('authors', id, trx))
29
+ return { kind: 'not-found' } as const
30
+
31
+ const duplicate = await trx
32
+ .selectFrom('authors')
33
+ .select(['id'])
34
+ .where('email', '=', input.data.email)
35
+ .where('id', '!=', id)
36
+ .executeTakeFirst()
37
+
38
+ if (duplicate)
39
+ return { kind: 'duplicate' } as const
40
+
41
+ await trx
42
+ .updateTable('authors')
43
+ .set({
44
+ name: input.data.name,
45
+ email: input.data.email,
46
+ bio: input.data.bio || null,
47
+ avatar: input.data.avatar || null,
48
+ updated_at: timestamp(),
49
+ } as any)
50
+ .where('id', '=', id)
51
+ .execute()
52
+
53
+ const author = await findRow('authors', id, trx)
54
+ if (!author)
55
+ throw new Error('Updated author could not be loaded.')
56
+ return { kind: 'updated', author } as const
57
+ })
58
+
59
+ if (result.kind === 'not-found')
60
+ return response.json({ message: 'Author not found.' }, 404)
61
+ if (result.kind === 'duplicate')
62
+ return response.json({ message: 'An author with that email already exists.' }, 422)
63
+
64
+ return response.json(result.author)
65
+ }
66
+ catch (error) {
67
+ if (isUniqueViolation(error))
68
+ return response.json({ message: 'An author with that email already exists.' }, 422)
69
+ return dashboardOperationalError(error, 'Author could not be updated.', 'AuthorUpdateAction', 500)
70
+ }
49
71
  },
50
72
  })