webspresso 0.0.90 → 0.0.91
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/README.md +358 -8
- package/bin/commands/service.js +193 -0
- package/bin/webspresso.js +2 -0
- package/core/auth/middleware.js +9 -0
- package/core/compression/compressible.js +51 -0
- package/core/compression/compression-stream.js +59 -0
- package/core/compression/index.js +25 -0
- package/core/compression/middleware.js +301 -0
- package/core/compression/negotiate.js +153 -0
- package/core/errors/base.js +31 -0
- package/core/errors/domain.js +193 -0
- package/core/errors/http.js +133 -0
- package/core/errors/index.js +77 -0
- package/core/errors/middleware.js +162 -0
- package/core/errors/normalize.js +171 -0
- package/core/orm/complexity.js +120 -0
- package/core/orm/events.js +64 -14
- package/core/orm/index.js +45 -10
- package/core/orm/model.js +2 -0
- package/core/orm/query-builder.js +30 -4
- package/core/orm/repository.js +57 -25
- package/core/orm/transaction.js +39 -5
- package/core/orm/utils.js +6 -3
- package/core/queue/adapters/database.js +284 -0
- package/core/queue/adapters/memory.js +155 -0
- package/core/queue/adapters/redis.js +336 -0
- package/core/queue/index.js +21 -0
- package/core/queue/job.js +103 -0
- package/core/queue/manager.js +387 -0
- package/core/realtime/adapters/redis.js +431 -0
- package/core/realtime/adapters/socket-io.js +203 -0
- package/core/realtime/adapters/sse.js +210 -0
- package/core/realtime/adapters/websocket.js +337 -0
- package/core/realtime/auth-manager.js +140 -0
- package/core/realtime/core.js +359 -0
- package/core/realtime/emitter.js +98 -0
- package/core/realtime/errors.js +71 -0
- package/core/realtime/index.js +79 -0
- package/core/realtime/reconnect-manager.js +118 -0
- package/core/realtime/registry.js +243 -0
- package/core/shutdown/http-adapter.js +134 -0
- package/core/shutdown/index.js +12 -0
- package/core/shutdown/shutdown-manager.js +312 -0
- package/core/ssr/index.js +13 -0
- package/core/ssr/stream.js +174 -0
- package/index.d.ts +829 -52
- package/index.js +103 -0
- package/package.json +22 -2
- package/plugins/admin-panel/api.js +37 -4
- package/plugins/admin-panel/app.js +57 -17
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +4 -1
- package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +37 -23
- package/plugins/admin-panel/client/parts/08-record-list.js +17 -24
- package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
- package/plugins/admin-panel/core/admin-module.js +2 -2
- package/plugins/admin-panel/core/api-extensions.js +149 -0
- package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
- package/plugins/admin-panel/index.js +90 -17
- package/plugins/admin-panel/modules/custom-pages.js +144 -45
- package/plugins/admin-panel/modules/menu.js +85 -0
- package/plugins/admin-panel/modules/user-management.js +5 -2
- package/plugins/admin-panel/vendor/mithril.min.js +1 -0
- package/plugins/admin-panel/vendor/quill.min.js +7 -0
- package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
- package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
- package/plugins/basic-auth/index.js +293 -0
- package/plugins/data-exchange/export-xlsx.js +5 -1
- package/plugins/data-exchange/import.js +16 -2
- package/plugins/data-exchange/index.js +14 -0
- package/plugins/email/index.js +14 -1
- package/plugins/index.js +29 -0
- package/plugins/queue/index.js +108 -0
- package/plugins/realtime/index.js +150 -0
- package/plugins/recaptcha/index.js +11 -0
- package/plugins/redirect/index.js +9 -1
- package/plugins/rest-resources/index.js +280 -11
- package/plugins/upload/index.js +16 -0
- package/src/app-context.js +46 -6
- package/src/file-router.js +196 -21
- package/src/helpers.js +20 -2
- package/src/plugin-manager.js +124 -4
- package/src/server.js +304 -44
- package/src/services/builtins/auth.js +273 -0
- package/src/services/builtins/exchange.js +229 -0
- package/src/services/builtins/mail.js +134 -0
- package/src/services/builtins/media.js +196 -0
- package/src/services/builtins/system.js +221 -0
- package/src/services/discovery.js +140 -0
- package/src/services/executor.js +292 -0
- package/src/services/index.js +70 -0
- package/src/services/memoize.js +256 -0
- package/src/services/registry.js +288 -0
- package/src/services/validator.js +281 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in Auth Services
|
|
3
|
+
* @module src/services/builtins/auth
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { z } = require('zod');
|
|
7
|
+
const { hash, verify } = require('../../../core/auth/hash');
|
|
8
|
+
const {
|
|
9
|
+
TOKEN_TYPES,
|
|
10
|
+
createKnexAuthTokensAdapter,
|
|
11
|
+
createAuthToken,
|
|
12
|
+
verifyAuthToken,
|
|
13
|
+
consumeAuthToken,
|
|
14
|
+
} = require('../../../core/auth/tokens');
|
|
15
|
+
const {
|
|
16
|
+
ValidationError,
|
|
17
|
+
UnauthorizedError,
|
|
18
|
+
NotFoundError,
|
|
19
|
+
} = require('../../../core/errors');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create built-in auth services map
|
|
23
|
+
* @param {Object} [options]
|
|
24
|
+
* @param {string} [options.userModel='User'] - Name of user model
|
|
25
|
+
* @param {Object} [options.fields] - Field name mappings
|
|
26
|
+
* @returns {Record<string, Object>}
|
|
27
|
+
*/
|
|
28
|
+
function createAuthServices(options = {}) {
|
|
29
|
+
const userModel = options.userModel || 'User';
|
|
30
|
+
const fields = {
|
|
31
|
+
email: 'email',
|
|
32
|
+
password: 'password',
|
|
33
|
+
name: 'name',
|
|
34
|
+
role: 'role',
|
|
35
|
+
active: 'active',
|
|
36
|
+
emailVerifiedAt: 'email_verified_at',
|
|
37
|
+
...(options.fields || {}),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
'auth.login': {
|
|
42
|
+
schema: z.object({
|
|
43
|
+
email: z.string().email(),
|
|
44
|
+
password: z.string().min(1),
|
|
45
|
+
remember: z.boolean().optional(),
|
|
46
|
+
}),
|
|
47
|
+
async handler(input, ctx) {
|
|
48
|
+
const { email, password, remember } = input;
|
|
49
|
+
const db = ctx.db;
|
|
50
|
+
if (!db) {
|
|
51
|
+
throw new Error('Database instance is required in context for auth.login');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const repo = db.getRepository(userModel);
|
|
55
|
+
const user = await repo.findOne({ [fields.email]: email });
|
|
56
|
+
|
|
57
|
+
if (!user) {
|
|
58
|
+
throw new UnauthorizedError('Invalid email or password');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (user[fields.active] === false || user[fields.active] === 0) {
|
|
62
|
+
throw new UnauthorizedError('Account is deactivated');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const isValid = await verify(password, user[fields.password]);
|
|
66
|
+
if (!isValid) {
|
|
67
|
+
throw new UnauthorizedError('Invalid email or password');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Exclude password from returned user object
|
|
71
|
+
const { [fields.password]: _, ...safeUser } = user;
|
|
72
|
+
|
|
73
|
+
// Attach to session if running inside HTTP request context
|
|
74
|
+
if (ctx.req && ctx.req.session) {
|
|
75
|
+
ctx.req.session.user = safeUser;
|
|
76
|
+
if (remember && ctx.req.session.cookie) {
|
|
77
|
+
// Extend session maxAge if remember is checked (30 days)
|
|
78
|
+
ctx.req.session.cookie.maxAge = 30 * 24 * 60 * 60 * 1000;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { user: safeUser };
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
'auth.register': {
|
|
87
|
+
schema: z.object({
|
|
88
|
+
email: z.string().email(),
|
|
89
|
+
password: z.string().min(8, 'Password must be at least 8 characters long'),
|
|
90
|
+
name: z.string().optional(),
|
|
91
|
+
role: z.string().optional(),
|
|
92
|
+
}),
|
|
93
|
+
transaction: true,
|
|
94
|
+
async handler(input, ctx) {
|
|
95
|
+
const { email, password, name, role } = input;
|
|
96
|
+
const db = ctx.db;
|
|
97
|
+
const trx = ctx.trx;
|
|
98
|
+
if (!db) {
|
|
99
|
+
throw new Error('Database instance is required in context for auth.register');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const repo = db.getRepository(userModel);
|
|
103
|
+
const existing = await repo.findOne({ [fields.email]: email }, { trx });
|
|
104
|
+
if (existing) {
|
|
105
|
+
throw new ValidationError('Email is already registered', [
|
|
106
|
+
{ field: 'email', message: 'Email is already registered' },
|
|
107
|
+
]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const hashedPassword = await hash(password);
|
|
111
|
+
const userData = {
|
|
112
|
+
[fields.email]: email,
|
|
113
|
+
[fields.password]: hashedPassword,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
if (name !== undefined) userData[fields.name] = name;
|
|
117
|
+
if (role !== undefined) userData[fields.role] = role;
|
|
118
|
+
|
|
119
|
+
const user = await repo.create(userData, { trx });
|
|
120
|
+
const { [fields.password]: _, ...safeUser } = user;
|
|
121
|
+
|
|
122
|
+
return { user: safeUser };
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
'auth.request-password-reset': {
|
|
127
|
+
schema: z.object({
|
|
128
|
+
email: z.string().email(),
|
|
129
|
+
ttlMs: z.number().positive().default(60 * 60 * 1000), // Default 1 hour
|
|
130
|
+
}),
|
|
131
|
+
async handler(input, ctx) {
|
|
132
|
+
const { email, ttlMs } = input;
|
|
133
|
+
const db = ctx.db;
|
|
134
|
+
if (!db) {
|
|
135
|
+
throw new Error('Database instance is required in context for auth.request-password-reset');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const repo = db.getRepository(userModel);
|
|
139
|
+
const user = await repo.findOne({ [fields.email]: email });
|
|
140
|
+
|
|
141
|
+
// Safe timing-attack resistant behavior: do not leak if account exists
|
|
142
|
+
if (!user) {
|
|
143
|
+
return { sent: true, userId: null, rawToken: null };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const adapter = createKnexAuthTokensAdapter(db.knex || db);
|
|
147
|
+
const { rawToken, expiresAt } = await createAuthToken(
|
|
148
|
+
adapter,
|
|
149
|
+
TOKEN_TYPES.PASSWORD_RESET,
|
|
150
|
+
user.id,
|
|
151
|
+
ttlMs
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
sent: true,
|
|
156
|
+
userId: user.id,
|
|
157
|
+
rawToken,
|
|
158
|
+
expiresAt,
|
|
159
|
+
};
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
'auth.reset-password': {
|
|
164
|
+
schema: z.object({
|
|
165
|
+
token: z.string().min(1, 'Reset token is required'),
|
|
166
|
+
password: z.string().min(8, 'Password must be at least 8 characters long'),
|
|
167
|
+
}),
|
|
168
|
+
transaction: true,
|
|
169
|
+
async handler(input, ctx) {
|
|
170
|
+
const { token, password } = input;
|
|
171
|
+
const db = ctx.db;
|
|
172
|
+
const trx = ctx.trx;
|
|
173
|
+
if (!db) {
|
|
174
|
+
throw new Error('Database instance is required in context for auth.reset-password');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const adapter = createKnexAuthTokensAdapter(trx || db.knex || db);
|
|
178
|
+
const verified = await verifyAuthToken(adapter, TOKEN_TYPES.PASSWORD_RESET, token);
|
|
179
|
+
|
|
180
|
+
if (!verified) {
|
|
181
|
+
throw new ValidationError('Invalid or expired password reset token', [
|
|
182
|
+
{ field: 'token', message: 'Token is invalid or has expired' },
|
|
183
|
+
]);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const hashedPassword = await hash(password);
|
|
187
|
+
const repo = db.getRepository(userModel);
|
|
188
|
+
await repo.update(verified.userId, { [fields.password]: hashedPassword }, { trx });
|
|
189
|
+
|
|
190
|
+
await consumeAuthToken(adapter, verified.tokenHash);
|
|
191
|
+
|
|
192
|
+
return { success: true, userId: verified.userId };
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
'auth.change-password': {
|
|
197
|
+
schema: z.object({
|
|
198
|
+
currentPassword: z.string().min(1, 'Current password is required'),
|
|
199
|
+
newPassword: z.string().min(8, 'New password must be at least 8 characters long'),
|
|
200
|
+
}),
|
|
201
|
+
auth: true, // Requires user in context
|
|
202
|
+
transaction: true,
|
|
203
|
+
async handler(input, ctx) {
|
|
204
|
+
const { currentPassword, newPassword } = input;
|
|
205
|
+
const db = ctx.db;
|
|
206
|
+
const trx = ctx.trx;
|
|
207
|
+
const currentUser = ctx.user || (ctx.auth && ctx.auth.user) || (ctx.req && ctx.req.session && ctx.req.session.user);
|
|
208
|
+
|
|
209
|
+
if (!currentUser || !currentUser.id) {
|
|
210
|
+
throw new UnauthorizedError('User authentication required');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const repo = db.getRepository(userModel);
|
|
214
|
+
const user = await repo.findById(currentUser.id, { trx });
|
|
215
|
+
|
|
216
|
+
if (!user) {
|
|
217
|
+
throw new NotFoundError('User not found');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const isCurrentValid = await verify(currentPassword, user[fields.password]);
|
|
221
|
+
if (!isCurrentValid) {
|
|
222
|
+
throw new ValidationError('Current password does not match', [
|
|
223
|
+
{ field: 'currentPassword', message: 'Current password does not match' },
|
|
224
|
+
]);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const hashedNewPassword = await hash(newPassword);
|
|
228
|
+
await repo.update(currentUser.id, { [fields.password]: hashedNewPassword }, { trx });
|
|
229
|
+
|
|
230
|
+
return { success: true };
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
'auth.verify-email': {
|
|
235
|
+
schema: z.object({
|
|
236
|
+
token: z.string().min(1, 'Verification token is required'),
|
|
237
|
+
}),
|
|
238
|
+
transaction: true,
|
|
239
|
+
async handler(input, ctx) {
|
|
240
|
+
const { token } = input;
|
|
241
|
+
const db = ctx.db;
|
|
242
|
+
const trx = ctx.trx;
|
|
243
|
+
if (!db) {
|
|
244
|
+
throw new Error('Database instance is required in context for auth.verify-email');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const adapter = createKnexAuthTokensAdapter(trx || db.knex || db);
|
|
248
|
+
const verified = await verifyAuthToken(adapter, TOKEN_TYPES.EMAIL_VERIFY, token);
|
|
249
|
+
|
|
250
|
+
if (!verified) {
|
|
251
|
+
throw new ValidationError('Invalid or expired verification token', [
|
|
252
|
+
{ field: 'token', message: 'Verification token is invalid or has expired' },
|
|
253
|
+
]);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const repo = db.getRepository(userModel);
|
|
257
|
+
await repo.update(
|
|
258
|
+
verified.userId,
|
|
259
|
+
{ [fields.emailVerifiedAt]: new Date() },
|
|
260
|
+
{ trx }
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
await consumeAuthToken(adapter, verified.tokenHash);
|
|
264
|
+
|
|
265
|
+
return { success: true, userId: verified.userId };
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
module.exports = {
|
|
272
|
+
createAuthServices,
|
|
273
|
+
};
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in Data Exchange & Spreadsheet Services
|
|
3
|
+
* @module src/services/builtins/exchange
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { z } = require('zod');
|
|
7
|
+
const { ValidationError, NotFoundError } = require('../../../core/errors');
|
|
8
|
+
const { getModel } = require('../../../core/orm/model');
|
|
9
|
+
const { buildXlsxBuffer } = require('../../../plugins/data-exchange/export-xlsx');
|
|
10
|
+
const {
|
|
11
|
+
parseCsvToRows,
|
|
12
|
+
parseXlsxToRows,
|
|
13
|
+
buildPayloadForRow,
|
|
14
|
+
allowedImportColumns,
|
|
15
|
+
} = require('../../../plugins/data-exchange/import');
|
|
16
|
+
const { buildHeaderMapping, dataRowsToObjects } = require('../../../plugins/data-exchange/parse-table');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Convert base64 data to Buffer
|
|
20
|
+
* @param {string} base64Str
|
|
21
|
+
* @returns {Buffer}
|
|
22
|
+
*/
|
|
23
|
+
function parseBase64Buffer(base64Str) {
|
|
24
|
+
const match = base64Str.match(/^data:([^;]+);base64,(.+)$/);
|
|
25
|
+
if (match) {
|
|
26
|
+
return Buffer.from(match[2], 'base64');
|
|
27
|
+
}
|
|
28
|
+
return Buffer.from(base64Str, 'base64');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Create built-in exchange services map
|
|
33
|
+
* @param {Object} [options]
|
|
34
|
+
* @param {Object} [options.db]
|
|
35
|
+
* @returns {Record<string, Object>}
|
|
36
|
+
*/
|
|
37
|
+
function createExchangeServices(options = {}) {
|
|
38
|
+
const db = options.db || null;
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
'exchange.export': {
|
|
42
|
+
schema: z.object({
|
|
43
|
+
model: z.string().min(1, 'Model name is required'),
|
|
44
|
+
ids: z.array(z.union([z.string(), z.number()])).optional(),
|
|
45
|
+
where: z.record(z.any()).optional(),
|
|
46
|
+
limit: z.number().int().positive().default(10000),
|
|
47
|
+
}),
|
|
48
|
+
auth: 'admin',
|
|
49
|
+
async handler(input, ctx) {
|
|
50
|
+
const { model: modelName, ids, where, limit } = input;
|
|
51
|
+
const activeDb = ctx.db || db;
|
|
52
|
+
if (!activeDb) {
|
|
53
|
+
throw new Error('Database instance is required in context for exchange.export');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const model = activeDb.getModel ? activeDb.getModel(modelName) : getModel(modelName);
|
|
57
|
+
if (!model) {
|
|
58
|
+
throw new NotFoundError(`Model "${modelName}" not found`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const repo = activeDb.getRepository(modelName);
|
|
62
|
+
let records = [];
|
|
63
|
+
|
|
64
|
+
if (ids && ids.length > 0) {
|
|
65
|
+
records = await repo.query().whereIn(model.primaryKey, ids).limit(limit).list();
|
|
66
|
+
} else if (where && Object.keys(where).length > 0) {
|
|
67
|
+
records = await repo.findAll(where, { limit });
|
|
68
|
+
} else {
|
|
69
|
+
records = await repo.query().limit(limit).list();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const buffer = await buildXlsxBuffer(model, records);
|
|
73
|
+
const safeName = String(model.name).replace(/[^\w.-]+/g, '_');
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
buffer,
|
|
77
|
+
filename: `${safeName}_export.xlsx`,
|
|
78
|
+
rowCount: records.length,
|
|
79
|
+
model: modelName,
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
'exchange.import': {
|
|
85
|
+
schema: z.object({
|
|
86
|
+
model: z.string().min(1, 'Model name is required'),
|
|
87
|
+
buffer: z.any().optional(),
|
|
88
|
+
base64: z.string().optional(),
|
|
89
|
+
csvText: z.string().optional(),
|
|
90
|
+
mode: z.enum(['insert', 'upsert']).default('insert'),
|
|
91
|
+
upsertKey: z.string().optional(),
|
|
92
|
+
}),
|
|
93
|
+
auth: 'admin',
|
|
94
|
+
transaction: true,
|
|
95
|
+
async handler(input, ctx) {
|
|
96
|
+
const { model: modelName, buffer: rawBuffer, base64, csvText, mode, upsertKey: userUpsertKey } = input;
|
|
97
|
+
const activeDb = ctx.db || db;
|
|
98
|
+
const trx = ctx.trx;
|
|
99
|
+
if (!activeDb) {
|
|
100
|
+
throw new Error('Database instance is required in context for exchange.import');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const model = activeDb.getModel ? activeDb.getModel(modelName) : getModel(modelName);
|
|
104
|
+
if (!model) {
|
|
105
|
+
throw new NotFoundError(`Model "${modelName}" not found`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let buffer;
|
|
109
|
+
let isCsv = false;
|
|
110
|
+
|
|
111
|
+
if (csvText) {
|
|
112
|
+
buffer = Buffer.from(csvText, 'utf8');
|
|
113
|
+
isCsv = true;
|
|
114
|
+
} else if (rawBuffer) {
|
|
115
|
+
buffer = Buffer.isBuffer(rawBuffer) ? rawBuffer : Buffer.from(rawBuffer);
|
|
116
|
+
} else if (base64) {
|
|
117
|
+
buffer = parseBase64Buffer(base64);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!buffer || buffer.length === 0) {
|
|
121
|
+
throw new ValidationError('Missing spreadsheet file content (buffer, base64, or csvText)', [
|
|
122
|
+
{ field: 'buffer', message: 'Spreadsheet content is required' },
|
|
123
|
+
]);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let rows = [];
|
|
127
|
+
if (isCsv) {
|
|
128
|
+
rows = parseCsvToRows(buffer);
|
|
129
|
+
} else {
|
|
130
|
+
try {
|
|
131
|
+
rows = await parseXlsxToRows(buffer);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
// Fallback to CSV parsing
|
|
134
|
+
try {
|
|
135
|
+
rows = parseCsvToRows(buffer);
|
|
136
|
+
} catch (csvErr) {
|
|
137
|
+
throw new ValidationError(`Invalid spreadsheet format: ${err.message}`, [
|
|
138
|
+
{ field: 'buffer', message: 'Could not parse Excel or CSV file' },
|
|
139
|
+
]);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (rows.length < 2) {
|
|
145
|
+
throw new ValidationError('File must include a header row and at least one data row', [
|
|
146
|
+
{ field: 'buffer', message: 'File is empty or missing data rows' },
|
|
147
|
+
]);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const isColumnAllowed = (col) => {
|
|
151
|
+
if (model.columns && typeof model.columns.has === 'function' && model.columns.has(col)) return true;
|
|
152
|
+
if (model.schema && model.schema.shape && col in model.schema.shape) return true;
|
|
153
|
+
return false;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
let upsertKey = String(userUpsertKey || model.primaryKey);
|
|
157
|
+
if (!isColumnAllowed(upsertKey)) {
|
|
158
|
+
throw new ValidationError(`upsertKey "${upsertKey}" is not a column on model ${modelName}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const allowedKeys = model.columns && model.columns.size
|
|
162
|
+
? allowedImportColumns(model)
|
|
163
|
+
: Object.keys(model.schema?.shape || {}).filter(c => !model.hidden?.includes(c));
|
|
164
|
+
const headerMapping = buildHeaderMapping(rows[0], allowedKeys);
|
|
165
|
+
const objects = dataRowsToObjects(rows, headerMapping);
|
|
166
|
+
|
|
167
|
+
const repo = activeDb.getRepository(modelName);
|
|
168
|
+
const summary = {
|
|
169
|
+
success: true,
|
|
170
|
+
created: 0,
|
|
171
|
+
updated: 0,
|
|
172
|
+
failed: 0,
|
|
173
|
+
totalRows: objects.length,
|
|
174
|
+
errors: [],
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
for (const item of objects) {
|
|
178
|
+
const { rowNumber, data } = item;
|
|
179
|
+
let payload;
|
|
180
|
+
try {
|
|
181
|
+
payload = buildPayloadForRow(model, data, mode);
|
|
182
|
+
} catch (e) {
|
|
183
|
+
summary.failed++;
|
|
184
|
+
summary.errors.push({ row: rowNumber, message: e.message || String(e) });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
if (mode === 'insert') {
|
|
190
|
+
await repo.create(payload, { trx });
|
|
191
|
+
summary.created++;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Upsert mode
|
|
196
|
+
const keyVal = payload[upsertKey];
|
|
197
|
+
if (keyVal === undefined || keyVal === null || keyVal === '') {
|
|
198
|
+
const insertPayload = { ...payload };
|
|
199
|
+
delete insertPayload[upsertKey];
|
|
200
|
+
await repo.create(insertPayload, { trx });
|
|
201
|
+
summary.created++;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const existing = await repo.findOne({ [upsertKey]: keyVal }, { trx });
|
|
206
|
+
if (existing) {
|
|
207
|
+
const id = existing[model.primaryKey];
|
|
208
|
+
await repo.update(id, payload, { trx });
|
|
209
|
+
summary.updated++;
|
|
210
|
+
} else {
|
|
211
|
+
await repo.create({ ...payload, [upsertKey]: keyVal }, { trx });
|
|
212
|
+
summary.created++;
|
|
213
|
+
}
|
|
214
|
+
} catch (e) {
|
|
215
|
+
summary.failed++;
|
|
216
|
+
summary.errors.push({ row: rowNumber, message: e.message || String(e) });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
summary.success = summary.failed === 0;
|
|
221
|
+
return summary;
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
module.exports = {
|
|
228
|
+
createExchangeServices,
|
|
229
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in Mail & Notification Services
|
|
3
|
+
* @module src/services/builtins/mail
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { z } = require('zod');
|
|
7
|
+
const { ValidationError, NotFoundError } = require('../../../core/errors');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create built-in mail services map
|
|
11
|
+
* @param {Object} options
|
|
12
|
+
* @param {Object} options.emailService - Instance created by createEmailService
|
|
13
|
+
* @param {Object} [options.registry] - TemplateRegistry instance
|
|
14
|
+
* @param {Object} [options.db] - Database or knex instance
|
|
15
|
+
* @param {string} [options.tableName='email_logs']
|
|
16
|
+
* @returns {Record<string, Object>}
|
|
17
|
+
*/
|
|
18
|
+
function createMailServices(options = {}) {
|
|
19
|
+
const { emailService, registry, db, tableName = 'email_logs' } = options;
|
|
20
|
+
|
|
21
|
+
if (!emailService) {
|
|
22
|
+
throw new Error('emailService instance is required for createMailServices');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const emailSchema = z.string().email();
|
|
26
|
+
const recipientSchema = z.union([emailSchema, z.array(emailSchema)]);
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
'mail.send': {
|
|
30
|
+
schema: z.object({
|
|
31
|
+
to: recipientSchema,
|
|
32
|
+
subject: z.string().min(1, 'Subject is required'),
|
|
33
|
+
from: z.string().optional(),
|
|
34
|
+
replyTo: z.string().optional(),
|
|
35
|
+
cc: z.union([z.string(), z.array(z.string())]).optional(),
|
|
36
|
+
bcc: z.union([z.string(), z.array(z.string())]).optional(),
|
|
37
|
+
template: z.string().optional(),
|
|
38
|
+
mjml: z.string().optional(),
|
|
39
|
+
html: z.string().optional(),
|
|
40
|
+
text: z.string().optional(),
|
|
41
|
+
data: z.record(z.any()).optional().default({}),
|
|
42
|
+
attachments: z.array(z.any()).optional(),
|
|
43
|
+
}),
|
|
44
|
+
async handler(input, ctx) {
|
|
45
|
+
try {
|
|
46
|
+
const result = await emailService.send(input);
|
|
47
|
+
return {
|
|
48
|
+
success: true,
|
|
49
|
+
messageId: result.messageId,
|
|
50
|
+
to: input.to,
|
|
51
|
+
subject: input.subject,
|
|
52
|
+
};
|
|
53
|
+
} catch (err) {
|
|
54
|
+
throw new Error(`Failed to send email: ${err.message}`);
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
'mail.send-templated': {
|
|
60
|
+
schema: z.object({
|
|
61
|
+
template: z.string().min(1, 'Template ID is required'),
|
|
62
|
+
to: recipientSchema,
|
|
63
|
+
subject: z.string().min(1, 'Subject is required'),
|
|
64
|
+
data: z.record(z.any()).optional().default({}),
|
|
65
|
+
from: z.string().optional(),
|
|
66
|
+
replyTo: z.string().optional(),
|
|
67
|
+
attachments: z.array(z.any()).optional(),
|
|
68
|
+
}),
|
|
69
|
+
async handler(input, ctx) {
|
|
70
|
+
const { template, ...rest } = input;
|
|
71
|
+
try {
|
|
72
|
+
const result = await emailService.send({
|
|
73
|
+
template,
|
|
74
|
+
...rest,
|
|
75
|
+
});
|
|
76
|
+
return {
|
|
77
|
+
success: true,
|
|
78
|
+
messageId: result.messageId,
|
|
79
|
+
template,
|
|
80
|
+
to: input.to,
|
|
81
|
+
};
|
|
82
|
+
} catch (err) {
|
|
83
|
+
throw new Error(`Failed to send templated email: ${err.message}`);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
'mail.preview': {
|
|
89
|
+
schema: z.object({
|
|
90
|
+
template: z.string().optional(),
|
|
91
|
+
mjml: z.string().optional(),
|
|
92
|
+
data: z.record(z.any()).optional().default({}),
|
|
93
|
+
}),
|
|
94
|
+
async handler(input, ctx) {
|
|
95
|
+
const { template, mjml, data } = input;
|
|
96
|
+
if (!template && !mjml) {
|
|
97
|
+
throw new ValidationError('Either template or mjml must be provided for preview', [
|
|
98
|
+
{ field: 'template', message: 'Either template or mjml must be provided' },
|
|
99
|
+
]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const previewOpts = template ? template : { mjml };
|
|
103
|
+
const rendered = await emailService.preview(previewOpts, data);
|
|
104
|
+
return {
|
|
105
|
+
html: rendered.html,
|
|
106
|
+
text: rendered.text,
|
|
107
|
+
errors: rendered.errors || [],
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
'mail.query-logs': {
|
|
113
|
+
schema: z.object({
|
|
114
|
+
limit: z.number().int().positive().default(50),
|
|
115
|
+
offset: z.number().int().nonnegative().default(0),
|
|
116
|
+
status: z.enum(['sent', 'error']).optional(),
|
|
117
|
+
to: z.string().optional(),
|
|
118
|
+
template: z.string().optional(),
|
|
119
|
+
}),
|
|
120
|
+
auth: 'admin',
|
|
121
|
+
async handler(input, ctx) {
|
|
122
|
+
if (!emailService.queryLogs) {
|
|
123
|
+
return { logs: [], total: 0 };
|
|
124
|
+
}
|
|
125
|
+
const logs = await emailService.queryLogs(input);
|
|
126
|
+
return { logs, total: logs.length };
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = {
|
|
133
|
+
createMailServices,
|
|
134
|
+
};
|