@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
@@ -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 { findRow, rowExists, rowId, timestamp } from './content-input'
6
8
  import { parsePageInput, parsePublished } from './page-input'
7
9
 
@@ -15,32 +17,48 @@ export default new Action({
15
17
  if (!id)
16
18
  return response.json({ message: 'A valid page id is required.' }, 422)
17
19
 
18
- if (!await rowExists('pages', id))
19
- return response.json({ message: 'Page not found.' }, 404)
20
-
21
20
  const input = parsePageInput(request)
22
21
 
23
22
  if ('message' in input)
24
23
  return response.json({ message: input.message }, 422)
25
24
 
26
- const current = await db
27
- .selectFrom('pages')
28
- .select(['published_at'])
29
- .where('id', '=', id)
30
- .executeTakeFirst()
31
- const published = parsePublished(request.get('published'))
32
-
33
- await db
34
- .updateTable('pages')
35
- .set({
36
- title: input.data.title,
37
- template: input.data.template,
38
- published_at: published ? current?.published_at || timestamp() : null,
39
- updated_at: timestamp(),
40
- } as any)
41
- .where('id', '=', id)
42
- .execute()
43
-
44
- return response.json(await findRow('pages', id))
25
+ try {
26
+ const page = await transaction(async (rawTrx) => {
27
+ const trx = rawTrx as unknown as typeof db
28
+ if (!await rowExists('pages', id, trx))
29
+ return null
30
+
31
+ const current = await trx
32
+ .selectFrom('pages')
33
+ .select(['published_at'])
34
+ .where('id', '=', id)
35
+ .executeTakeFirst()
36
+ const published = parsePublished(request.get('published'))
37
+
38
+ await trx
39
+ .updateTable('pages')
40
+ .set({
41
+ title: input.data.title,
42
+ template: input.data.template,
43
+ published_at: published ? current?.published_at || timestamp() : null,
44
+ updated_at: timestamp(),
45
+ } as any)
46
+ .where('id', '=', id)
47
+ .execute()
48
+
49
+ const updated = await findRow('pages', id, trx)
50
+ if (!updated)
51
+ throw new Error('Updated page could not be loaded.')
52
+ return updated
53
+ })
54
+
55
+ if (!page)
56
+ return response.json({ message: 'Page not found.' }, 404)
57
+
58
+ return response.json(page)
59
+ }
60
+ catch (error) {
61
+ return dashboardOperationalError(error, 'Page could not be updated.', 'PageUpdateAction', 500)
62
+ }
45
63
  },
46
64
  })
@@ -1,12 +1,16 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
+ import { db } from '@stacksjs/database'
4
+ import { transaction } from '@stacksjs/orm'
3
5
  import { response } from '@stacksjs/router'
6
+ import { dashboardOperationalError } from '../dashboard-response'
7
+ import { detachPostRelations } from './post-input'
4
8
 
5
9
  /**
6
10
  * `DELETE /api/dashboard/posts/{id}` — deletes a CMS post from the dashboard.
7
11
  *
8
- * Detaches the native category and tag relations before removing the post so
9
- * no pivot rows are orphaned.
12
+ * Detaches the model-declared category and tag pivots before removing the post,
13
+ * with all three writes committed by the same transaction.
10
14
  */
11
15
  export default new Action({
12
16
  name: 'PostDestroyAction',
@@ -18,17 +22,26 @@ export default new Action({
18
22
  if (!Number.isInteger(id) || id <= 0)
19
23
  return response.json({ message: 'A valid post id is required.' }, 422)
20
24
 
21
- const post = await Post.find(id)
25
+ try {
26
+ const deleted = await transaction(async (rawTrx) => {
27
+ const trx = rawTrx as unknown as typeof db
28
+ const post = await trx.selectFrom('posts').select(['id']).where('id', '=', id).executeTakeFirst()
22
29
 
23
- if (!post)
24
- return response.json({ message: 'Post not found.' }, 404)
30
+ if (!post)
31
+ return false
25
32
 
26
- await Promise.all([
27
- post.categories().detach(),
28
- post.tags().detach(),
29
- ])
30
- await post.delete()
33
+ await detachPostRelations(trx, id)
34
+ await trx.deleteFrom('posts').where('id', '=', id).execute()
35
+ return true
36
+ })
31
37
 
32
- return response.json({ message: 'Post deleted.', id })
38
+ if (!deleted)
39
+ return response.json({ message: 'Post not found.' }, 404)
40
+
41
+ return response.json({ message: 'Post deleted.', id })
42
+ }
43
+ catch (error) {
44
+ return dashboardOperationalError(error, 'Post could not be deleted.', 'PostDestroyAction', 500)
45
+ }
33
46
  },
34
47
  })
@@ -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 PostRow {
5
6
  id: number
@@ -55,8 +56,8 @@ function counts(posts: Array<{ status: string }>) {
55
56
  * `GET /api/dashboard/posts` — backs `views/dashboard/content/posts/index.stx`.
56
57
  *
57
58
  * Uses one bounded query per persisted dataset and groups pivot ids in maps,
58
- * avoiding relation N+1 queries while still keeping all writes on the native
59
- * model relations.
59
+ * avoiding relation N+1 queries. Writes use the same model-declared pivots on
60
+ * one native database transaction.
60
61
  *
61
62
  * There is deliberately no mock-data fallback. This action used to catch every
62
63
  * error and serve placeholder rows, which made a page that was 404ing against
@@ -69,56 +70,61 @@ export default new Action({
69
70
  method: 'GET',
70
71
  apiResponse: true,
71
72
  async handle() {
72
- const rows = await db
73
- .selectFrom('posts')
74
- .selectAll()
75
- .orderBy('created_at', 'desc')
76
- .execute() as unknown as PostRow[]
73
+ try {
74
+ const rows = await db
75
+ .selectFrom('posts')
76
+ .selectAll()
77
+ .orderBy('created_at', 'desc')
78
+ .execute() as unknown as PostRow[]
77
79
 
78
- const posts = rows.map(row => ({
79
- id: Number(row.id),
80
- title: String(row.title || ''),
81
- excerpt: String(row.excerpt || ''),
82
- content: String(row.content || ''),
83
- poster: String(row.poster || ''),
84
- status: normalizeStatus(row.status),
85
- views: Number(row.views || 0),
86
- published_at: row.published_at || null,
87
- created_at: row.created_at || null,
88
- updated_at: row.updated_at || null,
89
- author_id: row.author_id ?? null,
90
- featured: Boolean(row.is_featured),
91
- }))
80
+ const posts = rows.map(row => ({
81
+ id: Number(row.id),
82
+ title: String(row.title || ''),
83
+ excerpt: String(row.excerpt || ''),
84
+ content: String(row.content || ''),
85
+ poster: String(row.poster || ''),
86
+ status: normalizeStatus(row.status),
87
+ views: Number(row.views || 0),
88
+ published_at: row.published_at || null,
89
+ created_at: row.created_at || null,
90
+ updated_at: row.updated_at || null,
91
+ author_id: row.author_id ?? null,
92
+ featured: Boolean(row.is_featured),
93
+ }))
92
94
 
93
- const postIds = posts.map(post => post.id)
94
- const [categories, tags, authors, categoryRelations, tagRelations] = await Promise.all([
95
- db.selectFrom('categories').select(['id', 'name', 'slug']).orderBy('name').execute(),
96
- db.selectFrom('tags').select(['id', 'name', 'slug']).orderBy('name').execute(),
97
- db.selectFrom('authors').select(['id', 'name', 'email']).orderBy('name').execute(),
98
- postIds.length
99
- ? db.selectFrom('categorizable_models')
100
- .whereIn('categorizable_id', postIds)
101
- .where('categorizable_type', '=', 'posts')
102
- .select(['category_id as relatedId', 'categorizable_id as postId'])
103
- .execute() as unknown as Promise<PivotRow[]>
104
- : Promise.resolve([]),
105
- postIds.length
106
- ? db.selectFrom('taggable_models')
107
- .whereIn('taggable_id', postIds)
108
- .where('taggable_type', '=', 'posts')
109
- .select(['tag_id as relatedId', 'taggable_id as postId'])
110
- .execute() as unknown as Promise<PivotRow[]>
111
- : Promise.resolve([]),
112
- ])
95
+ const postIds = posts.map(post => post.id)
96
+ const [categories, tags, authors, categoryRelations, tagRelations] = await Promise.all([
97
+ db.selectFrom('categories').select(['id', 'name', 'slug']).orderBy('name').execute(),
98
+ db.selectFrom('tags').select(['id', 'name', 'slug']).orderBy('name').execute(),
99
+ db.selectFrom('authors').select(['id', 'name', 'email']).orderBy('name').execute(),
100
+ postIds.length
101
+ ? db.selectFrom('categorizable_models')
102
+ .whereIn('categorizable_id', postIds)
103
+ .where('categorizable_type', '=', 'posts')
104
+ .select(['category_id as relatedId', 'categorizable_id as postId'])
105
+ .execute() as unknown as Promise<PivotRow[]>
106
+ : Promise.resolve([]),
107
+ postIds.length
108
+ ? db.selectFrom('taggable_models')
109
+ .whereIn('taggable_id', postIds)
110
+ .where('taggable_type', '=', 'posts')
111
+ .select(['tag_id as relatedId', 'taggable_id as postId'])
112
+ .execute() as unknown as Promise<PivotRow[]>
113
+ : Promise.resolve([]),
114
+ ])
113
115
 
114
- const categoryIdsByPost = relatedIdsByPost(categoryRelations)
115
- const tagIdsByPost = relatedIdsByPost(tagRelations)
116
- const withRelations = posts.map(post => ({
117
- ...post,
118
- categoryIds: categoryIdsByPost.get(post.id) || [],
119
- tagIds: tagIdsByPost.get(post.id) || [],
120
- }))
116
+ const categoryIdsByPost = relatedIdsByPost(categoryRelations)
117
+ const tagIdsByPost = relatedIdsByPost(tagRelations)
118
+ const withRelations = posts.map(post => ({
119
+ ...post,
120
+ categoryIds: categoryIdsByPost.get(post.id) || [],
121
+ tagIds: tagIdsByPost.get(post.id) || [],
122
+ }))
121
123
 
122
- return { posts: withRelations, categories, tags, authors, ...counts(posts) }
124
+ return { posts: withRelations, categories, tags, authors, ...counts(posts) }
125
+ }
126
+ catch (error) {
127
+ return dashboardOperationalError(error, 'Posts could not be loaded.', 'PostIndexAction')
128
+ }
123
129
  },
124
130
  })
@@ -1,9 +1,11 @@
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'
5
6
  import { randomUUIDv7 } from 'bun'
6
- import { findPost, insertedId, invalidPostContent, invalidPostReference, postPayload, publishedAtFor, timestamp } from './post-input'
7
+ import { dashboardOperationalError } from '../dashboard-response'
8
+ import { findPost, insertedId, invalidPostContent, invalidPostReference, postPayload, publishedAtFor, syncPostRelations, timestamp } from './post-input'
7
9
 
8
10
  /**
9
11
  * `POST /api/dashboard/posts` — creates a CMS post from the dashboard.
@@ -23,45 +25,53 @@ export default new Action({
23
25
  if (invalidContent)
24
26
  return response.json({ message: invalidContent }, 422)
25
27
 
26
- const invalidReference = await invalidPostReference(payload)
27
- if (invalidReference)
28
- return response.json({ message: invalidReference }, 422)
28
+ try {
29
+ const result = await transaction(async (rawTrx) => {
30
+ const trx = rawTrx as unknown as typeof db
31
+ const invalidReference = await invalidPostReference(payload, trx)
32
+ if (invalidReference)
33
+ return { kind: 'invalid', message: invalidReference } as const
29
34
 
30
- const now = timestamp()
35
+ const now = timestamp()
31
36
 
32
- const result = await db
33
- .insertInto('posts')
34
- .values({
35
- uuid: randomUUIDv7(),
36
- title: payload.title,
37
- excerpt: payload.excerpt,
38
- content: payload.content,
39
- poster: payload.poster,
40
- status: payload.status,
41
- author_id: payload.authorId,
42
- is_featured: payload.featured ? 1 : 0,
43
- views: 0,
44
- published_at: publishedAtFor(payload.status, null, now),
45
- created_at: now,
46
- updated_at: now,
47
- } as any)
48
- .executeTakeFirst()
37
+ const insertResult = await trx
38
+ .insertInto('posts')
39
+ .values({
40
+ uuid: randomUUIDv7(),
41
+ title: payload.title,
42
+ excerpt: payload.excerpt,
43
+ content: payload.content,
44
+ poster: payload.poster,
45
+ status: payload.status,
46
+ author_id: payload.authorId,
47
+ is_featured: payload.featured ? 1 : 0,
48
+ views: 0,
49
+ published_at: publishedAtFor(payload.status, null, now),
50
+ created_at: now,
51
+ updated_at: now,
52
+ } as any)
53
+ .executeTakeFirst()
49
54
 
50
- const id = insertedId(result)
55
+ const id = insertedId(insertResult)
51
56
 
52
- if (!id)
53
- return response.json({ message: 'Could not create post.' }, 500)
57
+ if (!id)
58
+ throw new Error('Post insert did not return an id.')
54
59
 
55
- const post = await Post.find(id)
60
+ await syncPostRelations(trx, id, payload)
56
61
 
57
- if (!post)
58
- return response.json({ message: 'Created post could not be loaded.' }, 500)
62
+ const created = await findPost(id, trx)
63
+ if (!created)
64
+ throw new Error('Created post could not be read back.')
65
+ return { kind: 'created', post: created } as const
66
+ })
59
67
 
60
- await Promise.all([
61
- post.categories().sync(payload.categoryIds),
62
- post.tags().sync(payload.tagIds),
63
- ])
68
+ if (result.kind === 'invalid')
69
+ return response.json({ message: result.message }, 422)
64
70
 
65
- return response.json(await findPost(id), 201)
71
+ return response.json(result.post, 201)
72
+ }
73
+ catch (error) {
74
+ return dashboardOperationalError(error, 'Post could not be created.', 'PostStoreAction', 500)
75
+ }
66
76
  },
67
77
  })
@@ -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 { transaction } from '@stacksjs/orm'
4
5
  import { response } from '@stacksjs/router'
5
- import { findPost, invalidPostContent, invalidPostReference, postPayload, publishedAtFor, timestamp } from './post-input'
6
+ import { dashboardOperationalError } from '../dashboard-response'
7
+ import { findPost, invalidPostContent, invalidPostReference, postPayload, publishedAtFor, syncPostRelations, timestamp } from './post-input'
6
8
 
7
9
  /**
8
10
  * `PATCH /api/dashboard/posts/{id}` — updates a CMS post from the dashboard.
@@ -27,45 +29,55 @@ export default new Action({
27
29
  if (invalidContent)
28
30
  return response.json({ message: invalidContent }, 422)
29
31
 
30
- const invalidReference = await invalidPostReference(payload)
31
- if (invalidReference)
32
- return response.json({ message: invalidReference }, 422)
32
+ try {
33
+ const result = await transaction(async (rawTrx) => {
34
+ const trx = rawTrx as unknown as typeof db
35
+ const invalidReference = await invalidPostReference(payload, trx)
36
+ if (invalidReference)
37
+ return { kind: 'invalid', message: invalidReference } as const
33
38
 
34
- const existing = await db
35
- .selectFrom('posts')
36
- .select(['id', 'published_at'])
37
- .where('id', '=', id)
38
- .executeTakeFirst() as { id: number, published_at: string | null } | undefined
39
+ const existing = await trx
40
+ .selectFrom('posts')
41
+ .select(['id', 'published_at'])
42
+ .where('id', '=', id)
43
+ .executeTakeFirst() as { id: number, published_at: string | null } | undefined
39
44
 
40
- if (!existing)
41
- return response.json({ message: 'Post not found.' }, 404)
45
+ if (!existing)
46
+ return { kind: 'not-found' } as const
42
47
 
43
- await db
44
- .updateTable('posts')
45
- .set({
46
- title: payload.title,
47
- excerpt: payload.excerpt,
48
- content: payload.content,
49
- poster: payload.poster,
50
- status: payload.status,
51
- author_id: payload.authorId,
52
- is_featured: payload.featured ? 1 : 0,
53
- published_at: publishedAtFor(payload.status, existing.published_at, timestamp()),
54
- updated_at: timestamp(),
55
- } as any)
56
- .where('id', '=', id)
57
- .execute()
48
+ await trx
49
+ .updateTable('posts')
50
+ .set({
51
+ title: payload.title,
52
+ excerpt: payload.excerpt,
53
+ content: payload.content,
54
+ poster: payload.poster,
55
+ status: payload.status,
56
+ author_id: payload.authorId,
57
+ is_featured: payload.featured ? 1 : 0,
58
+ published_at: publishedAtFor(payload.status, existing.published_at, timestamp()),
59
+ updated_at: timestamp(),
60
+ } as any)
61
+ .where('id', '=', id)
62
+ .execute()
58
63
 
59
- const post = await Post.find(id)
64
+ await syncPostRelations(trx, id, payload)
60
65
 
61
- if (!post)
62
- return response.json({ message: 'Updated post could not be loaded.' }, 500)
66
+ const updated = await findPost(id, trx)
67
+ if (!updated)
68
+ throw new Error('Updated post could not be read back.')
69
+ return { kind: 'updated', post: updated } as const
70
+ })
63
71
 
64
- await Promise.all([
65
- post.categories().sync(payload.categoryIds),
66
- post.tags().sync(payload.tagIds),
67
- ])
72
+ if (result.kind === 'invalid')
73
+ return response.json({ message: result.message }, 422)
74
+ if (result.kind === 'not-found')
75
+ return response.json({ message: 'Post not found.' }, 404)
68
76
 
69
- return response.json(await findPost(id))
77
+ return response.json(result.post)
78
+ }
79
+ catch (error) {
80
+ return dashboardOperationalError(error, 'Post could not be updated.', 'PostUpdateAction', 500)
81
+ }
70
82
  },
71
83
  })
@@ -1,13 +1,16 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
+ import { db } from '@stacksjs/database'
4
+ import { transaction } from '@stacksjs/orm'
3
5
  import { response } from '@stacksjs/router'
6
+ import { dashboardOperationalError } from '../dashboard-response'
4
7
  import { rowId } from './content-input'
5
8
 
6
9
  /**
7
10
  * `DELETE /api/dashboard/tags/{id}` — deletes a CMS tag.
8
11
  *
9
- * Detaches related posts through the model relation before removing the tag so
10
- * no pivot rows are orphaned.
12
+ * Detaches related posts through the model-declared pivot before removing the
13
+ * tag, with both writes committed by the same transaction.
11
14
  */
12
15
  export default new Action({
13
16
  name: 'TagDestroyAction',
@@ -19,13 +22,29 @@ export default new Action({
19
22
  if (!id)
20
23
  return response.json({ message: 'A valid tag id is required.' }, 422)
21
24
 
22
- const tag = await Tag.find(id)
23
- if (!tag)
24
- return response.json({ message: 'Tag not found.' }, 404)
25
+ try {
26
+ const deleted = await transaction(async (rawTrx) => {
27
+ const trx = rawTrx as unknown as typeof db
28
+ const tag = await trx.selectFrom('tags').select(['id']).where('id', '=', id).executeTakeFirst()
29
+ if (!tag)
30
+ return false
25
31
 
26
- await tag.posts().detach()
27
- await tag.delete()
32
+ await trx
33
+ .deleteFrom('taggable_models')
34
+ .where('tag_id', '=', id)
35
+ .where('taggable_type', '=', 'posts')
36
+ .execute()
37
+ await trx.deleteFrom('tags').where('id', '=', id).execute()
38
+ return true
39
+ })
28
40
 
29
- return response.json({ message: 'Tag deleted.', id })
41
+ if (!deleted)
42
+ return response.json({ message: 'Tag not found.' }, 404)
43
+
44
+ return response.json({ message: 'Tag deleted.', id })
45
+ }
46
+ catch (error) {
47
+ return dashboardOperationalError(error, 'Tag could not be deleted.', 'TagDestroyAction', 500)
48
+ }
30
49
  },
31
50
  })
@@ -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 TagRow {
5
6
  id: number
@@ -28,32 +29,37 @@ export default new Action({
28
29
  method: 'GET',
29
30
  apiResponse: true,
30
31
  async handle() {
31
- const [rows, countRows] = await Promise.all([
32
- db
33
- .selectFrom('tags')
34
- .select(['id', 'name', 'slug', 'description', 'color', 'created_at', 'updated_at'])
35
- .orderBy('created_at', 'desc')
36
- .execute() as unknown as Promise<TagRow[]>,
37
- db
38
- .selectFrom('taggable_models')
39
- .select(['tag_id', db.fn.count('taggable_id').as('count')])
40
- .where('taggable_type', '=', 'posts')
41
- .groupBy('tag_id')
42
- .execute() as unknown as Promise<TagCountRow[]>,
43
- ])
44
- const countByTag = new Map(countRows.map(row => [Number(row.tag_id), Number(row.count)]))
32
+ try {
33
+ const [rows, countRows] = await Promise.all([
34
+ db
35
+ .selectFrom('tags')
36
+ .select(['id', 'name', 'slug', 'description', 'color', 'created_at', 'updated_at'])
37
+ .orderBy('created_at', 'desc')
38
+ .execute() as unknown as Promise<TagRow[]>,
39
+ db
40
+ .selectFrom('taggable_models')
41
+ .select(['tag_id', db.fn.count('taggable_id').as('count')])
42
+ .where('taggable_type', '=', 'posts')
43
+ .groupBy('tag_id')
44
+ .execute() as unknown as Promise<TagCountRow[]>,
45
+ ])
46
+ const countByTag = new Map(countRows.map(row => [Number(row.tag_id), Number(row.count)]))
45
47
 
46
- const tags = rows.map(row => ({
47
- id: Number(row.id),
48
- name: String(row.name || ''),
49
- slug: String(row.slug || ''),
50
- description: String(row.description || ''),
51
- color: String(row.color || ''),
52
- post_count: countByTag.get(Number(row.id)) || 0,
53
- created_at: row.created_at || null,
54
- updated_at: row.updated_at || null,
55
- }))
48
+ const tags = rows.map(row => ({
49
+ id: Number(row.id),
50
+ name: String(row.name || ''),
51
+ slug: String(row.slug || ''),
52
+ description: String(row.description || ''),
53
+ color: String(row.color || ''),
54
+ post_count: countByTag.get(Number(row.id)) || 0,
55
+ created_at: row.created_at || null,
56
+ updated_at: row.updated_at || null,
57
+ }))
56
58
 
57
- return { tags }
59
+ return { tags }
60
+ }
61
+ catch (error) {
62
+ return dashboardOperationalError(error, 'Tags could not be loaded.', 'TagIndexAction')
63
+ }
58
64
  },
59
65
  })