@stacksjs/defaults 0.70.112 → 0.70.113
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.
- package/app/Actions/Dashboard/Email/InboxIndexAction.ts +49 -0
- package/app/Actions/Dashboard/Email/InboxMarkReadAction.ts +36 -0
- package/app/Actions/Dashboard/Email/InboxShowAction.ts +44 -0
- package/app/Actions/Dashboard/Email/InboxStatsAction.ts +36 -0
- package/app/Controllers/QueryController.ts +73 -65
- package/package.json +1 -1
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Action } from '@stacksjs/actions'
|
|
2
|
+
import { emailSDK } from '@stacksjs/email'
|
|
3
|
+
import { config } from '@stacksjs/config'
|
|
4
|
+
|
|
5
|
+
export interface InboxItem {
|
|
6
|
+
messageId: string
|
|
7
|
+
from: string
|
|
8
|
+
fromName?: string
|
|
9
|
+
to: string
|
|
10
|
+
subject: string
|
|
11
|
+
date: string
|
|
12
|
+
read: boolean
|
|
13
|
+
preview?: string
|
|
14
|
+
hasAttachments?: boolean
|
|
15
|
+
path: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function defaultMailbox(): string {
|
|
19
|
+
const domain = (config as any)?.email?.domain || 'stacksjs.com'
|
|
20
|
+
return `chris@${domain}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default new Action({
|
|
24
|
+
name: 'InboxIndexAction',
|
|
25
|
+
description: 'Returns real inbound emails for a mailbox from S3.',
|
|
26
|
+
method: 'GET',
|
|
27
|
+
apiResponse: true,
|
|
28
|
+
|
|
29
|
+
async handle(request: any) {
|
|
30
|
+
try {
|
|
31
|
+
const mailbox = request?.query?.mailbox || defaultMailbox()
|
|
32
|
+
const emails = await emailSDK.getInbox(mailbox, { limit: 1000 })
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
mailbox,
|
|
36
|
+
total: emails.length,
|
|
37
|
+
emails,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
return {
|
|
42
|
+
mailbox: request?.query?.mailbox || defaultMailbox(),
|
|
43
|
+
total: 0,
|
|
44
|
+
emails: [],
|
|
45
|
+
error: err instanceof Error ? err.message : 'unknown error',
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Action } from '@stacksjs/actions'
|
|
2
|
+
import { emailSDK } from '@stacksjs/email'
|
|
3
|
+
import { config } from '@stacksjs/config'
|
|
4
|
+
|
|
5
|
+
function defaultMailbox(): string {
|
|
6
|
+
const domain = (config as any)?.email?.domain || 'stacksjs.com'
|
|
7
|
+
return `chris@${domain}`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default new Action({
|
|
11
|
+
name: 'InboxMarkReadAction',
|
|
12
|
+
description: 'Marks a single inbound email as read.',
|
|
13
|
+
method: 'POST',
|
|
14
|
+
apiResponse: true,
|
|
15
|
+
|
|
16
|
+
async handle(request: any) {
|
|
17
|
+
try {
|
|
18
|
+
const body = request?.body || {}
|
|
19
|
+
const mailbox = body.mailbox || defaultMailbox()
|
|
20
|
+
const messageId = body.messageId
|
|
21
|
+
|
|
22
|
+
if (!messageId) {
|
|
23
|
+
return { ok: false, error: 'messageId is required' }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ok = await emailSDK.markAsRead(mailbox, messageId)
|
|
27
|
+
return { ok, mailbox, messageId }
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
error: err instanceof Error ? err.message : 'unknown error',
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Action } from '@stacksjs/actions'
|
|
2
|
+
import { emailSDK } from '@stacksjs/email'
|
|
3
|
+
import { config } from '@stacksjs/config'
|
|
4
|
+
|
|
5
|
+
function defaultMailbox(): string {
|
|
6
|
+
const domain = (config as any)?.email?.domain || 'stacksjs.com'
|
|
7
|
+
return `chris@${domain}`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default new Action({
|
|
11
|
+
name: 'InboxShowAction',
|
|
12
|
+
description: 'Returns the body and metadata of a single inbound email.',
|
|
13
|
+
method: 'GET',
|
|
14
|
+
apiResponse: true,
|
|
15
|
+
|
|
16
|
+
async handle(request: any) {
|
|
17
|
+
try {
|
|
18
|
+
const mailbox = request?.query?.mailbox || defaultMailbox()
|
|
19
|
+
const messageId = request?.params?.id
|
|
20
|
+
|
|
21
|
+
if (!messageId) {
|
|
22
|
+
return { error: 'messageId is required' }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const email = await emailSDK.getEmail(mailbox, messageId)
|
|
26
|
+
if (!email) {
|
|
27
|
+
return { error: 'Email not found' }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
mailbox,
|
|
32
|
+
messageId,
|
|
33
|
+
html: email.html ?? '',
|
|
34
|
+
text: email.text ?? '',
|
|
35
|
+
metadata: email.metadata,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
return {
|
|
40
|
+
error: err instanceof Error ? err.message : 'unknown error',
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Action } from '@stacksjs/actions'
|
|
2
|
+
import { emailSDK } from '@stacksjs/email'
|
|
3
|
+
import { config } from '@stacksjs/config'
|
|
4
|
+
|
|
5
|
+
function defaultMailbox(): string {
|
|
6
|
+
const domain = (config as any)?.email?.domain || 'stacksjs.com'
|
|
7
|
+
return `chris@${domain}`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default new Action({
|
|
11
|
+
name: 'InboxStatsAction',
|
|
12
|
+
description: 'Returns aggregate inbox statistics (total/unread/read) for a mailbox.',
|
|
13
|
+
method: 'GET',
|
|
14
|
+
apiResponse: true,
|
|
15
|
+
|
|
16
|
+
async handle(request: any) {
|
|
17
|
+
try {
|
|
18
|
+
const mailbox = request?.query?.mailbox || defaultMailbox()
|
|
19
|
+
const stats = await emailSDK.getInboxStats(mailbox)
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
mailbox,
|
|
23
|
+
...stats,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
return {
|
|
28
|
+
mailbox: request?.query?.mailbox || defaultMailbox(),
|
|
29
|
+
total: 0,
|
|
30
|
+
unread: 0,
|
|
31
|
+
read: 0,
|
|
32
|
+
error: err instanceof Error ? err.message : 'unknown error',
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
})
|
|
@@ -49,7 +49,7 @@ export default class QueryController extends Controller {
|
|
|
49
49
|
db.fn.count('id').as('count'),
|
|
50
50
|
])
|
|
51
51
|
.where('status', '=', 'slow')
|
|
52
|
-
.
|
|
52
|
+
.whereRaw(sql`executed_at >= datetime("now", "-1 day")`)
|
|
53
53
|
.groupBy(sql`strftime("%Y-%m-%d %H:00:00", executed_at)`)
|
|
54
54
|
.orderBy('hour')
|
|
55
55
|
.execute()
|
|
@@ -84,8 +84,39 @@ export default class QueryController extends Controller {
|
|
|
84
84
|
search = '',
|
|
85
85
|
}) {
|
|
86
86
|
try {
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
// The wrapped builder is mutable: every chained call rewrites the
|
|
88
|
+
// same statement text, so a count projection would clobber the
|
|
89
|
+
// data projection on a shared instance. Route both queries
|
|
90
|
+
// through one filter helper over independent builders instead.
|
|
91
|
+
const applyFilters = (q: any): any => {
|
|
92
|
+
if (connection !== 'all')
|
|
93
|
+
q = q.where('connection', '=', connection)
|
|
94
|
+
|
|
95
|
+
if (status !== 'all')
|
|
96
|
+
q = q.where('status', '=', status as any)
|
|
97
|
+
|
|
98
|
+
if (type !== 'all')
|
|
99
|
+
q = q.where('tags', 'like', `%"${type}"%`)
|
|
100
|
+
|
|
101
|
+
if (search) {
|
|
102
|
+
// Parenthesized OR-group with bound parameters.
|
|
103
|
+
q = q.whereAny(['query', 'model', 'method', 'affected_tables'], 'like', `%${search}%`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return q
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Get total count for pagination
|
|
110
|
+
const totalResult = await applyFilters(db.selectFrom('query_logs'))
|
|
111
|
+
.select(db.fn.count('id').as('count'))
|
|
112
|
+
.executeTakeFirstOrThrow()
|
|
113
|
+
const total = Number(totalResult.count)
|
|
114
|
+
|
|
115
|
+
// Apply pagination
|
|
116
|
+
const offset = (page - 1) * perPage
|
|
117
|
+
|
|
118
|
+
// Execute paginated query
|
|
119
|
+
const results = await applyFilters(db.selectFrom('query_logs'))
|
|
89
120
|
.select([
|
|
90
121
|
'id',
|
|
91
122
|
'query',
|
|
@@ -100,37 +131,9 @@ export default class QueryController extends Controller {
|
|
|
100
131
|
'tags',
|
|
101
132
|
])
|
|
102
133
|
.orderBy('executed_at', 'desc')
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
query = query.where('connection', '=', connection)
|
|
107
|
-
|
|
108
|
-
if (status !== 'all')
|
|
109
|
-
query = query.where('status', '=', status as any)
|
|
110
|
-
|
|
111
|
-
if (type !== 'all')
|
|
112
|
-
query = query.where('tags', 'like', `%"${type}"%`)
|
|
113
|
-
|
|
114
|
-
if (search) {
|
|
115
|
-
query = query.where(eb => eb.or([
|
|
116
|
-
eb('query', 'like', `%${search}%`),
|
|
117
|
-
eb('model', 'like', `%${search}%`),
|
|
118
|
-
eb('method', 'like', `%${search}%`),
|
|
119
|
-
eb('affected_tables', 'like', `%${search}%`),
|
|
120
|
-
]))
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Get total count for pagination
|
|
124
|
-
const countQuery = query.$call(q => q.select(db.fn.count('id').as('count')))
|
|
125
|
-
const totalResult = await countQuery.executeTakeFirstOrThrow()
|
|
126
|
-
const total = Number(totalResult.count)
|
|
127
|
-
|
|
128
|
-
// Apply pagination
|
|
129
|
-
const offset = (page - 1) * perPage
|
|
130
|
-
query = query.limit(perPage).offset(offset)
|
|
131
|
-
|
|
132
|
-
// Execute paginated query
|
|
133
|
-
const results = await query.execute()
|
|
134
|
+
.limit(perPage)
|
|
135
|
+
.offset(offset)
|
|
136
|
+
.execute()
|
|
134
137
|
|
|
135
138
|
return {
|
|
136
139
|
data: results,
|
|
@@ -163,8 +166,29 @@ export default class QueryController extends Controller {
|
|
|
163
166
|
if (slowThreshold < 0)
|
|
164
167
|
slowThreshold = config.database?.queryLogging?.slowThreshold || 100
|
|
165
168
|
|
|
166
|
-
|
|
167
|
-
|
|
169
|
+
// Mutable builder: see getRecentQueries for why count and data
|
|
170
|
+
// queries are independent builders sharing one filter helper.
|
|
171
|
+
const applyFilters = (q: any): any => {
|
|
172
|
+
q = q.where('duration', '>=', slowThreshold)
|
|
173
|
+
|
|
174
|
+
if (connection !== 'all')
|
|
175
|
+
q = q.where('connection', '=', connection)
|
|
176
|
+
|
|
177
|
+
if (search) {
|
|
178
|
+
q = q.whereAny(['query', 'model', 'method', 'affected_tables'], 'like', `%${search}%`)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return q
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const totalResult = await applyFilters(db.selectFrom('query_logs'))
|
|
185
|
+
.select(db.fn.count('id').as('count'))
|
|
186
|
+
.executeTakeFirstOrThrow()
|
|
187
|
+
const total = Number(totalResult.count)
|
|
188
|
+
|
|
189
|
+
const offset = (page - 1) * perPage
|
|
190
|
+
|
|
191
|
+
const results = await applyFilters(db.selectFrom('query_logs'))
|
|
168
192
|
.select([
|
|
169
193
|
'id',
|
|
170
194
|
'query',
|
|
@@ -181,29 +205,10 @@ export default class QueryController extends Controller {
|
|
|
181
205
|
'indexes_used',
|
|
182
206
|
'missing_indexes',
|
|
183
207
|
])
|
|
184
|
-
.where('duration', '>=', slowThreshold)
|
|
185
208
|
.orderBy('duration', 'desc')
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (search) {
|
|
191
|
-
query = query.where(eb => eb.or([
|
|
192
|
-
eb('query', 'like', `%${search}%`),
|
|
193
|
-
eb('model', 'like', `%${search}%`),
|
|
194
|
-
eb('method', 'like', `%${search}%`),
|
|
195
|
-
eb('affected_tables', 'like', `%${search}%`),
|
|
196
|
-
]))
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
const countQuery = query.$call(q => q.select(db.fn.count('id').as('count')))
|
|
200
|
-
const totalResult = await countQuery.executeTakeFirstOrThrow()
|
|
201
|
-
const total = Number(totalResult.count)
|
|
202
|
-
|
|
203
|
-
const offset = (page - 1) * perPage
|
|
204
|
-
query = query.limit(perPage).offset(offset)
|
|
205
|
-
|
|
206
|
-
const results = await query.execute()
|
|
209
|
+
.limit(perPage)
|
|
210
|
+
.offset(offset)
|
|
211
|
+
.execute()
|
|
207
212
|
|
|
208
213
|
return {
|
|
209
214
|
data: results,
|
|
@@ -289,11 +294,11 @@ export default class QueryController extends Controller {
|
|
|
289
294
|
let query = db
|
|
290
295
|
.selectFrom('query_logs')
|
|
291
296
|
.select([
|
|
292
|
-
sql`strftime(
|
|
297
|
+
sql`strftime(${sql.literal(interval)}, executed_at)`.as('time_interval'),
|
|
293
298
|
db.fn.count('id').as('count'),
|
|
294
299
|
db.fn.avg('duration').as('avg_duration'),
|
|
295
300
|
])
|
|
296
|
-
.
|
|
301
|
+
.whereRaw(sql`executed_at >= datetime("now", ${sql.literal(timeConstraint)})`)
|
|
297
302
|
.groupBy('time_interval')
|
|
298
303
|
.orderBy('time_interval')
|
|
299
304
|
|
|
@@ -355,13 +360,16 @@ export default class QueryController extends Controller {
|
|
|
355
360
|
try {
|
|
356
361
|
const retentionDays = config.database?.queryLogging?.retention || 7
|
|
357
362
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
363
|
+
// Parameterized via db.unsafe: the delete builder can neither
|
|
364
|
+
// bind a datetime() expression nor render raw WHERE fragments.
|
|
365
|
+
const statement = await (db as any).unsafe(
|
|
366
|
+
`DELETE FROM query_logs WHERE executed_at < datetime("now", ?)`,
|
|
367
|
+
[`-${retentionDays} day`],
|
|
368
|
+
)
|
|
369
|
+
const result = typeof statement?.execute === 'function' ? await statement.execute() : statement
|
|
362
370
|
|
|
363
371
|
return {
|
|
364
|
-
pruned: result
|
|
372
|
+
pruned: Number(result?.changes ?? result?.numDeletedRows ?? 0),
|
|
365
373
|
retentionDays,
|
|
366
374
|
}
|
|
367
375
|
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/defaults",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.70.
|
|
5
|
+
"version": "0.70.113",
|
|
6
6
|
"description": "Default Stacks scaffold resources (views, layouts, components, preloader) — shipped so apps consuming the framework from node_modules get the same fallback resources a vendored checkout provides. Source of truth: storage/framework/defaults.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"license": "MIT",
|