create-rebe 1.0.0
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/LICENSE +21 -0
- package/README.md +54 -0
- package/bin/create-rebe.js +310 -0
- package/package.json +40 -0
- package/template/.env.example +141 -0
- package/template/.gitattributes +16 -0
- package/template/.nvmrc +1 -0
- package/template/.prettierignore +10 -0
- package/template/.prettierrc +12 -0
- package/template/LICENSE +21 -0
- package/template/README.md +150 -0
- package/template/SECURITY.md +133 -0
- package/template/app/hooks/register.hook.js +22 -0
- package/template/app/http/controllers/auth.controller.js +34 -0
- package/template/app/http/middlewares/auth.middleware.js +25 -0
- package/template/app/http/middlewares/register.middleware.js +18 -0
- package/template/app/http/validators/auth.validator.js +8 -0
- package/template/app/jobs/register.job.js +17 -0
- package/template/app/queue/register.queue.js +11 -0
- package/template/app/routes/api.route.js +18 -0
- package/template/app/routes/register.route.js +8 -0
- package/template/app/routes/web.route.js +7 -0
- package/template/app/socket/register.socket.js +15 -0
- package/template/config/app.config.js +12 -0
- package/template/config/bcrypt.config.js +7 -0
- package/template/config/cache.config.js +7 -0
- package/template/config/cors.config.js +37 -0
- package/template/config/cron.config.js +9 -0
- package/template/config/database.config.js +51 -0
- package/template/config/express.config.js +37 -0
- package/template/config/index.js +19 -0
- package/template/config/jwt.config.js +10 -0
- package/template/config/logger.config.js +10 -0
- package/template/config/mail.config.js +14 -0
- package/template/config/queue.config.js +11 -0
- package/template/config/redis.config.js +32 -0
- package/template/config/runtime.config.js +11 -0
- package/template/config/socket.config.js +15 -0
- package/template/config/storage.config.js +10 -0
- package/template/core/bootstrap.core.js +92 -0
- package/template/core/common/array.js +144 -0
- package/template/core/common/cache.js +100 -0
- package/template/core/common/collection.js +173 -0
- package/template/core/common/crypt.js +69 -0
- package/template/core/common/date.js +254 -0
- package/template/core/common/hash.js +61 -0
- package/template/core/common/object.js +155 -0
- package/template/core/common/path.js +80 -0
- package/template/core/common/storage.js +97 -0
- package/template/core/common/string.js +137 -0
- package/template/core/common/url.js +81 -0
- package/template/core/common.core.js +93 -0
- package/template/core/cron.core.js +141 -0
- package/template/core/database.core.js +113 -0
- package/template/core/error.core.js +83 -0
- package/template/core/express.core.js +161 -0
- package/template/core/hooks.core.js +47 -0
- package/template/core/jwt.core.js +81 -0
- package/template/core/logger.core.js +100 -0
- package/template/core/mailer.core.js +65 -0
- package/template/core/queue.core.js +226 -0
- package/template/core/redis.core.js +75 -0
- package/template/core/register.core.js +91 -0
- package/template/core/runtime.core.js +27 -0
- package/template/core/socket.core.js +93 -0
- package/template/core/validator.core.js +34 -0
- package/template/database/.gitkeep +0 -0
- package/template/database/models/post.model.js +26 -0
- package/template/database/models/user.model.js +30 -0
- package/template/docs/Bootstrap.md +50 -0
- package/template/docs/Common.md +48 -0
- package/template/docs/Cron.md +47 -0
- package/template/docs/Database.md +61 -0
- package/template/docs/Express.md +63 -0
- package/template/docs/Hooks.md +48 -0
- package/template/docs/Jwt.md +63 -0
- package/template/docs/Logger.md +60 -0
- package/template/docs/Mailer.md +50 -0
- package/template/docs/Queue.md +57 -0
- package/template/docs/README.md +32 -0
- package/template/docs/Redis.md +54 -0
- package/template/docs/Register.md +52 -0
- package/template/docs/Socket.md +55 -0
- package/template/docs/Validator.md +45 -0
- package/template/ecosystem.config.js +54 -0
- package/template/index.js +6 -0
- package/template/jest.config.js +27 -0
- package/template/jsconfig.json +37 -0
- package/template/logs/.gitkeep +0 -0
- package/template/nodemon.json +23 -0
- package/template/package-lock.json +7033 -0
- package/template/package.json +82 -0
- package/template/scripts/cli.js +258 -0
- package/template/storage/.gitkeep +0 -0
- package/template/tests/common.test.js +45 -0
- package/template/tests/crypt-hash-storage.test.js +44 -0
- package/template/tests/jwt.test.js +45 -0
- package/template/tests/register.test.js +55 -0
- package/template/tests/setup.js +11 -0
- package/template/tests/validator.test.js +65 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto')
|
|
4
|
+
|
|
5
|
+
// String helpers: casing, slugs, truncation and random tokens.
|
|
6
|
+
class Str {
|
|
7
|
+
// Uppercase the first character only.
|
|
8
|
+
static ucfirst(str) {
|
|
9
|
+
str = String(str ?? '')
|
|
10
|
+
return str.charAt(0).toUpperCase() + str.slice(1)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Lowercase the first character only.
|
|
14
|
+
static lcfirst(str) {
|
|
15
|
+
str = String(str ?? '')
|
|
16
|
+
return str.charAt(0).toLowerCase() + str.slice(1)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Capitalize every word.
|
|
20
|
+
static title(str) {
|
|
21
|
+
return String(str ?? '')
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Split words from camelCase, snake_case, kebab-case and spaces.
|
|
27
|
+
static words(str) {
|
|
28
|
+
return String(str ?? '')
|
|
29
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
30
|
+
.replace(/[_\-]+/g, ' ')
|
|
31
|
+
.trim()
|
|
32
|
+
.split(/\s+/)
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
static camel(str) {
|
|
37
|
+
const words = Str.words(str)
|
|
38
|
+
return words.map((w, i) => (i === 0 ? w.toLowerCase() : Str.ucfirst(w.toLowerCase()))).join('')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
static pascal(str) {
|
|
42
|
+
return Str.words(str)
|
|
43
|
+
.map((w) => Str.ucfirst(w.toLowerCase()))
|
|
44
|
+
.join('')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
static snake(str) {
|
|
48
|
+
return Str.words(str)
|
|
49
|
+
.map((w) => w.toLowerCase())
|
|
50
|
+
.join('_')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
static kebab(str) {
|
|
54
|
+
return Str.words(str)
|
|
55
|
+
.map((w) => w.toLowerCase())
|
|
56
|
+
.join('-')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// URL-friendly slug.
|
|
60
|
+
static slug(str, separator = '-') {
|
|
61
|
+
return String(str ?? '')
|
|
62
|
+
.normalize('NFKD')
|
|
63
|
+
.replace(/[̀-ͯ]/g, '')
|
|
64
|
+
.toLowerCase()
|
|
65
|
+
.replace(/[^a-z0-9]+/g, separator)
|
|
66
|
+
.replace(new RegExp(`^${separator}+|${separator}+$`, 'g'), '')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Cut a string to length, appending a suffix when truncated.
|
|
70
|
+
static truncate(str, length = 100, suffix = '...') {
|
|
71
|
+
str = String(str ?? '')
|
|
72
|
+
if (str.length <= length) return str
|
|
73
|
+
return str.slice(0, length - suffix.length).trimEnd() + suffix
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
static limitWords(str, count = 10, suffix = '...') {
|
|
77
|
+
const words = String(str ?? '').split(/\s+/)
|
|
78
|
+
if (words.length <= count) return str
|
|
79
|
+
return words.slice(0, count).join(' ') + suffix
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
static startsWith(str, prefix) {
|
|
83
|
+
return String(str ?? '').startsWith(prefix)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
static endsWith(str, suffix) {
|
|
87
|
+
return String(str ?? '').endsWith(suffix)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
static contains(str, needle) {
|
|
91
|
+
return String(str ?? '').includes(needle)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Ensure a string begins with the given prefix exactly once.
|
|
95
|
+
static start(str, prefix) {
|
|
96
|
+
str = String(str ?? '')
|
|
97
|
+
return str.startsWith(prefix) ? str : prefix + str
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Ensure a string ends with the given suffix exactly once.
|
|
101
|
+
static finish(str, suffix) {
|
|
102
|
+
str = String(str ?? '')
|
|
103
|
+
return str.endsWith(suffix) ? str : str + suffix
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
static mask(str, char = '*', start = 0, length = null) {
|
|
107
|
+
str = String(str ?? '')
|
|
108
|
+
const end = length === null ? str.length : start + length
|
|
109
|
+
return str
|
|
110
|
+
.split('')
|
|
111
|
+
.map((c, i) => (i >= start && i < end ? char : c))
|
|
112
|
+
.join('')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Cryptographically strong random hex string.
|
|
116
|
+
static random(length = 16) {
|
|
117
|
+
return crypto
|
|
118
|
+
.randomBytes(Math.ceil(length / 2))
|
|
119
|
+
.toString('hex')
|
|
120
|
+
.slice(0, length)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
static uuid() {
|
|
124
|
+
return crypto.randomUUID()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
static escapeHtml(str) {
|
|
128
|
+
const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
|
129
|
+
return String(str ?? '').replace(/[&<>"']/g, (c) => map[c])
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
static isEmpty(str) {
|
|
133
|
+
return str === null || str === undefined || String(str).trim() === ''
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = Str
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// URL helpers built on the WHATWG URL and URLSearchParams APIs.
|
|
4
|
+
class Url {
|
|
5
|
+
// Join base and path segments without doubling slashes.
|
|
6
|
+
static join(...parts) {
|
|
7
|
+
return parts
|
|
8
|
+
.filter((p) => p !== null && p !== undefined && p !== '')
|
|
9
|
+
.map((p, i) => {
|
|
10
|
+
let s = String(p)
|
|
11
|
+
if (i > 0) s = s.replace(/^\/+/, '')
|
|
12
|
+
if (i < parts.length - 1) s = s.replace(/\/+$/, '')
|
|
13
|
+
return s
|
|
14
|
+
})
|
|
15
|
+
.join('/')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Serialize an object to a query string (without the leading '?').
|
|
19
|
+
static buildQuery(params = {}) {
|
|
20
|
+
const search = new URLSearchParams()
|
|
21
|
+
for (const [key, value] of Object.entries(params)) {
|
|
22
|
+
if (value === null || value === undefined) continue
|
|
23
|
+
if (Array.isArray(value)) {
|
|
24
|
+
value.forEach((v) => search.append(key, String(v)))
|
|
25
|
+
} else {
|
|
26
|
+
search.append(key, String(value))
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return search.toString()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Parse a query string into a plain object, grouping repeated keys.
|
|
33
|
+
static parseQuery(query = '') {
|
|
34
|
+
const search = new URLSearchParams(query.replace(/^\?/, ''))
|
|
35
|
+
const out = {}
|
|
36
|
+
for (const key of new Set(search.keys())) {
|
|
37
|
+
const all = search.getAll(key)
|
|
38
|
+
out[key] = all.length > 1 ? all : all[0]
|
|
39
|
+
}
|
|
40
|
+
return out
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Append/override query parameters on an existing URL.
|
|
44
|
+
static withQuery(url, params = {}) {
|
|
45
|
+
const u = new URL(url)
|
|
46
|
+
for (const [key, value] of Object.entries(params)) {
|
|
47
|
+
if (value === null || value === undefined) {
|
|
48
|
+
u.searchParams.delete(key)
|
|
49
|
+
} else {
|
|
50
|
+
u.searchParams.set(key, String(value))
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return u.toString()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Break a URL into its component parts.
|
|
57
|
+
static parse(url) {
|
|
58
|
+
const u = new URL(url)
|
|
59
|
+
return {
|
|
60
|
+
protocol: u.protocol.replace(':', ''),
|
|
61
|
+
host: u.host,
|
|
62
|
+
hostname: u.hostname,
|
|
63
|
+
port: u.port,
|
|
64
|
+
path: u.pathname,
|
|
65
|
+
query: Url.parseQuery(u.search),
|
|
66
|
+
hash: u.hash.replace('#', ''),
|
|
67
|
+
origin: u.origin,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
static isValid(url) {
|
|
72
|
+
try {
|
|
73
|
+
new URL(url)
|
|
74
|
+
return true
|
|
75
|
+
} catch {
|
|
76
|
+
return false
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = Url
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Arr = require('@core/common/array')
|
|
4
|
+
const Str = require('@core/common/string')
|
|
5
|
+
const Obj = require('@core/common/object')
|
|
6
|
+
const Url = require('@core/common/url')
|
|
7
|
+
const Path = require('@core/common/path')
|
|
8
|
+
const Hash = require('@core/common/hash')
|
|
9
|
+
const Crypt = require('@core/common/crypt')
|
|
10
|
+
const Collection = require('@core/common/collection')
|
|
11
|
+
const DateTime = require('@core/common/date')
|
|
12
|
+
const Cache = require('@core/common/cache')
|
|
13
|
+
const Storage = require('@core/common/storage')
|
|
14
|
+
|
|
15
|
+
class Common {
|
|
16
|
+
static getEnv(key, defaultValue = null) {
|
|
17
|
+
const value = process.env[key]
|
|
18
|
+
return value !== undefined && value !== '' ? value : defaultValue
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static getEnvInt(key, defaultValue = null) {
|
|
22
|
+
const value = process.env[key]
|
|
23
|
+
if (value === undefined || value === '') return defaultValue
|
|
24
|
+
const parsed = parseInt(value, 10)
|
|
25
|
+
return isNaN(parsed) ? defaultValue : parsed
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static getEnvFloat(key, defaultValue = null) {
|
|
29
|
+
const value = process.env[key]
|
|
30
|
+
if (value === undefined || value === '') return defaultValue
|
|
31
|
+
const parsed = parseFloat(value)
|
|
32
|
+
return isNaN(parsed) ? defaultValue : parsed
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static getEnvBool(key, defaultValue = null) {
|
|
36
|
+
const value = process.env[key]
|
|
37
|
+
if (value === undefined || value === '') return defaultValue
|
|
38
|
+
return ['true', '1', 'yes', 'on', 'y', 't', 'enable', 'enabled'].includes(value.toLowerCase())
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
static getEnvArray(key, defaultValue = [], separator = ',') {
|
|
42
|
+
const value = process.env[key]
|
|
43
|
+
if (value === undefined || value === '') return defaultValue
|
|
44
|
+
return value
|
|
45
|
+
.split(separator)
|
|
46
|
+
.map((v) => v.trim())
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
static getEnvJson(key, defaultValue = null) {
|
|
51
|
+
const value = process.env[key]
|
|
52
|
+
if (value === undefined || value === '') return defaultValue
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(value)
|
|
55
|
+
} catch {
|
|
56
|
+
return defaultValue
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
static sleep(ms) {
|
|
61
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
static isEmpty(value) {
|
|
65
|
+
if (value === null || value === undefined) return true
|
|
66
|
+
if (typeof value === 'string') return value.trim() === ''
|
|
67
|
+
if (Array.isArray(value)) return value.length === 0
|
|
68
|
+
if (typeof value === 'object') return Object.keys(value).length === 0
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
static async attempt(fn) {
|
|
73
|
+
try {
|
|
74
|
+
return [null, await fn()]
|
|
75
|
+
} catch (error) {
|
|
76
|
+
return [error, null]
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
Common.Arr = Arr
|
|
82
|
+
Common.Str = Str
|
|
83
|
+
Common.Obj = Obj
|
|
84
|
+
Common.Url = Url
|
|
85
|
+
Common.Path = Path
|
|
86
|
+
Common.Hash = Hash
|
|
87
|
+
Common.Crypt = Crypt
|
|
88
|
+
Common.Collection = Collection
|
|
89
|
+
Common.Date = DateTime
|
|
90
|
+
Common.Cache = Cache
|
|
91
|
+
Common.Storage = Storage
|
|
92
|
+
|
|
93
|
+
module.exports = Common
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto')
|
|
4
|
+
const cron = require('node-cron')
|
|
5
|
+
|
|
6
|
+
const config = require('@config/index')
|
|
7
|
+
const logger = require('@core/logger.core')
|
|
8
|
+
const Register = require('@core/register.core')
|
|
9
|
+
|
|
10
|
+
const REGISTER_FILE = Register.path('app', 'jobs', 'register.job.js')
|
|
11
|
+
const TABLE = 'cron_runs'
|
|
12
|
+
|
|
13
|
+
class Cron {
|
|
14
|
+
static jobs = new Map()
|
|
15
|
+
static _db = null
|
|
16
|
+
|
|
17
|
+
static define(name, expression, handler, options = {}) {
|
|
18
|
+
if (Cron.jobs.has(name)) {
|
|
19
|
+
logger.warn(`Cron job "${name}" is already defined; overwriting`)
|
|
20
|
+
}
|
|
21
|
+
if (!cron.validate(expression)) {
|
|
22
|
+
throw new Error(`Invalid cron expression for "${name}": ${expression}`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
Cron.jobs.set(name, {
|
|
26
|
+
name,
|
|
27
|
+
expression,
|
|
28
|
+
handler,
|
|
29
|
+
timezone: options.timezone || config.cron.timezone,
|
|
30
|
+
runOnInit: options.runOnInit || false,
|
|
31
|
+
task: null,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
return Cron
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
static async start() {
|
|
38
|
+
if (!config.cron.enabled) {
|
|
39
|
+
logger.info('Cron is disabled (CRON_ENABLED=false)')
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
await Register.invoke(REGISTER_FILE, [Cron], { label: 'app/jobs/register.job.js' })
|
|
44
|
+
|
|
45
|
+
if (config.cron.history && config.db.enabled) {
|
|
46
|
+
try {
|
|
47
|
+
const Database = require('@core/database.core')
|
|
48
|
+
if (Database.sequelize) {
|
|
49
|
+
Cron._db = Database.sequelize
|
|
50
|
+
await Cron._ensureTable()
|
|
51
|
+
}
|
|
52
|
+
} catch (err) {
|
|
53
|
+
logger.warn(`Cron: DB history unavailable: ${err.message}`)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const job of Cron.jobs.values()) {
|
|
58
|
+
job.task = cron.schedule(job.expression, () => Cron._run(job), {
|
|
59
|
+
timezone: job.timezone,
|
|
60
|
+
})
|
|
61
|
+
if (job.runOnInit) Cron._run(job)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
logger.info(`Cron started ${Cron.jobs.size} job(s)${Cron.jobs.size ? `: ${[...Cron.jobs.keys()].join(', ')}` : ''}`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
static async _run(job) {
|
|
68
|
+
const runId = crypto.randomUUID()
|
|
69
|
+
const startedAt = new Date()
|
|
70
|
+
|
|
71
|
+
if (Cron._db) {
|
|
72
|
+
await Cron._db
|
|
73
|
+
.query(`INSERT INTO \`${TABLE}\` (id, job_name, started_at, status, created_at) VALUES (:id, :name, :startedAt, 'running', :now)`, {
|
|
74
|
+
replacements: { id: runId, name: job.name, startedAt, now: startedAt },
|
|
75
|
+
})
|
|
76
|
+
.catch((err) => logger.debug(`Cron: failed to record run start for "${job.name}": ${err.message}`))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
logger.debug(`Cron running: ${job.name}`)
|
|
81
|
+
await job.handler()
|
|
82
|
+
const finishedAt = new Date()
|
|
83
|
+
if (Cron._db) {
|
|
84
|
+
await Cron._db.query(`UPDATE \`${TABLE}\` SET status = 'completed', finished_at = :finishedAt, duration_ms = :duration WHERE id = :id`, { replacements: { finishedAt, duration: finishedAt - startedAt, id: runId } }).catch(() => {})
|
|
85
|
+
}
|
|
86
|
+
} catch (err) {
|
|
87
|
+
logger.error(`Cron job "${job.name}" failed`, err)
|
|
88
|
+
const finishedAt = new Date()
|
|
89
|
+
if (Cron._db) {
|
|
90
|
+
await Cron._db.query(`UPDATE \`${TABLE}\` SET status = 'failed', finished_at = :finishedAt, duration_ms = :duration, error = :error WHERE id = :id`, { replacements: { finishedAt, duration: finishedAt - startedAt, error: err.message, id: runId } }).catch(() => {})
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
static async runNow(name) {
|
|
96
|
+
const job = Cron.jobs.get(name)
|
|
97
|
+
if (!job) throw new Error(`Cron job "${name}" is not defined`)
|
|
98
|
+
return Cron._run(job)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
static remove(name) {
|
|
102
|
+
const job = Cron.jobs.get(name)
|
|
103
|
+
if (job?.task) job.task.stop()
|
|
104
|
+
return Cron.jobs.delete(name)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
static stop() {
|
|
108
|
+
for (const job of Cron.jobs.values()) {
|
|
109
|
+
if (job.task) job.task.stop()
|
|
110
|
+
}
|
|
111
|
+
logger.info('Cron stopped')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
static list() {
|
|
115
|
+
return [...Cron.jobs.values()].map(({ name, expression, timezone }) => ({
|
|
116
|
+
name,
|
|
117
|
+
expression,
|
|
118
|
+
timezone,
|
|
119
|
+
}))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
static async _ensureTable() {
|
|
123
|
+
await Cron._db.query(`
|
|
124
|
+
CREATE TABLE IF NOT EXISTS \`${TABLE}\` (
|
|
125
|
+
\`id\` VARCHAR(36) NOT NULL,
|
|
126
|
+
\`job_name\` VARCHAR(100) NOT NULL,
|
|
127
|
+
\`started_at\` DATETIME NOT NULL,
|
|
128
|
+
\`finished_at\` DATETIME NULL,
|
|
129
|
+
\`status\` ENUM('running','completed','failed') NOT NULL DEFAULT 'running',
|
|
130
|
+
\`error\` TEXT NULL,
|
|
131
|
+
\`duration_ms\` INT NULL,
|
|
132
|
+
\`created_at\` DATETIME NOT NULL,
|
|
133
|
+
PRIMARY KEY (\`id\`),
|
|
134
|
+
INDEX idx_job_name (\`job_name\`),
|
|
135
|
+
INDEX idx_started_at (\`started_at\`)
|
|
136
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
|
137
|
+
`)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = Cron
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const { Sequelize, DataTypes, Model, Op } = require('sequelize')
|
|
6
|
+
|
|
7
|
+
const config = require('@config/index')
|
|
8
|
+
const logger = require('@core/logger.core')
|
|
9
|
+
|
|
10
|
+
const MODELS_DIR = path.resolve(process.cwd(), 'database', 'models')
|
|
11
|
+
|
|
12
|
+
class Database {
|
|
13
|
+
static sequelize = null
|
|
14
|
+
static models = new Map()
|
|
15
|
+
|
|
16
|
+
// ── Connection ───────────────────────────────────────────────────────────
|
|
17
|
+
static async connect() {
|
|
18
|
+
if (Database.sequelize) return Database.sequelize
|
|
19
|
+
|
|
20
|
+
const db = config.db
|
|
21
|
+
|
|
22
|
+
Database.sequelize = new Sequelize(db.database, db.username, db.password, {
|
|
23
|
+
host: db.host,
|
|
24
|
+
port: db.port,
|
|
25
|
+
dialect: db.dialect,
|
|
26
|
+
timezone: db.timezone,
|
|
27
|
+
logging: db.logging ? (msg) => logger.debug(msg) : false,
|
|
28
|
+
benchmark: db.benchmark,
|
|
29
|
+
logQueryParameters: db.logQueryParameters,
|
|
30
|
+
dialectOptions: db.dialectOptions,
|
|
31
|
+
pool: db.pool,
|
|
32
|
+
retry: db.retry,
|
|
33
|
+
define: db.define,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
await Database.sequelize.authenticate()
|
|
38
|
+
logger.info(`Database connected (${db.dialect}://${db.host}:${db.port}/${db.database})`)
|
|
39
|
+
Database.loadModels()
|
|
40
|
+
} catch (err) {
|
|
41
|
+
logger.error('Database connection failed', err)
|
|
42
|
+
throw err
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return Database.sequelize
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
static async disconnect() {
|
|
49
|
+
if (Database.sequelize) {
|
|
50
|
+
await Database.sequelize.close()
|
|
51
|
+
Database.sequelize = null
|
|
52
|
+
Database.models.clear()
|
|
53
|
+
logger.info('Database connection closed')
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Models ───────────────────────────────────────────────────────────────
|
|
58
|
+
static loadModels() {
|
|
59
|
+
if (!fs.existsSync(MODELS_DIR)) {
|
|
60
|
+
logger.warn(`Models directory not found: ${MODELS_DIR}`)
|
|
61
|
+
return Database.models
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const files = fs.readdirSync(MODELS_DIR).filter((file) => file.endsWith('.js') && !file.startsWith('_'))
|
|
65
|
+
|
|
66
|
+
for (const file of files) {
|
|
67
|
+
try {
|
|
68
|
+
const definition = require(path.join(MODELS_DIR, file))
|
|
69
|
+
const factory = definition.default || definition
|
|
70
|
+
|
|
71
|
+
if (typeof factory !== 'function') {
|
|
72
|
+
logger.warn(`Skipped model "${file}": expected a (sequelize, DataTypes) factory export`)
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const model = factory(Database.sequelize, DataTypes)
|
|
77
|
+
if (model?.name) {
|
|
78
|
+
Database.models.set(model.name, model)
|
|
79
|
+
}
|
|
80
|
+
} catch (err) {
|
|
81
|
+
logger.error(`Failed to load model "${file}"`, err)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Second pass: wire up associations once all models are loaded.
|
|
86
|
+
const modelsAsObject = Object.fromEntries(Database.models)
|
|
87
|
+
for (const model of Database.models.values()) {
|
|
88
|
+
if (typeof model.associate === 'function') {
|
|
89
|
+
try {
|
|
90
|
+
model.associate(modelsAsObject)
|
|
91
|
+
} catch (err) {
|
|
92
|
+
logger.error(`Failed to associate model "${model.name}"`, err)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const names = [...Database.models.keys()]
|
|
98
|
+
logger.info(`Loaded ${names.length} model(s)${names.length ? `: ${names.join(', ')}` : ''}`)
|
|
99
|
+
return Database.models
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
static model(name) {
|
|
103
|
+
if (!Database.models.has(name)) throw new Error(`Model "${name}" is not registered`)
|
|
104
|
+
return Database.models.get(name)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
Database.Sequelize = Sequelize
|
|
109
|
+
Database.DataTypes = DataTypes
|
|
110
|
+
Database.Model = Model
|
|
111
|
+
Database.Op = Op
|
|
112
|
+
|
|
113
|
+
module.exports = Database
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const logger = require('@core/logger.core')
|
|
4
|
+
const config = require('@config/index')
|
|
5
|
+
const Common = require('@core/common.core')
|
|
6
|
+
|
|
7
|
+
module.exports = class ErrorHandler {
|
|
8
|
+
static isProd = config.app.env === 'production'
|
|
9
|
+
|
|
10
|
+
static setExceptionHandler() {
|
|
11
|
+
process.on('uncaughtException', (err) => {
|
|
12
|
+
logger.error('Uncaught exception', err)
|
|
13
|
+
process.exit(1)
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
static setRejectionHandler() {
|
|
18
|
+
process.on('unhandledRejection', (reason) => {
|
|
19
|
+
const err = reason instanceof Error ? reason : new Error(String(reason))
|
|
20
|
+
logger.error('Unhandled rejection', err)
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static notFoundHandler(req, res, _next) {
|
|
25
|
+
const status = 404
|
|
26
|
+
const message = `Cannot ${req.method} ${req.originalUrl}`
|
|
27
|
+
|
|
28
|
+
if (ErrorHandler._wantsJson(req)) {
|
|
29
|
+
return res.status(status).json({
|
|
30
|
+
success: false,
|
|
31
|
+
status,
|
|
32
|
+
message,
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return res.status(status).send(`
|
|
37
|
+
<!DOCTYPE html>
|
|
38
|
+
<html>
|
|
39
|
+
<head><title>404 Not Found</title></head>
|
|
40
|
+
<body>
|
|
41
|
+
<h1>404 Not Found</h1>
|
|
42
|
+
<p>${Common.Str.escapeHtml(message)}</p>
|
|
43
|
+
</body>
|
|
44
|
+
</html>
|
|
45
|
+
`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
static expressErrorHandler(err, req, res, _next) {
|
|
49
|
+
const status = err.status || err.statusCode || 500
|
|
50
|
+
const message = err.message || 'Internal server error'
|
|
51
|
+
const detail = ErrorHandler.isProd ? undefined : err.stack
|
|
52
|
+
|
|
53
|
+
logger.error(err)
|
|
54
|
+
|
|
55
|
+
if (ErrorHandler._wantsJson(req)) {
|
|
56
|
+
return res.status(status).json({
|
|
57
|
+
success: false,
|
|
58
|
+
status,
|
|
59
|
+
message,
|
|
60
|
+
...(detail && { stack: detail }),
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return res.status(status).send(`
|
|
65
|
+
<!DOCTYPE html>
|
|
66
|
+
<html>
|
|
67
|
+
<head><title>${status} Error</title></head>
|
|
68
|
+
<body>
|
|
69
|
+
<h1>${status} - ${Common.Str.escapeHtml(message)}</h1>
|
|
70
|
+
${detail ? `<pre>${Common.Str.escapeHtml(detail)}</pre>` : ''}
|
|
71
|
+
</body>
|
|
72
|
+
</html>
|
|
73
|
+
`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Detect whether the client expects JSON:
|
|
77
|
+
// - Explicit Accept: application/json header
|
|
78
|
+
// - XHR request (axios, fetch with X-Requested-With)
|
|
79
|
+
// - API path convention (/api/*)
|
|
80
|
+
static _wantsJson(req) {
|
|
81
|
+
return req.xhr || req.path.startsWith('/api') || (req.headers.accept && req.headers.accept.includes('application/json'))
|
|
82
|
+
}
|
|
83
|
+
}
|