adminforth 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.
Files changed (58) hide show
  1. package/auth.js +66 -0
  2. package/dataConnectors/mongo.js +187 -0
  3. package/dataConnectors/postgres.js +283 -0
  4. package/dataConnectors/sqlite.js +257 -0
  5. package/index.js +722 -0
  6. package/modules/codeInjector.js +313 -0
  7. package/modules/utils.js +12 -0
  8. package/package.json +23 -0
  9. package/servers/express.js +217 -0
  10. package/spa/.eslintrc.cjs +14 -0
  11. package/spa/.vscode/extensions.json +6 -0
  12. package/spa/README.md +39 -0
  13. package/spa/env.d.ts +1 -0
  14. package/spa/index.html +23 -0
  15. package/spa/package-lock.json +4152 -0
  16. package/spa/package.json +40 -0
  17. package/spa/postcss.config.js +6 -0
  18. package/spa/public/favicon.ico +0 -0
  19. package/spa/src/App.vue +172 -0
  20. package/spa/src/assets/base.css +0 -0
  21. package/spa/src/assets/logo.svg +1 -0
  22. package/spa/src/components/AcceptModal.vue +52 -0
  23. package/spa/src/components/Breadcrumbs.vue +40 -0
  24. package/spa/src/components/BreadcrumbsWithButtons.vue +26 -0
  25. package/spa/src/components/CustomDateRangePicker.vue +218 -0
  26. package/spa/src/components/Dropdown.vue +154 -0
  27. package/spa/src/components/Filters.vue +141 -0
  28. package/spa/src/components/HelloWorld.vue +17 -0
  29. package/spa/src/components/MenuLink.vue +25 -0
  30. package/spa/src/components/ResourceForm.vue +198 -0
  31. package/spa/src/components/SingleSkeletLoader.vue +13 -0
  32. package/spa/src/components/ValueRenderer.vue +44 -0
  33. package/spa/src/components/icons/IconCalendar.vue +5 -0
  34. package/spa/src/components/icons/IconCommunity.vue +7 -0
  35. package/spa/src/components/icons/IconDocumentation.vue +7 -0
  36. package/spa/src/components/icons/IconEcosystem.vue +7 -0
  37. package/spa/src/components/icons/IconSupport.vue +7 -0
  38. package/spa/src/components/icons/IconTime.vue +5 -0
  39. package/spa/src/components/icons/IconTooling.vue +19 -0
  40. package/spa/src/index.scss +26 -0
  41. package/spa/src/main.ts +18 -0
  42. package/spa/src/router/index.ts +53 -0
  43. package/spa/src/stores/core.ts +135 -0
  44. package/spa/src/stores/modal.ts +38 -0
  45. package/spa/src/utils.ts +44 -0
  46. package/spa/src/views/CreateView.vue +103 -0
  47. package/spa/src/views/EditView.vue +95 -0
  48. package/spa/src/views/HomeView.vue +8 -0
  49. package/spa/src/views/ListView.vue +466 -0
  50. package/spa/src/views/LoginView.vue +122 -0
  51. package/spa/src/views/ResourceParent.vue +18 -0
  52. package/spa/src/views/ShowView.vue +94 -0
  53. package/spa/tailwind.config.js +12 -0
  54. package/spa/tsconfig.app.json +14 -0
  55. package/spa/tsconfig.json +11 -0
  56. package/spa/tsconfig.node.json +19 -0
  57. package/spa/vite.config.ts +42 -0
  58. package/types.js +34 -0
package/index.js ADDED
@@ -0,0 +1,722 @@
1
+
2
+ import Auth from './auth.js';
3
+ import MongoConnector from './dataConnectors/mongo.js';
4
+ import PostgresConnector from './dataConnectors/postgres.js';
5
+ import SQLiteConnector from './dataConnectors/sqlite.js';
6
+ import CodeInjector from './modules/codeInjector.js';
7
+ import { guessLabelFromName } from './modules/utils.js';
8
+ import ExpressServer from './servers/express.js';
9
+ import {v1 as uuid} from 'uuid';
10
+
11
+
12
+ import { AdminForthFilterOperators, AdminForthTypes } from './types.js';
13
+
14
+ const AVAILABLE_SHOW_IN = ['list', 'edit', 'create', 'filter', 'show'];
15
+
16
+ class AdminForth {
17
+ static Types = AdminForthTypes;
18
+
19
+ static Utils = {
20
+ generatePasswordHash: async (password) => {
21
+ return await Auth.generatePasswordHash(password);
22
+ }
23
+ }
24
+
25
+ #defaultConfig = {
26
+ deleteConfirmation: true,
27
+
28
+
29
+ }
30
+
31
+ constructor(config) {
32
+ this.config = {...this.#defaultConfig,...config};
33
+ this.validateConfig();
34
+ this.express = new ExpressServer(this);
35
+ this.auth = new Auth();
36
+ this.codeInjector = new CodeInjector(this);
37
+ this.connectors = {};
38
+ this.statuses = {}
39
+ }
40
+
41
+ validateConfig() {
42
+ if (this.config.rootUser) {
43
+ if (!this.config.rootUser.username) {
44
+ throw new Error('rootUser.username is required');
45
+ }
46
+ if (!this.config.rootUser.password) {
47
+ throw new Error('rootUser.password is required');
48
+ }
49
+
50
+ console.log('\n ⚠️⚠️⚠️ [INSECURE ALERT] config.rootUser is set, please create a new user and remove config.rootUser from config before going to production\n');
51
+ }
52
+
53
+ if (this.config.auth) {
54
+ if (!this.config.auth.resourceId) {
55
+ throw new Error('No config.auth.resourceId defined');
56
+ }
57
+ if (!this.config.auth.passwordHashField) {
58
+ throw new Error('No config.auth.passwordHashField defined');
59
+ }
60
+ const userResource = this.config.resources.find((res) => res.resourceId === this.config.auth.resourceId);
61
+ if (!userResource) {
62
+ throw new Error(`Resource with id "${this.config.auth.resourceId}" not found`);
63
+ }
64
+ }
65
+
66
+
67
+ const errors = [];
68
+ if (!this.config.baseUrl) {
69
+ this.config.baseUrl = '';
70
+ }
71
+ if (!this.config.brandName) {
72
+ this.config.brandName = 'AdminForth';
73
+ }
74
+
75
+ if (!this.config.datesFormat) {
76
+ this.config.datesFormat = 'MMM D, YYYY HH:mm:ss';
77
+ }
78
+
79
+ if (this.config.resources) {
80
+ this.config.resources.forEach((res) => {
81
+ if (!res.table) {
82
+ errors.push(`Resource "${res.dataSource}" is missing table`);
83
+ }
84
+ // if itemLabel is not callable, throw error
85
+ if (res.itemLabel && typeof res.itemLabel !== 'function') {
86
+ errors.push(`Resource "${res.dataSource}" itemLabel is not a function`);
87
+ }
88
+
89
+
90
+ res.resourceId = res.resourceId || res.table;
91
+ res.label = res.label || res.table.charAt(0).toUpperCase() + res.table.slice(1);
92
+ if (!res.dataSource) {
93
+ errors.push(`Resource "${res.resourceId}" is missing dataSource`);
94
+ }
95
+ if (!res.columns) {
96
+ res.columns = [];
97
+ }
98
+ res.columns.forEach((col) => {
99
+ col.label = col.label || guessLabelFromName(col.name);
100
+ if (col.showIn && !Array.isArray(col.showIn)) {
101
+ errors.push(`Resource "${res.resourceId}" column "${col.name}" showIn must be an array`);
102
+ }
103
+
104
+ // check col.required is string or object
105
+ if (col.required && !((typeof col.required === 'boolean') || (typeof col.required === 'object'))) {
106
+ errors.push(`Resource "${res.resourceId}" column "${col.name}" required must be a string or object`);
107
+ }
108
+
109
+ // if it is object check the keys are one of ['create', 'edit']
110
+ if (typeof col.required === 'object') {
111
+ const wrongRequiredOn = Object.keys(col.required).find((c) => !['create', 'edit'].includes(c));
112
+ if (wrongRequiredOn) {
113
+ errors.push(`Resource "${res.resourceId}" column "${col.name}" has invalid required value "${wrongRequiredOn}", allowed keys are 'create', 'edit']`);
114
+ }
115
+ }
116
+
117
+ // same for editingNote
118
+ if (col.editingNote && !((typeof col.editingNote === 'string') || (typeof col.editingNote === 'object'))) {
119
+ errors.push(`Resource "${res.resourceId}" column "${col.name}" editingNote must be a string or object`);
120
+ }
121
+ if (typeof col.editingNote === 'object') {
122
+ const wrongEditingNoteOn = Object.keys(col.editingNote).find((c) => !['create', 'edit'].includes(c));
123
+ if (wrongEditingNoteOn) {
124
+ errors.push(`Resource "${res.resourceId}" column "${col.name}" has invalid editingNote value "${wrongEditingNoteOn}", allowed keys are 'create', 'edit']`);
125
+ }
126
+ }
127
+
128
+ const wrongShowIn = col.showIn && col.showIn.find((c) => !AVAILABLE_SHOW_IN.includes(c));
129
+ if (wrongShowIn) {
130
+ errors.push(`Resource "${res.resourceId}" column "${col.name}" has invalid showIn value "${wrongShowIn}", allowed values are ${AVAILABLE_SHOW_IN.join(', ')}`);
131
+ }
132
+ col.showIn = col.showIn?.map(c => c.toLowerCase()) || AVAILABLE_SHOW_IN;
133
+ })
134
+
135
+
136
+ //check if resource has bulkActions
137
+ if(res.options?.bulkActions){
138
+ let bulkActions = res.options.bulkActions;
139
+
140
+ if(!Array.isArray(bulkActions)){
141
+ errors.push(`Resource "${res.resourceId}" bulkActions must be an array`);
142
+ bulkActions = [];
143
+ }
144
+ if(res.options?.allowDelete){
145
+ bulkActions.push({
146
+ label: `Delete checked`,
147
+ state: 'danger',
148
+ icon: 'flowbite:trash-bin-outline',
149
+ action: async ({selectedIds}) => {
150
+ const connector = this.connectors[res.dataSource];
151
+ await Promise.all(selectedIds.map(async (recordId) => {
152
+ await connector.deleteRecord({ resource: res, recordId });
153
+ }));
154
+ }
155
+ });
156
+ }
157
+
158
+ const newBulkActions = bulkActions.map((action) => {
159
+ return Object.assign(action, {id: uuid()});
160
+ });
161
+ console.log('newBulkActions', newBulkActions);
162
+ bulkActions = newBulkActions;
163
+ }
164
+ });
165
+
166
+ if (!this.config.menu) {
167
+ errors.push('No config.menu defined');
168
+ }
169
+
170
+ // check if there is only one homepage: true in menu, recursivly
171
+ let homepages = 0;
172
+ const browseMenu = (menu) => {
173
+ menu.forEach((item) => {
174
+ if (item.component && item.resourceId) {
175
+ errors.push(`Menu item cannot have both component and resourceId: ${JSON.stringify(item)}`);
176
+ }
177
+ if (item.component && !item.path) {
178
+ errors.push(`Menu item with component must have path : ${JSON.stringify(item)}`);
179
+ }
180
+
181
+ if (item.homepage) {
182
+ homepages++;
183
+ if (homepages > 1) {
184
+ errors.push('There must be only one homepage: true in menu, found second one in ' + JSON.stringify(item) );
185
+ }
186
+ }
187
+ if (item.children) {
188
+ browseMenu(item.children);
189
+ }
190
+ });
191
+ };
192
+
193
+ }
194
+
195
+ // check for duplicate resourceIds and show which ones are duplicated
196
+ const resourceIds = this.config.resources.map((res) => res.resourceId);
197
+ const uniqueResourceIds = new Set(resourceIds);
198
+ if (uniqueResourceIds.size != resourceIds.length) {
199
+ const duplicates = resourceIds.filter((item, index) => resourceIds.indexOf(item) != index);
200
+ errors.push(`Duplicate fields "resourceId" or "table": ${duplicates.join(', ')}`);
201
+ }
202
+
203
+ //add ids for onSelectedAllActions for each resource
204
+
205
+
206
+
207
+
208
+ if (errors.length > 0) {
209
+ throw new Error(`Invalid AdminForth config: ${errors.join(', ')}`);
210
+ }
211
+ }
212
+
213
+ postProcessAfterDiscover(resource) {
214
+ resource.columns.forEach((column) => {
215
+ // if db/user says column is required in boolean, exapd
216
+ if (typeof column.required === 'boolean') {
217
+ column.required = { create: column.required, edit: column.required };
218
+ }
219
+
220
+ // same for editingNote
221
+ if (typeof column.editingNote === 'string') {
222
+ column.editingNote = { create: column.editingNote, edit: column.editingNote };
223
+ }
224
+ })
225
+ resource.dataSourceColumns = resource.columns.filter((col) => !col.virtual);
226
+ }
227
+
228
+ async discoverDatabases() {
229
+ this.statuses.dbDiscover = 'running';
230
+ this.connectorClasses = {
231
+ 'sqlite': SQLiteConnector,
232
+ 'postgres': PostgresConnector,
233
+ 'mongodb': MongoConnector,
234
+ };
235
+ if (!this.config.databaseConnectors) {
236
+ this.config.databaseConnectors = {...this.connectorClasses};
237
+ }
238
+ this.config.dataSources.forEach((ds) => {
239
+ const dbType = ds.url.split(':')[0];
240
+ if (!this.config.databaseConnectors[dbType]) {
241
+ throw new Error(`Database type ${dbType} is not supported, consider using databaseConnectors in AdminForth config`);
242
+ }
243
+ this.connectors[ds.id] = new this.config.databaseConnectors[dbType]({url: ds.url , fieldtypesByTable: ds.fieldtypesByTable});
244
+ });
245
+
246
+ await Promise.all(this.config.resources.map(async (res) => {
247
+ if (!this.connectors[res.dataSource]) {
248
+ throw new Error(`Resource '${res.table}' refers to unknown dataSource '${res.dataSource}'`);
249
+ }
250
+ const fieldTypes = await this.connectors[res.dataSource].discoverFields(res.table);
251
+ if (!Object.keys(fieldTypes).length) {
252
+ throw new Error(`Table '${res.table}' (In resource '${res.resourceId}') has no fields or does not exist`);
253
+ }
254
+
255
+ if (!res.columns) {
256
+ res.columns = Object.keys(fieldTypes).map((name) => ({ name }));
257
+ }
258
+
259
+ res.columns.forEach((col, i) => {
260
+ if (!fieldTypes[col.name] && !col.virtual) {
261
+ throw new Error(`Resource '${res.table}' has no column '${col.name}'`);
262
+ }
263
+ // first find discovered values, but allow override
264
+ res.columns[i] = { ...fieldTypes[col.name], ...col };
265
+ });
266
+
267
+ this.postProcessAfterDiscover(res);
268
+
269
+ // check if primaryKey column is present
270
+ if (!res.columns.some((col) => col.primaryKey)) {
271
+ throw new Error(`Resource '${res.table}' has no column defined or auto-discovered. Please set 'primaryKey: true' in a columns which has unique value for each record and index`);
272
+ }
273
+
274
+ }));
275
+
276
+ this.statuses.dbDiscover = 'done';
277
+
278
+ // console.log('⚙️⚙️⚙️ Database discovery done', JSON.stringify(this.config.resources, null, 2));
279
+ }
280
+
281
+ async init() {
282
+ console.log('AdminForth init');
283
+ }
284
+
285
+ async bundleNow({ hotReload=false, verbose=false }) {
286
+ this.codeInjector.bundleNow({ hotReload, verbose });
287
+ }
288
+
289
+ setupEndpoints(server) {
290
+ server.endpoint({
291
+ noAuth: true,
292
+ method: 'POST',
293
+ path: '/login',
294
+ handler: async ({ body, response }) => {
295
+ const { username, password } = body;
296
+ let token;
297
+ if (username === this.config.rootUser.username && password === this.config.rootUser.password) {
298
+ token = this.auth.issueJWT({ username, pk: null });
299
+ } else {
300
+ // get resource from db
301
+ if (!this.config.auth) {
302
+ throw new Error('No config.auth defined');
303
+ }
304
+ const userResource = this.config.resources.find((res) => res.resourceId === this.config.auth.resourceId);
305
+ const userRecord = await this.connectors[userResource.dataSource].getData({
306
+ resource: userResource,
307
+ filters: [
308
+ { field: this.config.auth.usernameField, operator: AdminForthFilterOperators.EQ, value: username },
309
+ ],
310
+ limit: 1,
311
+ offset: 0,
312
+ sort: [],
313
+ }).data[0];
314
+
315
+ if (!userRecord) {
316
+ return { error: 'User not found' };
317
+ }
318
+
319
+ const passwordHash = userRecord[this.config.auth.passwordHashField];
320
+ console.log('User record', userRecord, passwordHash) // why does it has no hash?
321
+ const valid = await Auth.verifyPassword(password, passwordHash);
322
+ if (valid) {
323
+ token = this.auth.issueJWT({
324
+ username, pk: userRecord[userResource.columns.find((col) => col.primaryKey).name]
325
+ });
326
+ } else {
327
+ return { error: INVALID_MESSAGE };
328
+ }
329
+ }
330
+
331
+ response.setHeader('Set-Cookie', `adminforth_jwt=${token}; Path=${this.config.baseUrl || '/'}; HttpOnly; SameSite=Strict`);
332
+ return { ok: true };
333
+ },
334
+ });
335
+
336
+ server.endpoint({
337
+ noAuth: true,
338
+ method: 'POST',
339
+ path: '/logout',
340
+ handler: async ({ response }) => {
341
+ response.setHeader('Set-Cookie', `adminforth_jwt=; Path=${this.config.baseUrl || '/'}; HttpOnly; SameSite=Strict; Expires=Thu, 01 Jan 1970 00:00:00 GMT`);
342
+ return { ok: true };
343
+ },
344
+ })
345
+
346
+ server.endpoint({
347
+ noAuth: true,
348
+ method: 'GET',
349
+ path: '/get_public_config',
350
+ handler: async ({ body }) => {
351
+
352
+ // find resource
353
+ if (!this.config.auth) {
354
+ throw new Error('No config.auth defined');
355
+ }
356
+ const usernameField = this.config.auth.usernameField;
357
+ const resource = this.config.resources.find((res) => res.resourceId === this.config.auth.resourceId);
358
+ const usernameColumn = resource.columns.find((col) => col.name === usernameField);
359
+
360
+ return {
361
+ brandName: this.config.brandName,
362
+ usernameFieldName: usernameColumn.label,
363
+ loginBackgroundImage: this.config.auth.loginBackgroundImage,
364
+ };
365
+ },
366
+ });
367
+
368
+ server.endpoint({
369
+ method: 'GET',
370
+ path: '/get_base_config',
371
+ handler: async ({input, adminUser, cookies}) => {
372
+ const cookieParsed = this.auth.verify(cookies['adminforth_jwt']);
373
+ let username = ''
374
+ if (cookieParsed['pk'] == null) {
375
+ username = this.config.rootUser.username;
376
+ } else {
377
+ const userResource = this.config.resources.find((res) => res.resourceId === this.config.auth.resourceId);
378
+ const user = await this.connectors[userResource.dataSource].getData({
379
+ resource: userResource,
380
+ filters: [
381
+ { field: userResource.columns.find((col) => col.primaryKey).name, operator: AdminForthFilterOperators.EQ, value: cookieParsed['pk'] },
382
+ ],
383
+ limit: 1,
384
+ offset: 0,
385
+ sort: [],
386
+ });
387
+ if (!user.data.length) {
388
+ return { error: 'Unauthorized' };
389
+ }
390
+ username = user.data[0][this.config.auth.usernameField];
391
+ }
392
+
393
+ return {
394
+ user: {
395
+ [this.config.auth.usernameField]: username
396
+ },
397
+ resources: this.config.resources.map((res) => ({
398
+ resourceId: res.resourceId,
399
+ label: res.label,
400
+ })),
401
+ menu: this.config.menu,
402
+ config: {
403
+ brandName: this.config.brandName,
404
+ datesFormat: this.config.datesFormat,
405
+ deleteConfirmation: this.config.deleteConfirmation,
406
+ auth: this.config.auth,
407
+ usernameField: this.config.auth.usernameField,
408
+ },
409
+ adminUser,
410
+ };
411
+ },
412
+ });
413
+
414
+ server.endpoint({
415
+ method: 'POST',
416
+ path: '/get_resource_columns',
417
+ handler: async ({ body }) => {
418
+ const { resourceId } = body;
419
+ if (!this.statuses.dbDiscover) {
420
+ return { error: 'Database discovery not started' };
421
+ }
422
+ if (this.statuses.dbDiscover !== 'done') {
423
+ return { error : 'Database discovery is still in progress, please try later' };
424
+ }
425
+ const resource = this.config.resources.find((res) => res.resourceId == resourceId);
426
+ if (!resource) {
427
+ return { error: `Resource ${resourceId} not found` };
428
+ }
429
+ return { resource };
430
+ },
431
+ });
432
+ server.endpoint({
433
+ method: 'POST',
434
+ path: '/get_resource_data',
435
+ handler: async ({ body }) => {
436
+ const { resourceId, limit, offset, filters, sort } = body;
437
+ if (!this.statuses.dbDiscover) {
438
+ return { error: 'Database discovery not started' };
439
+ }
440
+ if (this.statuses.dbDiscover !== 'done') {
441
+ return { error : 'Database discovery is still in progress, please try later' };
442
+ }
443
+ const resource = this.config.resources.find((res) => res.resourceId == resourceId);
444
+ if (!resource) {
445
+ return { error: `Resource ${resourceId} not found` };
446
+ }
447
+ const data = await this.connectors[resource.dataSource].getData({
448
+ resource,
449
+ limit,
450
+ offset,
451
+ filters,
452
+ sort,
453
+ });
454
+ return {...data, options: resource?.options };
455
+ },
456
+ });
457
+ server.endpoint({
458
+ method: 'POST',
459
+ path: '/get_min_max_for_columns',
460
+ handler: async ({ body }) => {
461
+ const { resourceId } = body;
462
+ if (!this.statuses.dbDiscover) {
463
+ return { error: 'Database discovery not started' };
464
+ }
465
+ if (this.statuses.dbDiscover !== 'done') {
466
+ return { error : 'Database discovery is still in progress, please try later' };
467
+ }
468
+ const resource = this.config.resources.find((res) => res.resourceId == resourceId);
469
+ if (!resource) {
470
+ return { error: `Resource '${resourceId}' not found` };
471
+ }
472
+ const item = await this.connectors[resource.dataSource].getMinMaxForColumns({
473
+ resource,
474
+ columns: resource.columns.filter((col) => [
475
+ AdminForthTypes.INT,
476
+ AdminForthTypes.FLOAT,
477
+ AdminForthTypes.DATE,
478
+ AdminForthTypes.DATETIME,
479
+ AdminForthTypes.TIME,
480
+ AdminForthTypes.DECIMAL,
481
+ ].includes(col.type) && col.allowMinMaxQuery === true),
482
+ });
483
+ return item;
484
+ },
485
+ });
486
+ server.endpoint({
487
+ method: 'POST',
488
+ path: '/get_record',
489
+ handler: async ({ body }) => {
490
+ const { resourceId, primaryKey } = body;
491
+ const resource = this.config.resources.find((res) => res.resourceId == resourceId);
492
+ const primaryKeyColumn = resource.columns.find((col) => col.primaryKey);
493
+ const connector = this.connectors[resource.dataSource];
494
+ const record = await connector.getRecordByPrimaryKey(resource, primaryKey);
495
+ if (!record) {
496
+ return { error: `Record with ${primaryKeyColumn.name} ${primaryKey} not found` };
497
+ }
498
+
499
+ // execute hook if needed
500
+ if (resource.hooks?.show) {
501
+ const resp = await resource.hooks?.show({ resource, record, adminUser });
502
+ if (!resp || (!resp.ok && !resp.error)) {
503
+ throw new Error(`Hook must return object with {ok: true} or { error: 'Error' } `);
504
+ }
505
+
506
+ if (resp.error) {
507
+ return { error: resp.error };
508
+ }
509
+ }
510
+
511
+ const labler = resource.itemLabel || ((record) => `${resource.label} ${record[primaryKeyColumn.name]}`);
512
+ record._label = labler(record);
513
+ return record;
514
+ }
515
+ });
516
+ server.endpoint({
517
+ noAuth: true, // TODO
518
+ method: 'POST',
519
+ path: '/create_record',
520
+ handler: async ({ body, adminUser }) => {
521
+ console.log('create_record', body, this.config.resources);
522
+ const resource = this.config.resources.find((res) => res.resourceId == body['resourceId']);
523
+ if (!resource) {
524
+ return { error: `Resource '${body['resourceId']}' not found` };
525
+ }
526
+ for (const column of resource.columns) {
527
+ if (column.fillOnCreate) {
528
+ if (body['record'][column.name] === undefined) {
529
+ body['record'][column.name] = column.fillOnCreate({
530
+ initialRecord: body['record'], adminUser
531
+ });
532
+ }
533
+ }
534
+ if (column.required?.create && body['record'][column.name] === undefined) {
535
+ return { error: `Column '${column.name}' is required` };
536
+ }
537
+
538
+ if (column.isUnique) {
539
+ const existingRecord = await this.connectors[resource.dataSource].getData({
540
+ resource,
541
+ filters: [{ field: column.name, operator: AdminForthFilterOperators.EQ, value: body['record'][column.name] }],
542
+ limit: 1,
543
+ sort: [],
544
+ offset: 0
545
+ });
546
+ if (existingRecord.data.length > 0) {
547
+ return { error: `Record with ${column.name} ${body['record'][column.name]} already exists` };
548
+ }
549
+ }
550
+ }
551
+ const connector = this.connectors[resource.dataSource];
552
+
553
+ const record = body['record'];
554
+
555
+ // execute hook if needed
556
+ if (resource.hooks?.create?.beforeSave) {
557
+ const resp = await resource.hooks?.create?.beforeSave({ resource, record, adminUser });
558
+ if (!resp || (!resp.ok && !resp.error)) {
559
+ throw new Error(`Hook beforeSave must return object with {ok: true} or { error: 'Error' } `);
560
+ }
561
+
562
+ if (resp.error) {
563
+ return { error: resp.error };
564
+ }
565
+ }
566
+
567
+ // remove virtual columns from record
568
+ for (const column of resource.columns.filter((col) => col.virtual)) {
569
+ if (record[column.name]) {
570
+ delete record[column.name];
571
+ }
572
+ }
573
+
574
+ await connector.createRecord({ resource, record });
575
+
576
+ // execute hook if needed
577
+ if (resource.hooks?.create?.afterSave) {
578
+ const resp = await resource.hooks?.create?.afterSave({ resource, record, adminUser });
579
+ if (!resp || (!resp.ok && !resp.error)) {
580
+ throw new Error(`Hook afterSave must return object with {ok: true} or { error: 'Error' } `);
581
+ }
582
+
583
+ if (resp.error) {
584
+ return { error: resp.error };
585
+ }
586
+ }
587
+
588
+ return {
589
+ newRecordId: body['record'][connector.getPrimaryKey(resource)]
590
+ }
591
+ }
592
+ });
593
+ server.endpoint({
594
+ noAuth: true, // TODO
595
+ method: 'POST',
596
+ path: '/update_record',
597
+ handler: async ({ body }) => {
598
+ console.log('update_record', body);
599
+ const resource = this.config.resources.find((res) => res.resourceId == body['resourceId']);
600
+ if (!resource) {
601
+ return { error: `Resource '${body['resourceId']}' not found` };
602
+ }
603
+
604
+ const recordId = body['recordId'];
605
+ const connector = this.connectors[resource.dataSource];
606
+ const oldRecord = await connector.getRecordByPrimaryKey(resource, recordId)
607
+ if (!oldRecord) {
608
+ const primaryKeyColumn = resource.columns.find((col) => col.primaryKey);
609
+ return { error: `Record with ${primaryKeyColumn.name} ${recordId} not found` };
610
+ }
611
+
612
+ // execute hook if needed
613
+ if (resource.hooks?.edit?.beforeSave) {
614
+ const resp = await resource.hooks?.edit?.beforeSave({ resource, record, adminUser });
615
+ if (!resp || (!resp.ok && !resp.error)) {
616
+ throw new Error(`Hook beforeSave must return object with {ok: true} or { error: 'Error' } `);
617
+ }
618
+
619
+ if (resp.error) {
620
+ return { error: resp.error };
621
+ }
622
+ }
623
+
624
+ const newValues = {};
625
+ const record = body['record'];
626
+ for (const col of resource.columns) {
627
+ if (record[col.name] !== oldRecord[col.name]) {
628
+ newValues[col.name] = connector.setFieldValue(col, record[col.name]);
629
+ }
630
+ }
631
+ if (Object.keys(newValues).length > 0) {
632
+ await connector.updateRecord({ resource, recordId, record, newValues});
633
+ }
634
+
635
+ // execute hook if needed
636
+ if (resource.hooks?.edit?.afterSave) {
637
+ const resp = await resource.hooks?.edit?.afterSave({ resource, record, adminUser });
638
+ if (!resp || (!resp.ok && !resp.error)) {
639
+ throw new Error(`Hook afterSave must return object with {ok: true} or { error: 'Error' } `);
640
+ }
641
+
642
+ if (resp.error) {
643
+ return { error: resp.error };
644
+ }
645
+ }
646
+
647
+ return {
648
+ newRecordId: recordId
649
+ }
650
+ }
651
+ });
652
+ server.endpoint({
653
+ noAuth: true, // TODO
654
+ method: 'POST',
655
+ path: '/delete_record',
656
+ handler: async ({ body }) => {
657
+ const resource = this.config.resources.find((res) => res.resourceId == body['resourceId']);
658
+ if (!resource) {
659
+ return { error: `Resource '${body['resourceId']}' not found` };
660
+ }
661
+
662
+ // execute hook if needed
663
+ if (resource.hooks?.delete?.beforeSave) {
664
+ const resp = await resource.hooks?.delete?.beforeSave({ resource, record, adminUser });
665
+ if (!resp || (!resp.ok && !resp.error)) {
666
+ throw new Error(`Hook beforeSave must return object with {ok: true} or { error: 'Error' } `);
667
+ }
668
+
669
+ if (resp.error) {
670
+ return { error: resp.error };
671
+ }
672
+ }
673
+
674
+ const connector = this.connectors[resource.dataSource];
675
+ await connector.deleteRecord({ resource, recordId: body['primaryKey']});
676
+
677
+ // execute hook if needed
678
+ if (resource.hooks?.delete?.afterSave) {
679
+ const resp = await resource.hooks?.delete?.afterSave({ resource, record, adminUser });
680
+ if (!resp || (!resp.ok && !resp.error)) {
681
+ throw new Error(`Hook afterSave must return object with {ok: true} or { error: 'Error' } `);
682
+ }
683
+
684
+ if (resp.error) {
685
+ return { error: resp.error };
686
+ }
687
+ }
688
+ return {
689
+ recordId: body['primaryKey']
690
+ }
691
+ }
692
+ });
693
+ server.endpoint({
694
+ noAuth: true, // TODO
695
+ method: 'POST',
696
+ path: '/start_bulk_action',
697
+ handler: async ({ body }) => {
698
+ const { resourceId, actionId, recordIds } = body;
699
+ const resource = this.config.resources.find((res) => res.resourceId == resourceId);
700
+ if (!resource) {
701
+ return { error: `Resource '${resourceId}' not found` };
702
+ }
703
+ const action = resource.options.bulkActions.find((act) => act.id == actionId);
704
+ if (!action) {
705
+ return { error: `Action '${actionId}' not found` };
706
+ } else{
707
+ await action.action({selectedIds:recordIds})
708
+
709
+ }
710
+ return {
711
+ actionId,
712
+ recordIds,
713
+ resourceId,
714
+ status:'success'
715
+
716
+ }
717
+ }
718
+ })
719
+ }
720
+ }
721
+
722
+ export default AdminForth;