@baasix/baasix 0.1.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.MD +85 -0
- package/README.md +526 -0
- package/assets/banner.jpg +0 -0
- package/assets/banner_small.jpg +0 -0
- package/assets/logo_icon.svg +20 -0
- package/assets/logo_icon_rounded.svg +20 -0
- package/dist/LICENSE.MD +85 -0
- package/dist/README.md +526 -0
- package/dist/app/404/index.html +1 -0
- package/dist/app/404.html +1 -0
- package/dist/app/_next/static/chunks/041e1f03-56ae8a902a7f2fe6.js +24 -0
- package/dist/app/_next/static/chunks/1117-05479929a8da73e3.js +1 -0
- package/dist/app/_next/static/chunks/1299.77cc7b7b76b75cba.js +1 -0
- package/dist/app/_next/static/chunks/1303-35a96e9c9cdeab9d.js +1 -0
- package/dist/app/_next/static/chunks/1509-56ac00cdaaecdf53.js +1 -0
- package/dist/app/_next/static/chunks/1668-e3eabd0f6753c780.js +1 -0
- package/dist/app/_next/static/chunks/1783-d9fb550fd324300c.js +1 -0
- package/dist/app/_next/static/chunks/2117-29b5fa47421595ad.js +2 -0
- package/dist/app/_next/static/chunks/2344.35b46d2179a765b5.js +1 -0
- package/dist/app/_next/static/chunks/257.990da16794a31292.js +1 -0
- package/dist/app/_next/static/chunks/2676-73b0ee7c80073a84.js +1 -0
- package/dist/app/_next/static/chunks/3563-b8842744384391fe.js +1 -0
- package/dist/app/_next/static/chunks/363642f4-933b579ed3c85f60.js +1 -0
- package/dist/app/_next/static/chunks/3817-e20c8f0a0810fc95.js +1 -0
- package/dist/app/_next/static/chunks/3834.84944e390d902509.js +2 -0
- package/dist/app/_next/static/chunks/4043-3a30c8a75896f241.js +1 -0
- package/dist/app/_next/static/chunks/4225-14090c7c0cd9dec6.js +1 -0
- package/dist/app/_next/static/chunks/4438-c9a12ca15b6e9160.js +1 -0
- package/dist/app/_next/static/chunks/4458-679fd0c6884f456a.js +1 -0
- package/dist/app/_next/static/chunks/4475-8bdfbd536fba8c48.js +1 -0
- package/dist/app/_next/static/chunks/4883-8a924721bb21b3b0.js +1 -0
- package/dist/app/_next/static/chunks/489-683ab07188f9df2b.js +1 -0
- package/dist/app/_next/static/chunks/4952-1b97320cf61f3f21.js +1 -0
- package/dist/app/_next/static/chunks/5094-8d53e403235d4ca6.js +1 -0
- package/dist/app/_next/static/chunks/5101-3a146e0625747ad1.js +1 -0
- package/dist/app/_next/static/chunks/54a60aa6-d9747982e0a81f58.js +79 -0
- package/dist/app/_next/static/chunks/5650-f096291df402bfc2.js +1 -0
- package/dist/app/_next/static/chunks/600-539045311240f579.js +1 -0
- package/dist/app/_next/static/chunks/6170-803b82e19d3ade6d.js +89 -0
- package/dist/app/_next/static/chunks/6241-30d7169d1010e5a4.js +1 -0
- package/dist/app/_next/static/chunks/6530-a91e10cffa4200c4.js +1 -0
- package/dist/app/_next/static/chunks/6547-4bbbdb5c399aef1e.js +1 -0
- package/dist/app/_next/static/chunks/6712-781937c53a2c49da.js +1 -0
- package/dist/app/_next/static/chunks/6fcbdc68-90be1a5480b8d353.js +1 -0
- package/dist/app/_next/static/chunks/70e0d97a-aeaf0cdc26ba1a58.js +1 -0
- package/dist/app/_next/static/chunks/7214-5154a89d08d24dde.js +1 -0
- package/dist/app/_next/static/chunks/7324-b53229c59a640880.js +10 -0
- package/dist/app/_next/static/chunks/7636-66424f0b51d350e9.js +1 -0
- package/dist/app/_next/static/chunks/7874-39a3f2541165a675.js +1 -0
- package/dist/app/_next/static/chunks/7982-9da12b83f11e3f5f.js +1 -0
- package/dist/app/_next/static/chunks/8213a2eb-da25a3b3c5521b2b.js +1 -0
- package/dist/app/_next/static/chunks/8473-6598318371eca31b.js +1 -0
- package/dist/app/_next/static/chunks/8640fa6b-72e43370f68e5587.js +1 -0
- package/dist/app/_next/static/chunks/9090-3ef676f29c95f1c7.js +1 -0
- package/dist/app/_next/static/chunks/9124-a02f9e209e6e3cce.js +1 -0
- package/dist/app/_next/static/chunks/926-156f32067d111d6b.js +1 -0
- package/dist/app/_next/static/chunks/9487-b17481605e513b83.js +1 -0
- package/dist/app/_next/static/chunks/9599-a7e572bb88c3392b.js +1 -0
- package/dist/app/_next/static/chunks/9881-419697138376e755.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/activity-log/all-activity/page-8917930b4d663405.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/activity-log/email-log/page-b27a6ee32782d7df.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/activity-log/notifications/page-b7eda523ede2702c.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/activity-log/page-1cfa62d1caedaed0.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/activity-log/sessions/page-3e21e20db90aeff7.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/activity-log/workflow-executions/page-27bcc26b747fb29b.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/activity-log/workflow-logs/page-9f9e9e952aef436e.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/change-password/page-8d61aa499eabb127.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/dashboard/page-1ceeac9e72997a8a.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/data-browser/page-8cda2b57759dd670.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/file-manager/page-8c6f1b1da66ad7e4.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/layout-f70d225b2759c998.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/settings/migrations/page-aacec8f7cfb40ab2.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/settings/permissions/page-828110cfcde429c6.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/settings/project/page-420e794bb76bd204.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/settings/roles/page-9001d02b28f70708.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/settings/schema/page-899574f35091dd58.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/settings/tasks/page-ad7ab3e27c83f44f.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/settings/templates/edit/page-bd83414cb8c4cb04.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/settings/templates/page-3181447f8772b1d3.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/settings/tenants/page-ef9bfbacef5a1d73.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/users/invites/page-480306b7b2bbac7e.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/users/list/page-74da51254c2606b3.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/users/page-e99c6f0b915001b2.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/users/preferences/page-1a935630ce8f2b12.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/users/user-roles/page-901dfb8ea1f39ca8.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/workflows/detail/page-9a6b839aea688ca4.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/workflows/edit/page-11774efbc2fecae2.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/workflows/execution/page-8ec1aea90412c03d.js +1 -0
- package/dist/app/_next/static/chunks/app/(authenticated)/workflows/page-88bc5b36ccb0a1f7.js +1 -0
- package/dist/app/_next/static/chunks/app/(public)/forgot-password/page-ed263fd46ef81c20.js +1 -0
- package/dist/app/_next/static/chunks/app/(public)/layout-f538977545844af8.js +1 -0
- package/dist/app/_next/static/chunks/app/(public)/login/page-c0a10b137f346096.js +1 -0
- package/dist/app/_next/static/chunks/app/(public)/register/page-4cb7644893efd9b3.js +1 -0
- package/dist/app/_next/static/chunks/app/_not-found/page-653f8815b78256cc.js +1 -0
- package/dist/app/_next/static/chunks/app/layout-591ca7a3e16528a1.js +1 -0
- package/dist/app/_next/static/chunks/app/page-dd19d124b5fa2577.js +1 -0
- package/dist/app/_next/static/chunks/c37d3baf.c2ff165f5b02c692.js +1 -0
- package/dist/app/_next/static/chunks/d0deef33.0379166a4ec23470.js +1 -0
- package/dist/app/_next/static/chunks/fd9d1056-54169f07cd680d6c.js +1 -0
- package/dist/app/_next/static/chunks/framework-8e0e0f4a6b83a956.js +1 -0
- package/dist/app/_next/static/chunks/main-324e91f5a430cddf.js +1 -0
- package/dist/app/_next/static/chunks/main-app-55bcae20c77aaf0e.js +1 -0
- package/dist/app/_next/static/chunks/pages/_app-3c9ca398d360b709.js +1 -0
- package/dist/app/_next/static/chunks/pages/_error-cf5ca766ac8f493f.js +1 -0
- package/dist/app/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- package/dist/app/_next/static/chunks/webpack-2c306566f7ee1b63.js +1 -0
- package/dist/app/_next/static/css/6c4002bae4e236b2.css +3 -0
- package/dist/app/_next/static/css/a275cc2b185e04f8.css +1 -0
- package/dist/app/_next/static/eCWhKA8XHqmB1zgFcEtN2/_buildManifest.js +1 -0
- package/dist/app/_next/static/eCWhKA8XHqmB1zgFcEtN2/_ssgManifest.js +1 -0
- package/dist/app/activity-log/all-activity/index.html +1 -0
- package/dist/app/activity-log/all-activity/index.txt +14 -0
- package/dist/app/activity-log/email-log/index.html +1 -0
- package/dist/app/activity-log/email-log/index.txt +14 -0
- package/dist/app/activity-log/index.html +1 -0
- package/dist/app/activity-log/index.txt +14 -0
- package/dist/app/activity-log/notifications/index.html +1 -0
- package/dist/app/activity-log/notifications/index.txt +14 -0
- package/dist/app/activity-log/sessions/index.html +1 -0
- package/dist/app/activity-log/sessions/index.txt +14 -0
- package/dist/app/activity-log/workflow-executions/index.html +1 -0
- package/dist/app/activity-log/workflow-executions/index.txt +14 -0
- package/dist/app/activity-log/workflow-logs/index.html +1 -0
- package/dist/app/activity-log/workflow-logs/index.txt +14 -0
- package/dist/app/change-password/index.html +1 -0
- package/dist/app/change-password/index.txt +14 -0
- package/dist/app/dashboard/index.html +1 -0
- package/dist/app/dashboard/index.txt +14 -0
- package/dist/app/data-browser/index.html +1 -0
- package/dist/app/data-browser/index.txt +14 -0
- package/dist/app/file-manager/index.html +1 -0
- package/dist/app/file-manager/index.txt +14 -0
- package/dist/app/forgot-password/index.html +1 -0
- package/dist/app/forgot-password/index.txt +13 -0
- package/dist/app/index.html +1 -0
- package/dist/app/index.txt +9 -0
- package/dist/app/login/index.html +1 -0
- package/dist/app/login/index.txt +13 -0
- package/dist/app/logo-dark.png +0 -0
- package/dist/app/logo-icon.svg +81 -0
- package/dist/app/logo-light.png +0 -0
- package/dist/app/register/index.html +1 -0
- package/dist/app/register/index.txt +13 -0
- package/dist/app/settings/migrations/index.html +1 -0
- package/dist/app/settings/migrations/index.txt +14 -0
- package/dist/app/settings/permissions/index.html +1 -0
- package/dist/app/settings/permissions/index.txt +14 -0
- package/dist/app/settings/project/index.html +1 -0
- package/dist/app/settings/project/index.txt +14 -0
- package/dist/app/settings/roles/index.html +1 -0
- package/dist/app/settings/roles/index.txt +14 -0
- package/dist/app/settings/schema/index.html +1 -0
- package/dist/app/settings/schema/index.txt +14 -0
- package/dist/app/settings/tasks/index.html +1 -0
- package/dist/app/settings/tasks/index.txt +14 -0
- package/dist/app/settings/templates/edit/index.html +1 -0
- package/dist/app/settings/templates/edit/index.txt +14 -0
- package/dist/app/settings/templates/index.html +1 -0
- package/dist/app/settings/templates/index.txt +14 -0
- package/dist/app/settings/tenants/index.html +1 -0
- package/dist/app/settings/tenants/index.txt +14 -0
- package/dist/app/users/index.html +1 -0
- package/dist/app/users/index.txt +14 -0
- package/dist/app/users/invites/index.html +1 -0
- package/dist/app/users/invites/index.txt +14 -0
- package/dist/app/users/list/index.html +1 -0
- package/dist/app/users/list/index.txt +14 -0
- package/dist/app/users/preferences/index.html +1 -0
- package/dist/app/users/preferences/index.txt +14 -0
- package/dist/app/users/user-roles/index.html +1 -0
- package/dist/app/users/user-roles/index.txt +14 -0
- package/dist/app/workflows/detail/index.html +1 -0
- package/dist/app/workflows/detail/index.txt +14 -0
- package/dist/app/workflows/edit/index.html +1 -0
- package/dist/app/workflows/edit/index.txt +14 -0
- package/dist/app/workflows/execution/index.html +1 -0
- package/dist/app/workflows/execution/index.txt +14 -0
- package/dist/app/workflows/index.html +1 -0
- package/dist/app/workflows/index.txt +14 -0
- package/dist/app.d.ts +36 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +546 -0
- package/dist/app.js.map +1 -0
- package/dist/auth/adapters/baasix-adapter.d.ts +12 -0
- package/dist/auth/adapters/baasix-adapter.d.ts.map +1 -0
- package/dist/auth/adapters/baasix-adapter.js +318 -0
- package/dist/auth/adapters/baasix-adapter.js.map +1 -0
- package/dist/auth/adapters/index.d.ts +6 -0
- package/dist/auth/adapters/index.d.ts.map +1 -0
- package/dist/auth/adapters/index.js +5 -0
- package/dist/auth/adapters/index.js.map +1 -0
- package/dist/auth/core.d.ts +73 -0
- package/dist/auth/core.d.ts.map +1 -0
- package/dist/auth/core.js +528 -0
- package/dist/auth/core.js.map +1 -0
- package/dist/auth/index.d.ts +56 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +58 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/oauth2/index.d.ts +5 -0
- package/dist/auth/oauth2/index.d.ts.map +1 -0
- package/dist/auth/oauth2/index.js +5 -0
- package/dist/auth/oauth2/index.js.map +1 -0
- package/dist/auth/oauth2/utils.d.ts +90 -0
- package/dist/auth/oauth2/utils.d.ts.map +1 -0
- package/dist/auth/oauth2/utils.js +167 -0
- package/dist/auth/oauth2/utils.js.map +1 -0
- package/dist/auth/providers/apple.d.ts +28 -0
- package/dist/auth/providers/apple.d.ts.map +1 -0
- package/dist/auth/providers/apple.js +192 -0
- package/dist/auth/providers/apple.js.map +1 -0
- package/dist/auth/providers/credential.d.ts +87 -0
- package/dist/auth/providers/credential.d.ts.map +1 -0
- package/dist/auth/providers/credential.js +162 -0
- package/dist/auth/providers/credential.js.map +1 -0
- package/dist/auth/providers/facebook.d.ts +26 -0
- package/dist/auth/providers/facebook.d.ts.map +1 -0
- package/dist/auth/providers/facebook.js +112 -0
- package/dist/auth/providers/facebook.js.map +1 -0
- package/dist/auth/providers/github.d.ts +29 -0
- package/dist/auth/providers/github.d.ts.map +1 -0
- package/dist/auth/providers/github.js +144 -0
- package/dist/auth/providers/github.js.map +1 -0
- package/dist/auth/providers/google.d.ts +32 -0
- package/dist/auth/providers/google.d.ts.map +1 -0
- package/dist/auth/providers/google.js +145 -0
- package/dist/auth/providers/google.js.map +1 -0
- package/dist/auth/providers/index.d.ts +22 -0
- package/dist/auth/providers/index.d.ts.map +1 -0
- package/dist/auth/providers/index.js +17 -0
- package/dist/auth/providers/index.js.map +1 -0
- package/dist/auth/routes.d.ts +63 -0
- package/dist/auth/routes.d.ts.map +1 -0
- package/dist/auth/routes.js +827 -0
- package/dist/auth/routes.js.map +1 -0
- package/dist/auth/services/index.d.ts +10 -0
- package/dist/auth/services/index.d.ts.map +1 -0
- package/dist/auth/services/index.js +7 -0
- package/dist/auth/services/index.js.map +1 -0
- package/dist/auth/services/session.d.ts +81 -0
- package/dist/auth/services/session.d.ts.map +1 -0
- package/dist/auth/services/session.js +186 -0
- package/dist/auth/services/session.js.map +1 -0
- package/dist/auth/services/token.d.ts +41 -0
- package/dist/auth/services/token.d.ts.map +1 -0
- package/dist/auth/services/token.js +44 -0
- package/dist/auth/services/token.js.map +1 -0
- package/dist/auth/services/verification.d.ts +77 -0
- package/dist/auth/services/verification.d.ts.map +1 -0
- package/dist/auth/services/verification.js +143 -0
- package/dist/auth/services/verification.js.map +1 -0
- package/dist/auth/types.d.ts +318 -0
- package/dist/auth/types.d.ts.map +1 -0
- package/dist/auth/types.js +6 -0
- package/dist/auth/types.js.map +1 -0
- package/dist/customTypes/arrays.d.ts +200 -0
- package/dist/customTypes/arrays.d.ts.map +1 -0
- package/dist/customTypes/arrays.js +309 -0
- package/dist/customTypes/arrays.js.map +1 -0
- package/dist/customTypes/index.d.ts +8 -0
- package/dist/customTypes/index.d.ts.map +1 -0
- package/dist/customTypes/index.js +11 -0
- package/dist/customTypes/index.js.map +1 -0
- package/dist/customTypes/postgis.d.ts +146 -0
- package/dist/customTypes/postgis.d.ts.map +1 -0
- package/dist/customTypes/postgis.js +315 -0
- package/dist/customTypes/postgis.js.map +1 -0
- package/dist/customTypes/ranges.d.ts +128 -0
- package/dist/customTypes/ranges.d.ts.map +1 -0
- package/dist/customTypes/ranges.js +257 -0
- package/dist/customTypes/ranges.js.map +1 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +42 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations/0.1.0-alpha.0_initial_setup.d.ts +29 -0
- package/dist/migrations/0.1.0-alpha.0_initial_setup.d.ts.map +1 -0
- package/dist/migrations/0.1.0-alpha.0_initial_setup.js +72 -0
- package/dist/migrations/0.1.0-alpha.0_initial_setup.js.map +1 -0
- package/dist/migrations/_example_migration.d.ts +31 -0
- package/dist/migrations/_example_migration.d.ts.map +1 -0
- package/dist/migrations/_example_migration.js +75 -0
- package/dist/migrations/_example_migration.js.map +1 -0
- package/dist/plugins/definePlugin.d.ts +49 -0
- package/dist/plugins/definePlugin.d.ts.map +1 -0
- package/dist/plugins/definePlugin.js +131 -0
- package/dist/plugins/definePlugin.js.map +1 -0
- package/dist/plugins/softDelete.d.ts +179 -0
- package/dist/plugins/softDelete.d.ts.map +1 -0
- package/dist/plugins/softDelete.js +235 -0
- package/dist/plugins/softDelete.js.map +1 -0
- package/dist/routes/auth.route.d.ts +14 -0
- package/dist/routes/auth.route.d.ts.map +1 -0
- package/dist/routes/auth.route.js +421 -0
- package/dist/routes/auth.route.js.map +1 -0
- package/dist/routes/file.route.d.ts +7 -0
- package/dist/routes/file.route.d.ts.map +1 -0
- package/dist/routes/file.route.js +274 -0
- package/dist/routes/file.route.js.map +1 -0
- package/dist/routes/items.route.d.ts +7 -0
- package/dist/routes/items.route.d.ts.map +1 -0
- package/dist/routes/items.route.js +369 -0
- package/dist/routes/items.route.js.map +1 -0
- package/dist/routes/migration.route.d.ts +7 -0
- package/dist/routes/migration.route.d.ts.map +1 -0
- package/dist/routes/migration.route.js +225 -0
- package/dist/routes/migration.route.js.map +1 -0
- package/dist/routes/notification.route.d.ts +7 -0
- package/dist/routes/notification.route.d.ts.map +1 -0
- package/dist/routes/notification.route.js +124 -0
- package/dist/routes/notification.route.js.map +1 -0
- package/dist/routes/openapi.route.d.ts +7 -0
- package/dist/routes/openapi.route.d.ts.map +1 -0
- package/dist/routes/openapi.route.js +2169 -0
- package/dist/routes/openapi.route.js.map +1 -0
- package/dist/routes/permission.route.d.ts +7 -0
- package/dist/routes/permission.route.d.ts.map +1 -0
- package/dist/routes/permission.route.js +158 -0
- package/dist/routes/permission.route.js.map +1 -0
- package/dist/routes/realtime.route.d.ts +21 -0
- package/dist/routes/realtime.route.d.ts.map +1 -0
- package/dist/routes/realtime.route.js +243 -0
- package/dist/routes/realtime.route.js.map +1 -0
- package/dist/routes/reports.route.d.ts +7 -0
- package/dist/routes/reports.route.d.ts.map +1 -0
- package/dist/routes/reports.route.js +95 -0
- package/dist/routes/reports.route.js.map +1 -0
- package/dist/routes/schema.route.d.ts +7 -0
- package/dist/routes/schema.route.d.ts.map +1 -0
- package/dist/routes/schema.route.js +1780 -0
- package/dist/routes/schema.route.js.map +1 -0
- package/dist/routes/settings.route.d.ts +7 -0
- package/dist/routes/settings.route.d.ts.map +1 -0
- package/dist/routes/settings.route.js +154 -0
- package/dist/routes/settings.route.js.map +1 -0
- package/dist/routes/templates.route.d.ts +7 -0
- package/dist/routes/templates.route.d.ts.map +1 -0
- package/dist/routes/templates.route.js +91 -0
- package/dist/routes/templates.route.js.map +1 -0
- package/dist/routes/utils.route.d.ts +7 -0
- package/dist/routes/utils.route.d.ts.map +1 -0
- package/dist/routes/utils.route.js +33 -0
- package/dist/routes/utils.route.js.map +1 -0
- package/dist/routes/workflow.route.d.ts +7 -0
- package/dist/routes/workflow.route.d.ts.map +1 -0
- package/dist/routes/workflow.route.js +787 -0
- package/dist/routes/workflow.route.js.map +1 -0
- package/dist/services/AssetsService.d.ts +39 -0
- package/dist/services/AssetsService.d.ts.map +1 -0
- package/dist/services/AssetsService.js +255 -0
- package/dist/services/AssetsService.js.map +1 -0
- package/dist/services/CacheService.d.ts +169 -0
- package/dist/services/CacheService.d.ts.map +1 -0
- package/dist/services/CacheService.js +722 -0
- package/dist/services/CacheService.js.map +1 -0
- package/dist/services/FilesService.d.ts +30 -0
- package/dist/services/FilesService.d.ts.map +1 -0
- package/dist/services/FilesService.js +268 -0
- package/dist/services/FilesService.js.map +1 -0
- package/dist/services/HooksManager.d.ts +38 -0
- package/dist/services/HooksManager.d.ts.map +1 -0
- package/dist/services/HooksManager.js +165 -0
- package/dist/services/HooksManager.js.map +1 -0
- package/dist/services/ItemsService.d.ts +273 -0
- package/dist/services/ItemsService.d.ts.map +1 -0
- package/dist/services/ItemsService.js +2458 -0
- package/dist/services/ItemsService.js.map +1 -0
- package/dist/services/MailService.d.ts +76 -0
- package/dist/services/MailService.d.ts.map +1 -0
- package/dist/services/MailService.js +585 -0
- package/dist/services/MailService.js.map +1 -0
- package/dist/services/MigrationService.d.ts +243 -0
- package/dist/services/MigrationService.d.ts.map +1 -0
- package/dist/services/MigrationService.js +914 -0
- package/dist/services/MigrationService.js.map +1 -0
- package/dist/services/NotificationService.d.ts +35 -0
- package/dist/services/NotificationService.d.ts.map +1 -0
- package/dist/services/NotificationService.js +159 -0
- package/dist/services/NotificationService.js.map +1 -0
- package/dist/services/PermissionService.d.ts +128 -0
- package/dist/services/PermissionService.d.ts.map +1 -0
- package/dist/services/PermissionService.js +373 -0
- package/dist/services/PermissionService.js.map +1 -0
- package/dist/services/PluginManager.d.ts +138 -0
- package/dist/services/PluginManager.d.ts.map +1 -0
- package/dist/services/PluginManager.js +463 -0
- package/dist/services/PluginManager.js.map +1 -0
- package/dist/services/RealtimeService.d.ts +209 -0
- package/dist/services/RealtimeService.d.ts.map +1 -0
- package/dist/services/RealtimeService.js +978 -0
- package/dist/services/RealtimeService.js.map +1 -0
- package/dist/services/ReportService.d.ts +13 -0
- package/dist/services/ReportService.d.ts.map +1 -0
- package/dist/services/ReportService.js +91 -0
- package/dist/services/ReportService.js.map +1 -0
- package/dist/services/SettingsService.d.ts +60 -0
- package/dist/services/SettingsService.d.ts.map +1 -0
- package/dist/services/SettingsService.js +474 -0
- package/dist/services/SettingsService.js.map +1 -0
- package/dist/services/SocketService.d.ts +129 -0
- package/dist/services/SocketService.d.ts.map +1 -0
- package/dist/services/SocketService.js +600 -0
- package/dist/services/SocketService.js.map +1 -0
- package/dist/services/StatsService.d.ts +10 -0
- package/dist/services/StatsService.d.ts.map +1 -0
- package/dist/services/StatsService.js +40 -0
- package/dist/services/StatsService.js.map +1 -0
- package/dist/services/StorageService.d.ts +20 -0
- package/dist/services/StorageService.d.ts.map +1 -0
- package/dist/services/StorageService.js +164 -0
- package/dist/services/StorageService.js.map +1 -0
- package/dist/services/TasksService.d.ts +74 -0
- package/dist/services/TasksService.d.ts.map +1 -0
- package/dist/services/TasksService.js +404 -0
- package/dist/services/TasksService.js.map +1 -0
- package/dist/services/WorkflowService.d.ts +305 -0
- package/dist/services/WorkflowService.d.ts.map +1 -0
- package/dist/services/WorkflowService.js +1811 -0
- package/dist/services/WorkflowService.js.map +1 -0
- package/dist/templates/logo/logo.png +0 -0
- package/dist/templates/mails/default.liquid +23 -0
- package/dist/types/aggregation.d.ts +40 -0
- package/dist/types/aggregation.d.ts.map +1 -0
- package/dist/types/aggregation.js +6 -0
- package/dist/types/aggregation.js.map +1 -0
- package/dist/types/assets.d.ts +32 -0
- package/dist/types/assets.d.ts.map +1 -0
- package/dist/types/assets.js +6 -0
- package/dist/types/assets.js.map +1 -0
- package/dist/types/auth.d.ts +50 -0
- package/dist/types/auth.d.ts.map +1 -0
- package/dist/types/auth.js +6 -0
- package/dist/types/auth.js.map +1 -0
- package/dist/types/cache.d.ts +47 -0
- package/dist/types/cache.d.ts.map +1 -0
- package/dist/types/cache.js +6 -0
- package/dist/types/cache.js.map +1 -0
- package/dist/types/database.d.ts +16 -0
- package/dist/types/database.d.ts.map +1 -0
- package/dist/types/database.js +6 -0
- package/dist/types/database.js.map +1 -0
- package/dist/types/fields.d.ts +71 -0
- package/dist/types/fields.d.ts.map +1 -0
- package/dist/types/fields.js +6 -0
- package/dist/types/fields.js.map +1 -0
- package/dist/types/files.d.ts +33 -0
- package/dist/types/files.d.ts.map +1 -0
- package/dist/types/files.js +6 -0
- package/dist/types/files.js.map +1 -0
- package/dist/types/hooks.d.ts +29 -0
- package/dist/types/hooks.d.ts.map +1 -0
- package/dist/types/hooks.js +6 -0
- package/dist/types/hooks.js.map +1 -0
- package/dist/types/import-export.d.ts +62 -0
- package/dist/types/import-export.d.ts.map +1 -0
- package/dist/types/import-export.js +6 -0
- package/dist/types/import-export.js.map +1 -0
- package/dist/types/index.d.ts +31 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +58 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/mail.d.ts +34 -0
- package/dist/types/mail.d.ts.map +1 -0
- package/dist/types/mail.js +6 -0
- package/dist/types/mail.js.map +1 -0
- package/dist/types/notifications.d.ts +16 -0
- package/dist/types/notifications.d.ts.map +1 -0
- package/dist/types/notifications.js +6 -0
- package/dist/types/notifications.js.map +1 -0
- package/dist/types/plugin.d.ts +351 -0
- package/dist/types/plugin.d.ts.map +1 -0
- package/dist/types/plugin.js +8 -0
- package/dist/types/plugin.js.map +1 -0
- package/dist/types/query.d.ts +71 -0
- package/dist/types/query.d.ts.map +1 -0
- package/dist/types/query.js +6 -0
- package/dist/types/query.js.map +1 -0
- package/dist/types/relations.d.ts +111 -0
- package/dist/types/relations.d.ts.map +1 -0
- package/dist/types/relations.js +6 -0
- package/dist/types/relations.js.map +1 -0
- package/dist/types/reports.d.ts +17 -0
- package/dist/types/reports.d.ts.map +1 -0
- package/dist/types/reports.js +6 -0
- package/dist/types/reports.js.map +1 -0
- package/dist/types/schema.d.ts +26 -0
- package/dist/types/schema.d.ts.map +1 -0
- package/dist/types/schema.js +6 -0
- package/dist/types/schema.js.map +1 -0
- package/dist/types/seed.d.ts +27 -0
- package/dist/types/seed.d.ts.map +1 -0
- package/dist/types/seed.js +6 -0
- package/dist/types/seed.js.map +1 -0
- package/dist/types/services.d.ts +68 -0
- package/dist/types/services.d.ts.map +1 -0
- package/dist/types/services.js +6 -0
- package/dist/types/services.js.map +1 -0
- package/dist/types/settings.d.ts +36 -0
- package/dist/types/settings.d.ts.map +1 -0
- package/dist/types/settings.js +6 -0
- package/dist/types/settings.js.map +1 -0
- package/dist/types/sockets.d.ts +26 -0
- package/dist/types/sockets.d.ts.map +1 -0
- package/dist/types/sockets.js +6 -0
- package/dist/types/sockets.js.map +1 -0
- package/dist/types/sort.d.ts +25 -0
- package/dist/types/sort.d.ts.map +1 -0
- package/dist/types/sort.js +6 -0
- package/dist/types/sort.js.map +1 -0
- package/dist/types/spatial.d.ts +19 -0
- package/dist/types/spatial.d.ts.map +1 -0
- package/dist/types/spatial.js +6 -0
- package/dist/types/spatial.js.map +1 -0
- package/dist/types/stats.d.ts +21 -0
- package/dist/types/stats.d.ts.map +1 -0
- package/dist/types/stats.js +6 -0
- package/dist/types/stats.js.map +1 -0
- package/dist/types/storage.d.ts +19 -0
- package/dist/types/storage.d.ts.map +1 -0
- package/dist/types/storage.js +6 -0
- package/dist/types/storage.js.map +1 -0
- package/dist/types/tasks.d.ts +14 -0
- package/dist/types/tasks.d.ts.map +1 -0
- package/dist/types/tasks.js +6 -0
- package/dist/types/tasks.js.map +1 -0
- package/dist/types/utils.d.ts +54 -0
- package/dist/types/utils.d.ts.map +1 -0
- package/dist/types/utils.js +6 -0
- package/dist/types/utils.js.map +1 -0
- package/dist/types/workflow.d.ts +17 -0
- package/dist/types/workflow.d.ts.map +1 -0
- package/dist/types/workflow.js +6 -0
- package/dist/types/workflow.js.map +1 -0
- package/dist/utils/aggregationUtils.d.ts +192 -0
- package/dist/utils/aggregationUtils.d.ts.map +1 -0
- package/dist/utils/aggregationUtils.js +450 -0
- package/dist/utils/aggregationUtils.js.map +1 -0
- package/dist/utils/auth.d.ts +93 -0
- package/dist/utils/auth.d.ts.map +1 -0
- package/dist/utils/auth.js +557 -0
- package/dist/utils/auth.js.map +1 -0
- package/dist/utils/cache.d.ts +64 -0
- package/dist/utils/cache.d.ts.map +1 -0
- package/dist/utils/cache.js +464 -0
- package/dist/utils/cache.js.map +1 -0
- package/dist/utils/common.d.ts +53 -0
- package/dist/utils/common.d.ts.map +1 -0
- package/dist/utils/common.js +162 -0
- package/dist/utils/common.js.map +1 -0
- package/dist/utils/db.d.ts +101 -0
- package/dist/utils/db.d.ts.map +1 -0
- package/dist/utils/db.js +413 -0
- package/dist/utils/db.js.map +1 -0
- package/dist/utils/dirname.d.ts +30 -0
- package/dist/utils/dirname.d.ts.map +1 -0
- package/dist/utils/dirname.js +95 -0
- package/dist/utils/dirname.js.map +1 -0
- package/dist/utils/dynamicVariableResolver.d.ts +17 -0
- package/dist/utils/dynamicVariableResolver.d.ts.map +1 -0
- package/dist/utils/dynamicVariableResolver.js +262 -0
- package/dist/utils/dynamicVariableResolver.js.map +1 -0
- package/dist/utils/env.d.ts +38 -0
- package/dist/utils/env.d.ts.map +1 -0
- package/dist/utils/env.js +80 -0
- package/dist/utils/env.js.map +1 -0
- package/dist/utils/errorHandler.d.ts +14 -0
- package/dist/utils/errorHandler.d.ts.map +1 -0
- package/dist/utils/errorHandler.js +79 -0
- package/dist/utils/errorHandler.js.map +1 -0
- package/dist/utils/fieldExpansion.d.ts +30 -0
- package/dist/utils/fieldExpansion.d.ts.map +1 -0
- package/dist/utils/fieldExpansion.js +145 -0
- package/dist/utils/fieldExpansion.js.map +1 -0
- package/dist/utils/fieldUtils.d.ts +179 -0
- package/dist/utils/fieldUtils.d.ts.map +1 -0
- package/dist/utils/fieldUtils.js +424 -0
- package/dist/utils/fieldUtils.js.map +1 -0
- package/dist/utils/filterOperators.d.ts +472 -0
- package/dist/utils/filterOperators.d.ts.map +1 -0
- package/dist/utils/filterOperators.js +1229 -0
- package/dist/utils/filterOperators.js.map +1 -0
- package/dist/utils/importUtils.d.ts +127 -0
- package/dist/utils/importUtils.d.ts.map +1 -0
- package/dist/utils/importUtils.js +437 -0
- package/dist/utils/importUtils.js.map +1 -0
- package/dist/utils/index.d.ts +75 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +101 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/logger.d.ts +41 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +217 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/orderUtils.d.ts +117 -0
- package/dist/utils/orderUtils.d.ts.map +1 -0
- package/dist/utils/orderUtils.js +249 -0
- package/dist/utils/orderUtils.js.map +1 -0
- package/dist/utils/queryBuilder.d.ts +118 -0
- package/dist/utils/queryBuilder.d.ts.map +1 -0
- package/dist/utils/queryBuilder.js +489 -0
- package/dist/utils/queryBuilder.js.map +1 -0
- package/dist/utils/relationLoader.d.ts +65 -0
- package/dist/utils/relationLoader.d.ts.map +1 -0
- package/dist/utils/relationLoader.js +1081 -0
- package/dist/utils/relationLoader.js.map +1 -0
- package/dist/utils/relationPathResolver.d.ts +30 -0
- package/dist/utils/relationPathResolver.d.ts.map +1 -0
- package/dist/utils/relationPathResolver.js +173 -0
- package/dist/utils/relationPathResolver.js.map +1 -0
- package/dist/utils/relationUtils.d.ts +139 -0
- package/dist/utils/relationUtils.d.ts.map +1 -0
- package/dist/utils/relationUtils.js +711 -0
- package/dist/utils/relationUtils.js.map +1 -0
- package/dist/utils/router.d.ts +6 -0
- package/dist/utils/router.d.ts.map +1 -0
- package/dist/utils/router.js +95 -0
- package/dist/utils/router.js.map +1 -0
- package/dist/utils/schema.d.ts +88 -0
- package/dist/utils/schema.d.ts.map +1 -0
- package/dist/utils/schema.js +24 -0
- package/dist/utils/schema.js.map +1 -0
- package/dist/utils/schemaManager.d.ts +238 -0
- package/dist/utils/schemaManager.d.ts.map +1 -0
- package/dist/utils/schemaManager.js +1992 -0
- package/dist/utils/schemaManager.js.map +1 -0
- package/dist/utils/schemaValidator.d.ts +83 -0
- package/dist/utils/schemaValidator.d.ts.map +1 -0
- package/dist/utils/schemaValidator.js +491 -0
- package/dist/utils/schemaValidator.js.map +1 -0
- package/dist/utils/seed.d.ts +45 -0
- package/dist/utils/seed.d.ts.map +1 -0
- package/dist/utils/seed.js +248 -0
- package/dist/utils/seed.js.map +1 -0
- package/dist/utils/sessionCleanup.d.ts +10 -0
- package/dist/utils/sessionCleanup.d.ts.map +1 -0
- package/dist/utils/sessionCleanup.js +49 -0
- package/dist/utils/sessionCleanup.js.map +1 -0
- package/dist/utils/sortUtils.d.ts +117 -0
- package/dist/utils/sortUtils.d.ts.map +1 -0
- package/dist/utils/sortUtils.js +232 -0
- package/dist/utils/sortUtils.js.map +1 -0
- package/dist/utils/spatialUtils.d.ts +244 -0
- package/dist/utils/spatialUtils.d.ts.map +1 -0
- package/dist/utils/spatialUtils.js +359 -0
- package/dist/utils/spatialUtils.js.map +1 -0
- package/dist/utils/systemschema.d.ts +11040 -0
- package/dist/utils/systemschema.d.ts.map +1 -0
- package/dist/utils/systemschema.js +1777 -0
- package/dist/utils/systemschema.js.map +1 -0
- package/dist/utils/tenantUtils.d.ts +34 -0
- package/dist/utils/tenantUtils.d.ts.map +1 -0
- package/dist/utils/tenantUtils.js +124 -0
- package/dist/utils/tenantUtils.js.map +1 -0
- package/dist/utils/typeMapper.d.ts +25 -0
- package/dist/utils/typeMapper.d.ts.map +1 -0
- package/dist/utils/typeMapper.js +282 -0
- package/dist/utils/typeMapper.js.map +1 -0
- package/dist/utils/valueValidator.d.ts +60 -0
- package/dist/utils/valueValidator.d.ts.map +1 -0
- package/dist/utils/valueValidator.js +303 -0
- package/dist/utils/valueValidator.js.map +1 -0
- package/dist/utils/workflow.d.ts +87 -0
- package/dist/utils/workflow.d.ts.map +1 -0
- package/dist/utils/workflow.js +205 -0
- package/dist/utils/workflow.js.map +1 -0
- package/package.json +115 -0
|
@@ -0,0 +1,2458 @@
|
|
|
1
|
+
import { and, eq, inArray, sql, asc, desc } from 'drizzle-orm';
|
|
2
|
+
import { alias } from 'drizzle-orm/pg-core';
|
|
3
|
+
import argon2 from 'argon2';
|
|
4
|
+
import { APIError } from '../utils/errorHandler.js';
|
|
5
|
+
import { db, createTransaction, getCacheService } from '../utils/db.js';
|
|
6
|
+
import { schemaManager } from '../utils/schemaManager.js';
|
|
7
|
+
import { hooksManager } from './HooksManager.js';
|
|
8
|
+
import env from '../utils/env.js';
|
|
9
|
+
import { permissionService } from './PermissionService.js';
|
|
10
|
+
import { resolveDynamicVariables } from '../utils/dynamicVariableResolver.js';
|
|
11
|
+
import { drizzleWhere, combineFilters, applyPagination, applyFullTextSearch } from '../utils/queryBuilder.js';
|
|
12
|
+
import { drizzleOrder } from '../utils/orderUtils.js';
|
|
13
|
+
import { expandFieldsWithIncludes, buildSelectWithJoins, loadSeparateRelations, nestJoinedRelations, hasSeparateQueries } from '../utils/relationLoader.js';
|
|
14
|
+
import { processRelationalData, handleHasManyRelationship, handleM2MRelationship, handleM2ARelationship, handleRelatedRecordsBeforeDelete, validateRelationalData, resolveCircularDependencies, processDeferredFields } from '../utils/relationUtils.js';
|
|
15
|
+
import { resolveRelationPath } from '../utils/relationPathResolver.js';
|
|
16
|
+
import fieldUtils from '../utils/fieldUtils.js';
|
|
17
|
+
import valueValidator from '../utils/valueValidator.js';
|
|
18
|
+
import { shouldEnforceTenantContext, validateTenantContext, buildTenantFilter } from '../utils/tenantUtils.js';
|
|
19
|
+
import { buildAggregateAttributes, buildGroupByExpressions } from '../utils/aggregationUtils.js';
|
|
20
|
+
import { softDelete, restore } from '../plugins/softDelete.js';
|
|
21
|
+
/**
|
|
22
|
+
* ItemsService - Core CRUD service for all collections
|
|
23
|
+
*
|
|
24
|
+
* Provides:
|
|
25
|
+
* - Read operations with filters, sorting, pagination, includes
|
|
26
|
+
* - Create/Update/Delete operations with relation handling
|
|
27
|
+
* - Permission enforcement and field-level security
|
|
28
|
+
* - Multi-tenancy support
|
|
29
|
+
* - Lifecycle hooks integration
|
|
30
|
+
* - Soft-delete handling
|
|
31
|
+
*/
|
|
32
|
+
export class ItemsService {
|
|
33
|
+
collection;
|
|
34
|
+
accountability;
|
|
35
|
+
tenant;
|
|
36
|
+
table;
|
|
37
|
+
primaryKey;
|
|
38
|
+
isMultiTenant;
|
|
39
|
+
constructor(collection, params = {}) {
|
|
40
|
+
this.collection = collection;
|
|
41
|
+
this.accountability = params.accountability;
|
|
42
|
+
this.tenant = params.tenant;
|
|
43
|
+
// Get table and schema info from schema manager
|
|
44
|
+
this.table = schemaManager.getTable(collection);
|
|
45
|
+
this.primaryKey = schemaManager.getPrimaryKey(collection);
|
|
46
|
+
this.isMultiTenant = env.get('MULTI_TENANT') === 'true';
|
|
47
|
+
// Debug: log table columns
|
|
48
|
+
if (collection === 'products') {
|
|
49
|
+
console.log(`[ItemsService.constructor] Products table columns:`, Object.keys(this.table).filter(k => !k.startsWith('_')));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Get primary key column from table (with type safety)
|
|
54
|
+
*/
|
|
55
|
+
getPrimaryKeyColumn() {
|
|
56
|
+
return this.table[this.primaryKey];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Parse ID to correct type (string or number)
|
|
60
|
+
*/
|
|
61
|
+
parseId(id) {
|
|
62
|
+
return isNaN(Number(id)) ? id : parseInt(String(id));
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Extract all table names involved in a query (including relations)
|
|
66
|
+
* This is CRITICAL for proper cache invalidation
|
|
67
|
+
*/
|
|
68
|
+
extractAllTables(includes) {
|
|
69
|
+
const tables = [this.collection]; // Always include main table
|
|
70
|
+
if (!includes || includes.length === 0) {
|
|
71
|
+
return tables;
|
|
72
|
+
}
|
|
73
|
+
for (const include of includes) {
|
|
74
|
+
// Get the relation definition
|
|
75
|
+
const relationDef = schemaManager.getRelation(this.collection, include.relation);
|
|
76
|
+
if (!relationDef) {
|
|
77
|
+
console.warn(`[ItemsService.extractAllTables] Relation not found: ${this.collection}.${include.relation}`);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
// Add the related table
|
|
81
|
+
if (relationDef.relatedCollection) {
|
|
82
|
+
tables.push(relationDef.relatedCollection);
|
|
83
|
+
}
|
|
84
|
+
// For M2M relations, add the junction table
|
|
85
|
+
if (relationDef.type === 'M2M' && relationDef.junctionTable) {
|
|
86
|
+
tables.push(relationDef.junctionTable);
|
|
87
|
+
}
|
|
88
|
+
// For M2A (polymorphic) relations, add all possible related collections
|
|
89
|
+
if (relationDef.type === 'M2A' && relationDef.relatedCollections) {
|
|
90
|
+
tables.push(...relationDef.relatedCollections);
|
|
91
|
+
}
|
|
92
|
+
// Recursively handle nested includes
|
|
93
|
+
if (include.include && include.include.length > 0) {
|
|
94
|
+
// Create a temporary service for the related collection to extract its tables
|
|
95
|
+
try {
|
|
96
|
+
const relatedService = new ItemsService(relationDef.relatedCollection, {
|
|
97
|
+
accountability: this.accountability,
|
|
98
|
+
tenant: this.tenant
|
|
99
|
+
});
|
|
100
|
+
const nestedTables = relatedService.extractAllTables(include.include);
|
|
101
|
+
tables.push(...nestedTables);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
console.warn(`[ItemsService.extractAllTables] Error extracting nested tables:`, error);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Return unique table names only
|
|
109
|
+
return [...new Set(tables)];
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Get all involved tables from processed includes
|
|
113
|
+
* Used for cache invalidation tracking
|
|
114
|
+
*/
|
|
115
|
+
getInvolvedTables(processedIncludes) {
|
|
116
|
+
const tables = [this.collection]; // Always include main table
|
|
117
|
+
const collectTables = (includes) => {
|
|
118
|
+
for (const include of includes) {
|
|
119
|
+
// Add the related table from the alias
|
|
120
|
+
if (include.alias) {
|
|
121
|
+
const tableName = include.alias.split('_')[0]; // Extract table name from alias
|
|
122
|
+
tables.push(tableName);
|
|
123
|
+
}
|
|
124
|
+
// Also try to get table name from the table object
|
|
125
|
+
if (include.table && typeof include.table === 'object') {
|
|
126
|
+
const tableName = include.table[Symbol.for('drizzle:Name')];
|
|
127
|
+
if (tableName) {
|
|
128
|
+
tables.push(tableName);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// For M2M relations, also include junction table if available
|
|
132
|
+
if (include.relationType === 'BelongsToMany') {
|
|
133
|
+
// Junction table would be tracked separately in the relation definition
|
|
134
|
+
const relationDef = schemaManager.getRelation(this.collection, include.relation);
|
|
135
|
+
if (relationDef?.junctionTable) {
|
|
136
|
+
tables.push(relationDef.junctionTable);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Recursively handle nested includes
|
|
140
|
+
if (include.nested && include.nested.length > 0) {
|
|
141
|
+
collectTables(include.nested);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
collectTables(processedIncludes);
|
|
146
|
+
// Return unique table names only
|
|
147
|
+
return [...new Set(tables)];
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Generate cache key from query parameters
|
|
151
|
+
*/
|
|
152
|
+
generateCacheKey(query, processedIncludes) {
|
|
153
|
+
const keyParts = [
|
|
154
|
+
`collection:${this.collection}`,
|
|
155
|
+
`filter:${JSON.stringify(query.filter || {})}`,
|
|
156
|
+
`sort:${JSON.stringify(query.sort || {})}`,
|
|
157
|
+
`fields:${JSON.stringify(query.fields || [])}`,
|
|
158
|
+
`limit:${query.limit || 'none'}`,
|
|
159
|
+
`offset:${query.offset || 0}`,
|
|
160
|
+
`page:${query.page || 'none'}`,
|
|
161
|
+
`search:${query.search || ''}`,
|
|
162
|
+
`includes:${processedIncludes.map(i => i.relation).join(',')}`,
|
|
163
|
+
`paranoid:${query.paranoid !== false}`,
|
|
164
|
+
];
|
|
165
|
+
// Add tenant context if multi-tenant
|
|
166
|
+
if (this.accountability?.tenant) {
|
|
167
|
+
keyParts.push(`tenant:${this.accountability.tenant}`);
|
|
168
|
+
}
|
|
169
|
+
// Add user context for user-specific data
|
|
170
|
+
if (this.accountability?.user?.id) {
|
|
171
|
+
keyParts.push(`user:${this.accountability.user.id}`);
|
|
172
|
+
}
|
|
173
|
+
return keyParts.join('|');
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Execute query with cache wrapper
|
|
177
|
+
* Checks cache before querying, stores results after query
|
|
178
|
+
*/
|
|
179
|
+
async executeWithCache(cacheKey, tables, queryFn) {
|
|
180
|
+
const cache = getCacheService();
|
|
181
|
+
// If cache is disabled, execute query directly
|
|
182
|
+
if (!cache) {
|
|
183
|
+
return await queryFn();
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
// Try to get from cache
|
|
187
|
+
const cachedResult = await cache.get(cacheKey);
|
|
188
|
+
if (cachedResult !== undefined && cachedResult !== null) {
|
|
189
|
+
console.log(`[Cache] HIT: ${this.collection} - ${cacheKey.substring(0, 100)}...`);
|
|
190
|
+
return cachedResult;
|
|
191
|
+
}
|
|
192
|
+
// Cache miss - execute query
|
|
193
|
+
console.log(`[Cache] MISS: ${this.collection} - ${cacheKey.substring(0, 100)}...`);
|
|
194
|
+
const result = await queryFn();
|
|
195
|
+
// Store in cache
|
|
196
|
+
await cache.put(cacheKey, result, tables);
|
|
197
|
+
console.log(`[Cache] STORED: ${this.collection} - Cached result for tables: ${tables.join(', ')}`);
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
// Cache errors should not break the operation
|
|
202
|
+
console.error('[Cache] Error during cache operation:', error);
|
|
203
|
+
// Fallback to executing query without cache
|
|
204
|
+
return await queryFn();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Invalidate cache for this collection and all related tables
|
|
209
|
+
* Called after any mutation (create, update, delete)
|
|
210
|
+
*/
|
|
211
|
+
async invalidateCache(additionalTables = []) {
|
|
212
|
+
const cache = getCacheService();
|
|
213
|
+
if (!cache) {
|
|
214
|
+
// Cache not enabled, nothing to do
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
// Invalidate by the main collection table
|
|
219
|
+
const tablesToInvalidate = [this.collection, ...additionalTables];
|
|
220
|
+
// Remove duplicates
|
|
221
|
+
const uniqueTables = [...new Set(tablesToInvalidate)];
|
|
222
|
+
console.log(`[ItemsService.invalidateCache] Invalidating cache for tables: ${uniqueTables.join(', ')}`);
|
|
223
|
+
await cache.onMutate({ tables: uniqueTables });
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
// Cache invalidation failure should not break the operation
|
|
227
|
+
console.error('[ItemsService.invalidateCache] Cache invalidation failed:', error);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Get role ID from accountability
|
|
232
|
+
* The auth middleware should always set role.id from the database
|
|
233
|
+
*/
|
|
234
|
+
getRoleId() {
|
|
235
|
+
if (!this.accountability?.role)
|
|
236
|
+
return null;
|
|
237
|
+
// Role should be an object with id set by auth middleware
|
|
238
|
+
if (typeof this.accountability.role === 'object' && this.accountability.role.id) {
|
|
239
|
+
return this.accountability.role.id;
|
|
240
|
+
}
|
|
241
|
+
// Fallback for legacy string role (shouldn't happen with current middleware)
|
|
242
|
+
if (typeof this.accountability.role === 'string') {
|
|
243
|
+
console.warn('[ItemsService.getRoleId] Role is a string, expected object with id. This may cause permission issues.');
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Check if user is administrator
|
|
250
|
+
*/
|
|
251
|
+
async isAdministrator() {
|
|
252
|
+
console.log('[isAdministrator] accountability:', JSON.stringify(this.accountability, null, 2));
|
|
253
|
+
if (!this.accountability) {
|
|
254
|
+
console.log('[isAdministrator] No accountability - returning true');
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
if (Object.keys(this.accountability).length === 0) {
|
|
258
|
+
console.log('[isAdministrator] Empty accountability - returning true');
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
if (!this.accountability.role) {
|
|
262
|
+
console.log('[isAdministrator] No role - returning false');
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
// Check user.isAdmin flag first
|
|
266
|
+
console.log('[isAdministrator] Checking user.isAdmin:', this.accountability.user?.isAdmin);
|
|
267
|
+
if (this.accountability.user && this.accountability.user.isAdmin === true) {
|
|
268
|
+
console.log('[isAdministrator] User has isAdmin=true - returning true');
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
// If role is a string, check directly
|
|
272
|
+
if (typeof this.accountability.role === 'string') {
|
|
273
|
+
console.log('[isAdministrator] Role is string:', this.accountability.role);
|
|
274
|
+
return this.accountability.role === 'administrator';
|
|
275
|
+
}
|
|
276
|
+
// If role is an object with name, check the name
|
|
277
|
+
if (typeof this.accountability.role === 'object' && this.accountability.role.name) {
|
|
278
|
+
console.log('[isAdministrator] Role object name:', this.accountability.role.name);
|
|
279
|
+
return this.accountability.role.name === 'administrator';
|
|
280
|
+
}
|
|
281
|
+
// If role is an object with id, use PermissionService (hybrid cache)
|
|
282
|
+
const roleId = this.accountability.role.id;
|
|
283
|
+
if (roleId) {
|
|
284
|
+
return await permissionService.isAdministratorRoleAsync(roleId);
|
|
285
|
+
}
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Apply tenant context to query filter
|
|
290
|
+
*/
|
|
291
|
+
async enforceTenantContextFilter(filter = {}) {
|
|
292
|
+
if (!this.isMultiTenant)
|
|
293
|
+
return filter;
|
|
294
|
+
const shouldEnforce = await shouldEnforceTenantContext(this);
|
|
295
|
+
if (!shouldEnforce)
|
|
296
|
+
return filter;
|
|
297
|
+
const tenantId = this.tenant || this.accountability?.tenant;
|
|
298
|
+
if (!tenantId) {
|
|
299
|
+
throw new APIError('Tenant context required but not provided', 403);
|
|
300
|
+
}
|
|
301
|
+
// Use buildTenantFilter which handles isPublic bypass for supported collections (e.g., baasix_File)
|
|
302
|
+
const tenantFilter = buildTenantFilter(this.collection, tenantId);
|
|
303
|
+
return combineFilters(filter, tenantFilter);
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Validate and enforce tenant context on data
|
|
307
|
+
*/
|
|
308
|
+
async validateAndEnforceTenantContext(data) {
|
|
309
|
+
if (!this.isMultiTenant)
|
|
310
|
+
return data;
|
|
311
|
+
const shouldEnforce = await shouldEnforceTenantContext(this);
|
|
312
|
+
if (!shouldEnforce)
|
|
313
|
+
return data;
|
|
314
|
+
const tenantId = this.tenant || this.accountability?.tenant;
|
|
315
|
+
if (!tenantId) {
|
|
316
|
+
throw new APIError('Tenant context required but not provided', 403);
|
|
317
|
+
}
|
|
318
|
+
// Validate tenant context
|
|
319
|
+
await validateTenantContext(data, this);
|
|
320
|
+
// For baasix_User, tenant_Id is not set directly on the user record
|
|
321
|
+
// (users are associated with tenants via userRoles.tenant_Id)
|
|
322
|
+
if (this.collection === 'baasix_User') {
|
|
323
|
+
return data;
|
|
324
|
+
}
|
|
325
|
+
// Enforce tenant_Id
|
|
326
|
+
return {
|
|
327
|
+
...data,
|
|
328
|
+
tenant_Id: tenantId
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Helper to apply nested joins recursively
|
|
333
|
+
*/
|
|
334
|
+
applyNestedJoins(baseQuery, includes) {
|
|
335
|
+
for (const include of includes) {
|
|
336
|
+
if (include.separate)
|
|
337
|
+
continue;
|
|
338
|
+
if (include.relationType === 'BelongsTo' || include.relationType === 'HasOne') {
|
|
339
|
+
baseQuery = baseQuery.leftJoin(include.table, include.joinCondition);
|
|
340
|
+
if (include.nested.length > 0) {
|
|
341
|
+
baseQuery = this.applyNestedJoins(baseQuery, include.nested);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return baseQuery;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Filter relational field attributes based on permission allowed fields
|
|
349
|
+
*
|
|
350
|
+
* When a user has permission to access only specific relational fields like:
|
|
351
|
+
* - members.id
|
|
352
|
+
* - members.fullName
|
|
353
|
+
*
|
|
354
|
+
* And they request "members.*", this method ensures only the allowed fields
|
|
355
|
+
* are included in the query, not all fields from the related table.
|
|
356
|
+
*
|
|
357
|
+
* @param processedIncludes - The expanded includes from expandFieldsWithIncludes
|
|
358
|
+
* @param allowedFields - The allowed fields from permissions (e.g., ['id', 'name', 'members.id', 'members.fullName'])
|
|
359
|
+
* @param parentPath - The current path prefix for nested relations
|
|
360
|
+
*/
|
|
361
|
+
filterIncludesByAllowedFields(processedIncludes, allowedFields, parentPath = '') {
|
|
362
|
+
// If no field restrictions or wildcard access, no filtering needed
|
|
363
|
+
if (!allowedFields || allowedFields.includes('*')) {
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
for (const include of processedIncludes) {
|
|
367
|
+
const relationPath = parentPath ? `${parentPath}.${include.relation}` : include.relation;
|
|
368
|
+
// Collect allowed direct fields and nested relations for this relation
|
|
369
|
+
const allowedDirectFields = [];
|
|
370
|
+
const allowedNestedRelations = [];
|
|
371
|
+
let hasAnyFieldForRelation = false;
|
|
372
|
+
for (const field of allowedFields) {
|
|
373
|
+
if (field.startsWith(`${relationPath}.`)) {
|
|
374
|
+
hasAnyFieldForRelation = true;
|
|
375
|
+
// Extract the field name after the relation path
|
|
376
|
+
const remainingPath = field.substring(relationPath.length + 1);
|
|
377
|
+
// If it's a direct field (no more dots), add to direct fields
|
|
378
|
+
if (!remainingPath.includes('.')) {
|
|
379
|
+
if (!allowedDirectFields.includes(remainingPath)) {
|
|
380
|
+
allowedDirectFields.push(remainingPath);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// If it has more dots, it's a nested relation
|
|
384
|
+
else {
|
|
385
|
+
const nestedRelation = remainingPath.split('.')[0];
|
|
386
|
+
if (!allowedNestedRelations.includes(nestedRelation)) {
|
|
387
|
+
allowedNestedRelations.push(nestedRelation);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
// Only filter attributes if we have DIRECT fields specified for this relation
|
|
393
|
+
// If we only have nested relations, we need all parent attributes to load the relation
|
|
394
|
+
if (hasAnyFieldForRelation && allowedDirectFields.length > 0) {
|
|
395
|
+
// Check if all requested attributes are already in the allowed direct fields
|
|
396
|
+
const allAttributesAllowed = include.attributes.every(attr => allowedDirectFields.includes(attr));
|
|
397
|
+
if (!allAttributesAllowed) {
|
|
398
|
+
// Always ensure primary key is included for proper relation loading
|
|
399
|
+
if (!allowedDirectFields.includes('id')) {
|
|
400
|
+
allowedDirectFields.unshift('id');
|
|
401
|
+
}
|
|
402
|
+
// Filter attributes to only allowed direct fields
|
|
403
|
+
include.attributes = include.attributes.filter(attr => allowedDirectFields.includes(attr));
|
|
404
|
+
// If after filtering we have no attributes, use the allowed direct fields
|
|
405
|
+
if (include.attributes.length === 0) {
|
|
406
|
+
include.attributes = allowedDirectFields;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
// If only nested relations are specified, keep parent attributes intact
|
|
411
|
+
// but ensure id is included for relation loading
|
|
412
|
+
else if (hasAnyFieldForRelation && allowedNestedRelations.length > 0 && allowedDirectFields.length === 0) {
|
|
413
|
+
// Keep all attributes - we need them to load the parent relation
|
|
414
|
+
// Just make sure 'id' is present
|
|
415
|
+
if (!include.attributes.includes('id')) {
|
|
416
|
+
include.attributes.unshift('id');
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
// Recursively filter nested includes
|
|
420
|
+
if (include.nested && include.nested.length > 0) {
|
|
421
|
+
this.filterIncludesByAllowedFields(include.nested, allowedFields, relationPath);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Build complete query with filters, sorts, pagination, includes
|
|
427
|
+
*/
|
|
428
|
+
async buildQuery(query, options) {
|
|
429
|
+
const { isAdmin, action, bypassPermissions, idFilter } = options;
|
|
430
|
+
// Start with base filter
|
|
431
|
+
let filter = query.filter || {};
|
|
432
|
+
// Apply ID filter if provided
|
|
433
|
+
if (idFilter !== undefined) {
|
|
434
|
+
filter = combineFilters(filter, {
|
|
435
|
+
[this.primaryKey]: idFilter
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
// Apply permission filters
|
|
439
|
+
let permissionRelConditions = {};
|
|
440
|
+
if (!bypassPermissions && !isAdmin) {
|
|
441
|
+
const roleId = this.getRoleId();
|
|
442
|
+
// First, check if user has permission to perform this action
|
|
443
|
+
const hasAccess = await permissionService.canAccess(roleId, this.collection, action);
|
|
444
|
+
if (!hasAccess) {
|
|
445
|
+
throw new APIError(`You don't have permission to ${action} items in '${this.collection}'`, 403);
|
|
446
|
+
}
|
|
447
|
+
// Then apply permission filters
|
|
448
|
+
const permissionFilter = await permissionService.getFilter(roleId, this.collection, action, this.accountability);
|
|
449
|
+
if (permissionFilter.conditions) {
|
|
450
|
+
filter = combineFilters(filter, permissionFilter.conditions);
|
|
451
|
+
}
|
|
452
|
+
// Capture relConditions from permissions to apply later
|
|
453
|
+
if (permissionFilter.relConditions && Object.keys(permissionFilter.relConditions).length > 0) {
|
|
454
|
+
permissionRelConditions = permissionFilter.relConditions;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
// Apply tenant context
|
|
458
|
+
filter = await this.enforceTenantContextFilter(filter);
|
|
459
|
+
// Resolve dynamic variables in filter
|
|
460
|
+
filter = await resolveDynamicVariables(filter, this.accountability);
|
|
461
|
+
// Apply soft delete filter (paranoid mode)
|
|
462
|
+
// Exclude soft-deleted records unless paranoid: false is specified in options
|
|
463
|
+
const isParanoid = schemaManager.isParanoid(this.collection);
|
|
464
|
+
const includeDeleted = query.paranoid === false; // Explicit false check
|
|
465
|
+
if (isParanoid && !includeDeleted) {
|
|
466
|
+
// Add deletedAt IS NULL filter
|
|
467
|
+
filter = combineFilters(filter, {
|
|
468
|
+
[`${this.collection}.deletedAt`]: { _is_null: true }
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
// Get fields to select
|
|
472
|
+
const fields = query.fields || ['*'];
|
|
473
|
+
// Expand fields with includes
|
|
474
|
+
const { directFields, includes: processedIncludes } = expandFieldsWithIncludes(fields, this.collection);
|
|
475
|
+
// Copy expanded directFields before adding primary key (for sanitization later)
|
|
476
|
+
const expandedDirectFields = [...directFields];
|
|
477
|
+
// Ensure primary key is always included in directFields
|
|
478
|
+
// This is needed for proper record identification and loadHasManyRelations to work correctly
|
|
479
|
+
if (!directFields.includes(this.primaryKey) && !directFields.includes('*')) {
|
|
480
|
+
directFields.unshift(this.primaryKey);
|
|
481
|
+
}
|
|
482
|
+
// Use processed includes from field expansion
|
|
483
|
+
// Note: query.include is not used - includes are derived from fields parameter
|
|
484
|
+
const allIncludes = [...processedIncludes];
|
|
485
|
+
// Apply field-level permission filtering for relational fields
|
|
486
|
+
// This ensures that when a user requests "relation.*", only the fields they have
|
|
487
|
+
// permission to access are included, not all fields from the related table
|
|
488
|
+
if (!bypassPermissions && !isAdmin) {
|
|
489
|
+
const roleId = this.getRoleId();
|
|
490
|
+
if (roleId) {
|
|
491
|
+
const allowedFields = await permissionService.getAllowedFields(roleId, this.collection, action);
|
|
492
|
+
// Filter relational field attributes based on allowed fields
|
|
493
|
+
if (allowedFields && !allowedFields.includes('*')) {
|
|
494
|
+
this.filterIncludesByAllowedFields(allIncludes, allowedFields);
|
|
495
|
+
// Also filter the processedIncludes (which is the same reference used later)
|
|
496
|
+
this.filterIncludesByAllowedFields(processedIncludes, allowedFields);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
// Merge permission relConditions with query relConditions
|
|
501
|
+
// Permission relConditions take precedence (they are security constraints)
|
|
502
|
+
const mergedRelConditions = { ...query.relConditions };
|
|
503
|
+
for (const [relationName, conditions] of Object.entries(permissionRelConditions)) {
|
|
504
|
+
if (mergedRelConditions[relationName]) {
|
|
505
|
+
// Merge conditions for the same relation using combineFilters
|
|
506
|
+
mergedRelConditions[relationName] = combineFilters(mergedRelConditions[relationName], conditions);
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
mergedRelConditions[relationName] = conditions;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
// Apply relConditions to includes (supports nested relConditions)
|
|
513
|
+
if (Object.keys(mergedRelConditions).length > 0) {
|
|
514
|
+
const resolvedRelConditions = await resolveDynamicVariables(mergedRelConditions, this.accountability);
|
|
515
|
+
// Recursive function to apply relConditions to includes and their nested includes
|
|
516
|
+
const applyRelConditionsRecursive = (includes, relConds) => {
|
|
517
|
+
for (const include of includes) {
|
|
518
|
+
const relationConditions = relConds[include.relation];
|
|
519
|
+
if (relationConditions) {
|
|
520
|
+
// Separate filter conditions from nested relation conditions
|
|
521
|
+
const filterConditions = {};
|
|
522
|
+
const nestedRelConditions = {};
|
|
523
|
+
for (const [key, value] of Object.entries(relationConditions)) {
|
|
524
|
+
// Keys that are logical operators (AND, OR) are always filter conditions
|
|
525
|
+
// Everything else is either a field filter or a nested relation name
|
|
526
|
+
if (key === 'AND' || key === 'OR') {
|
|
527
|
+
filterConditions[key] = value;
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
// Check if this key is a nested relation by looking for it in includes
|
|
531
|
+
const isNestedRelation = include.nested.some((nested) => nested.relation === key);
|
|
532
|
+
if (isNestedRelation) {
|
|
533
|
+
// It's a nested relation (e.g., "tasks")
|
|
534
|
+
nestedRelConditions[key] = value;
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
// It's a field filter condition
|
|
538
|
+
filterConditions[key] = value;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
// Apply filter conditions to this include
|
|
543
|
+
if (Object.keys(filterConditions).length > 0) {
|
|
544
|
+
include.where = include.where
|
|
545
|
+
? combineFilters(include.where, filterConditions)
|
|
546
|
+
: filterConditions;
|
|
547
|
+
}
|
|
548
|
+
// Recursively apply nested relConditions
|
|
549
|
+
if (include.nested && include.nested.length > 0 && Object.keys(nestedRelConditions).length > 0) {
|
|
550
|
+
applyRelConditionsRecursive(include.nested, nestedRelConditions);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
applyRelConditionsRecursive(allIncludes, resolvedRelConditions);
|
|
556
|
+
}
|
|
557
|
+
// Build select columns and joins for relations
|
|
558
|
+
const { selectColumns, joins } = buildSelectWithJoins(this.table, directFields, allIncludes);
|
|
559
|
+
console.log(`[ItemsService.buildQuery] ${this.collection} - directFields:`, directFields, 'selectColumns keys:', Object.keys(selectColumns));
|
|
560
|
+
// Check if we need to add extra joins for sorting by HasMany relation fields
|
|
561
|
+
// This handles the edge case where we sort by a field in a HasMany relation
|
|
562
|
+
if (query.sort && hasSeparateQueries(allIncludes)) {
|
|
563
|
+
// Extract sort fields to check if they reference separate relations
|
|
564
|
+
let sortFields = [];
|
|
565
|
+
if (typeof query.sort === 'string') {
|
|
566
|
+
try {
|
|
567
|
+
const sortObj = JSON.parse(query.sort);
|
|
568
|
+
sortFields = Object.keys(sortObj);
|
|
569
|
+
}
|
|
570
|
+
catch (e) {
|
|
571
|
+
// Ignore parse errors
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
else if (Array.isArray(query.sort)) {
|
|
575
|
+
sortFields = query.sort.map(f => f.startsWith('-') ? f.substring(1) : f);
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
sortFields = Object.keys(query.sort);
|
|
579
|
+
}
|
|
580
|
+
// For each sort field that references a relation, add joins
|
|
581
|
+
for (const sortField of sortFields) {
|
|
582
|
+
if (sortField.includes('.')) {
|
|
583
|
+
// It's a relation field - expand it to get includes
|
|
584
|
+
const { includes: sortIncludes } = expandFieldsWithIncludes([sortField], this.collection);
|
|
585
|
+
// Build joins for these includes
|
|
586
|
+
const { joins: extraJoins } = buildSelectWithJoins(this.table, [], sortIncludes);
|
|
587
|
+
// Add these joins if not already present
|
|
588
|
+
joins.push(...extraJoins);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
// Build where clause with join accumulation for relation path filters
|
|
593
|
+
console.log(`[ItemsService.buildQuery] Collection: ${this.collection}, Filter:`, JSON.stringify(filter, null, 2));
|
|
594
|
+
const filterJoins = [];
|
|
595
|
+
let whereClause = drizzleWhere(filter, {
|
|
596
|
+
table: this.table,
|
|
597
|
+
tableName: this.collection,
|
|
598
|
+
schema: this.table, // Pass table columns as schema
|
|
599
|
+
joins: filterJoins // Accumulate joins from relation path filters
|
|
600
|
+
});
|
|
601
|
+
console.log(`[ItemsService.buildQuery] WHERE clause generated:`, whereClause ? 'yes' : 'no (undefined)');
|
|
602
|
+
// Deduplicate filter joins by alias (multiple conditions on same relation create duplicates)
|
|
603
|
+
const uniqueFilterJoins = [];
|
|
604
|
+
const seenAliases = new Set();
|
|
605
|
+
for (const join of filterJoins) {
|
|
606
|
+
if (!seenAliases.has(join.alias)) {
|
|
607
|
+
seenAliases.add(join.alias);
|
|
608
|
+
uniqueFilterJoins.push(join);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
// Replace filterJoins with deduplicated version
|
|
612
|
+
filterJoins.length = 0;
|
|
613
|
+
filterJoins.push(...uniqueFilterJoins);
|
|
614
|
+
// Log filter joins if any were created
|
|
615
|
+
if (filterJoins.length > 0) {
|
|
616
|
+
console.log(`[ItemsService] Filter generated ${filterJoins.length} joins for relation paths`);
|
|
617
|
+
}
|
|
618
|
+
// Apply full-text search if search query is provided
|
|
619
|
+
let searchOrderClause;
|
|
620
|
+
if (query.search) {
|
|
621
|
+
const { searchCondition, orderClause } = applyFullTextSearch(this.collection, this.table, query.search, query.searchFields, query.sortByRelevance);
|
|
622
|
+
// Combine search condition with existing where clause
|
|
623
|
+
if (whereClause) {
|
|
624
|
+
whereClause = and(whereClause, searchCondition);
|
|
625
|
+
}
|
|
626
|
+
else {
|
|
627
|
+
whereClause = searchCondition;
|
|
628
|
+
}
|
|
629
|
+
// Store search order clause for later
|
|
630
|
+
searchOrderClause = orderClause;
|
|
631
|
+
}
|
|
632
|
+
// Build order by clause
|
|
633
|
+
let orderByClause;
|
|
634
|
+
if (query.sort) {
|
|
635
|
+
// Convert sort array to sort object if needed
|
|
636
|
+
let sortObj = null;
|
|
637
|
+
if (Array.isArray(query.sort)) {
|
|
638
|
+
sortObj = query.sort.reduce((acc, field) => {
|
|
639
|
+
if (field.startsWith('-')) {
|
|
640
|
+
acc[field.substring(1)] = 'desc';
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
acc[field] = 'asc';
|
|
644
|
+
}
|
|
645
|
+
return acc;
|
|
646
|
+
}, {});
|
|
647
|
+
}
|
|
648
|
+
else {
|
|
649
|
+
sortObj = query.sort;
|
|
650
|
+
}
|
|
651
|
+
orderByClause = drizzleOrder(sortObj, {
|
|
652
|
+
table: this.table,
|
|
653
|
+
tableName: this.collection
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
else if (searchOrderClause) {
|
|
657
|
+
// If no explicit sort but search with relevance, use search order
|
|
658
|
+
orderByClause = [searchOrderClause];
|
|
659
|
+
}
|
|
660
|
+
// Calculate pagination
|
|
661
|
+
const { limit, offset } = applyPagination({
|
|
662
|
+
limit: query.limit,
|
|
663
|
+
page: query.page,
|
|
664
|
+
offset: query.offset
|
|
665
|
+
});
|
|
666
|
+
// Merge filter joins with existing joins
|
|
667
|
+
// Filter joins are from relation path filters (e.g., "userRoles.role.name")
|
|
668
|
+
// Don't convert filterJoins to raw SQL - they will be applied using Drizzle query builder methods
|
|
669
|
+
// use .leftJoin() and .innerJoin() instead of raw SQL
|
|
670
|
+
return {
|
|
671
|
+
whereClause,
|
|
672
|
+
orderByClause,
|
|
673
|
+
selectColumns,
|
|
674
|
+
joins,
|
|
675
|
+
processedIncludes,
|
|
676
|
+
limit,
|
|
677
|
+
offset,
|
|
678
|
+
filterJoins,
|
|
679
|
+
userRequestedFields: expandedDirectFields,
|
|
680
|
+
userRequestedIncludes: processedIncludes
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Sanitize records by removing auto-added fields that user didn't request
|
|
685
|
+
* This includes both main collection fields and nested relation fields
|
|
686
|
+
*
|
|
687
|
+
* @param records - Records to sanitize
|
|
688
|
+
* @param userRequestedFields - Expanded direct fields requested by user (before auto-adding primary key)
|
|
689
|
+
* @param userRequestedIncludes - ProcessedIncludes containing relation field info
|
|
690
|
+
*/
|
|
691
|
+
sanitizeAutoAddedFields(records, userRequestedFields, userRequestedIncludes) {
|
|
692
|
+
// Build a set of allowed fields for main collection
|
|
693
|
+
const allowedMainFields = new Set(userRequestedFields);
|
|
694
|
+
// Build a map of includes by relation name
|
|
695
|
+
const includesMap = new Map();
|
|
696
|
+
for (const include of userRequestedIncludes) {
|
|
697
|
+
allowedMainFields.add(include.relation); // Allow relation key in main record
|
|
698
|
+
includesMap.set(include.relation, include);
|
|
699
|
+
}
|
|
700
|
+
// Recursive function to sanitize a record
|
|
701
|
+
const sanitizeRecord = (record, allowedFields, includes) => {
|
|
702
|
+
const sanitized = {};
|
|
703
|
+
for (const [key, value] of Object.entries(record)) {
|
|
704
|
+
// Check if this is a relation
|
|
705
|
+
const include = includes.get(key);
|
|
706
|
+
if (include) {
|
|
707
|
+
// This is a relation - recursively sanitize
|
|
708
|
+
const relationAllowedFields = new Set(include.attributes);
|
|
709
|
+
// Build nested includes map
|
|
710
|
+
const nestedIncludesMap = new Map();
|
|
711
|
+
for (const nestedInclude of include.nested || []) {
|
|
712
|
+
relationAllowedFields.add(nestedInclude.relation);
|
|
713
|
+
nestedIncludesMap.set(nestedInclude.relation, nestedInclude);
|
|
714
|
+
}
|
|
715
|
+
if (Array.isArray(value)) {
|
|
716
|
+
sanitized[key] = value.map(item => item && typeof item === 'object'
|
|
717
|
+
? sanitizeRecord(item, relationAllowedFields, nestedIncludesMap)
|
|
718
|
+
: item);
|
|
719
|
+
}
|
|
720
|
+
else if (value && typeof value === 'object') {
|
|
721
|
+
sanitized[key] = sanitizeRecord(value, relationAllowedFields, nestedIncludesMap);
|
|
722
|
+
}
|
|
723
|
+
else {
|
|
724
|
+
sanitized[key] = value;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
else if (allowedFields.has(key)) {
|
|
728
|
+
// Regular field - keep if explicitly requested
|
|
729
|
+
sanitized[key] = value;
|
|
730
|
+
}
|
|
731
|
+
// else: field not requested, skip it
|
|
732
|
+
}
|
|
733
|
+
return sanitized;
|
|
734
|
+
};
|
|
735
|
+
return records.map(record => sanitizeRecord(record, allowedMainFields, includesMap));
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Apply field-level permissions
|
|
739
|
+
*/
|
|
740
|
+
async applyFieldPermissions(data, action, isAdmin) {
|
|
741
|
+
if (isAdmin)
|
|
742
|
+
return;
|
|
743
|
+
const roleId = this.getRoleId();
|
|
744
|
+
const allowedFields = await permissionService.getAllowedFields(roleId, this.collection, action);
|
|
745
|
+
if (!allowedFields || allowedFields.length === 0) {
|
|
746
|
+
throw new APIError(`You don't have permission to ${action} this item`, 403);
|
|
747
|
+
}
|
|
748
|
+
// Validate field permissions
|
|
749
|
+
const dataFields = Object.keys(data);
|
|
750
|
+
const relationNames = schemaManager.getRelationNames(this.collection);
|
|
751
|
+
for (const field of dataFields) {
|
|
752
|
+
// Skip relation fields - they'll be handled separately
|
|
753
|
+
if (relationNames.includes(field))
|
|
754
|
+
continue;
|
|
755
|
+
// Check if field is allowed
|
|
756
|
+
if (!allowedFields.includes(field) && !allowedFields.includes('*')) {
|
|
757
|
+
throw new APIError(`You don't have permission to ${action} field: ${field}`, 403);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Get default values from permissions
|
|
763
|
+
*/
|
|
764
|
+
async getDefaultValues(action) {
|
|
765
|
+
const roleId = this.getRoleId();
|
|
766
|
+
if (!roleId)
|
|
767
|
+
return {};
|
|
768
|
+
return await permissionService.getDefaultValues(roleId, this.collection, action, this.accountability);
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Read with two-query approach for HasMany sorting
|
|
772
|
+
*/
|
|
773
|
+
async readWithHasManyHandling(query, whereClause, orderByClause, processedIncludes, limit, offset, isAdmin, bypassPermissions, filterJoins = []) {
|
|
774
|
+
console.log('[ItemsService] Using Drizzle query builder for HasMany sorting/filtering');
|
|
775
|
+
// STEP 1: Build ID query using PostgreSQL DISTINCT ON for proper deduplication
|
|
776
|
+
// DISTINCT ON ensures we get unique IDs BEFORE applying LIMIT, not after
|
|
777
|
+
// This matches Sequelize's approach
|
|
778
|
+
// Build DISTINCT ON clause from sort fields + primary key
|
|
779
|
+
const tableName = this.collection;
|
|
780
|
+
const pkField = `"${tableName}"."${this.primaryKey}"`;
|
|
781
|
+
const distinctOnFields = [];
|
|
782
|
+
// Add sort fields from query.sort to DISTINCT ON
|
|
783
|
+
if (query.sort && Array.isArray(query.sort)) {
|
|
784
|
+
for (const sortItem of query.sort) {
|
|
785
|
+
if (typeof sortItem === 'string') {
|
|
786
|
+
// Simple sort: "fieldName" or "-fieldName"
|
|
787
|
+
const fieldName = sortItem.startsWith('-') ? sortItem.slice(1) : sortItem;
|
|
788
|
+
distinctOnFields.push(`"${tableName}"."${fieldName}"`);
|
|
789
|
+
}
|
|
790
|
+
else if (typeof sortItem === 'object') {
|
|
791
|
+
// Object sort: { fieldName: 'asc' }
|
|
792
|
+
for (const [fieldName, direction] of Object.entries(sortItem)) {
|
|
793
|
+
if (fieldName.includes('.')) {
|
|
794
|
+
// Relation path - need to resolve it
|
|
795
|
+
// For now, skip relations in DISTINCT ON (will fallback to pk only)
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
distinctOnFields.push(`"${tableName}"."${fieldName}"`);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
// Always add primary key to DISTINCT ON
|
|
804
|
+
if (!distinctOnFields.includes(pkField)) {
|
|
805
|
+
distinctOnFields.push(pkField);
|
|
806
|
+
}
|
|
807
|
+
// Build the DISTINCT ON clause
|
|
808
|
+
const distinctOnClause = distinctOnFields.length > 0
|
|
809
|
+
? `DISTINCT ON (${distinctOnFields.join(', ')}) ${pkField}`
|
|
810
|
+
: pkField;
|
|
811
|
+
console.log(`[ItemsService] Using DISTINCT ON with fields: ${distinctOnFields.join(', ')}`);
|
|
812
|
+
let idQuery = db
|
|
813
|
+
.select({ [this.primaryKey]: sql.raw(distinctOnClause) })
|
|
814
|
+
.from(this.table)
|
|
815
|
+
.$dynamic();
|
|
816
|
+
// Apply processedIncludes joins using Drizzle query builder
|
|
817
|
+
for (const include of processedIncludes) {
|
|
818
|
+
if (include.separate) {
|
|
819
|
+
// For HasMany relations, we need the join for sorting even though data loads separately
|
|
820
|
+
// Apply the join using Drizzle's leftJoin
|
|
821
|
+
idQuery = idQuery.leftJoin(include.table, include.joinCondition);
|
|
822
|
+
// Apply nested joins recursively
|
|
823
|
+
if (include.nested.length > 0) {
|
|
824
|
+
idQuery = this.applyNestedJoins(idQuery, include.nested);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
else if (include.relationType === 'BelongsTo' || include.relationType === 'HasOne') {
|
|
828
|
+
idQuery = idQuery.leftJoin(include.table, include.joinCondition);
|
|
829
|
+
if (include.nested.length > 0) {
|
|
830
|
+
idQuery = this.applyNestedJoins(idQuery, include.nested);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
// Apply filterJoins if present (using alias for custom aliases)
|
|
835
|
+
if (filterJoins.length > 0) {
|
|
836
|
+
console.log(`[ItemsService] Applying ${filterJoins.length} filterJoins to ID query`);
|
|
837
|
+
for (const filterJoin of filterJoins) {
|
|
838
|
+
const aliasedTable = alias(filterJoin.table, filterJoin.alias);
|
|
839
|
+
const joinMethod = filterJoin.type === 'inner' ? 'innerJoin' :
|
|
840
|
+
filterJoin.type === 'right' ? 'rightJoin' : 'leftJoin';
|
|
841
|
+
idQuery = idQuery[joinMethod](aliasedTable, filterJoin.condition);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
// Apply WHERE clause
|
|
845
|
+
if (whereClause) {
|
|
846
|
+
idQuery = idQuery.where(whereClause);
|
|
847
|
+
}
|
|
848
|
+
// Apply ORDER BY - must match DISTINCT ON for PostgreSQL
|
|
849
|
+
// We need to ensure ORDER BY starts with the same columns as DISTINCT ON
|
|
850
|
+
if (distinctOnFields.length > 0) {
|
|
851
|
+
// Build ORDER BY from DISTINCT ON fields
|
|
852
|
+
const orderByClauses = [];
|
|
853
|
+
// Add all DISTINCT ON fields to ORDER BY (in same order)
|
|
854
|
+
for (const field of distinctOnFields) {
|
|
855
|
+
// Determine direction from original sort if available
|
|
856
|
+
let direction = 'asc';
|
|
857
|
+
if (query.sort && Array.isArray(query.sort)) {
|
|
858
|
+
for (const sortItem of query.sort) {
|
|
859
|
+
if (typeof sortItem === 'object') {
|
|
860
|
+
for (const [fieldName, dir] of Object.entries(sortItem)) {
|
|
861
|
+
if (field.includes(fieldName)) {
|
|
862
|
+
direction = String(dir).toLowerCase();
|
|
863
|
+
break;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
orderByClauses.push(sql.raw(`${field} ${direction.toUpperCase()}`));
|
|
870
|
+
}
|
|
871
|
+
idQuery = idQuery.orderBy(...orderByClauses);
|
|
872
|
+
}
|
|
873
|
+
else if (orderByClause && orderByClause.length > 0) {
|
|
874
|
+
// Fallback to original ORDER BY if no DISTINCT ON
|
|
875
|
+
idQuery = idQuery.orderBy(...orderByClause);
|
|
876
|
+
}
|
|
877
|
+
// Apply pagination
|
|
878
|
+
if (limit !== undefined && limit !== -1) {
|
|
879
|
+
idQuery = idQuery.limit(limit);
|
|
880
|
+
}
|
|
881
|
+
if (offset !== undefined) {
|
|
882
|
+
idQuery = idQuery.offset(offset);
|
|
883
|
+
}
|
|
884
|
+
// Execute ID query
|
|
885
|
+
console.log('[ItemsService] Executing ID query with DISTINCT ON');
|
|
886
|
+
const idRecords = await idQuery;
|
|
887
|
+
// Extract IDs from the result (already deduplicated by DISTINCT ON)
|
|
888
|
+
const ids = idRecords
|
|
889
|
+
.map(record => record[this.primaryKey])
|
|
890
|
+
.filter(id => id != null);
|
|
891
|
+
console.log(`[ItemsService] Got ${ids.length} unique IDs from DISTINCT ON query`);
|
|
892
|
+
// If no IDs found, return empty result
|
|
893
|
+
if (ids.length === 0) {
|
|
894
|
+
return {
|
|
895
|
+
data: [],
|
|
896
|
+
totalCount: 0
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
// STEP 2: Get full records for these IDs with original includes
|
|
900
|
+
// Build select columns for full query
|
|
901
|
+
const fields = query.fields || ['*'];
|
|
902
|
+
const { directFields } = expandFieldsWithIncludes(fields, this.collection);
|
|
903
|
+
const { selectColumns } = buildSelectWithJoins(this.table, directFields, processedIncludes // Use original includes with separate: true for HasMany
|
|
904
|
+
);
|
|
905
|
+
let fullQuery = db.select(selectColumns).from(this.table);
|
|
906
|
+
// Apply joins (only BelongsTo/HasOne, not HasMany)
|
|
907
|
+
for (const include of processedIncludes) {
|
|
908
|
+
if (include.separate)
|
|
909
|
+
continue; // Skip HasMany for JOINs
|
|
910
|
+
if (include.relationType === 'BelongsTo' || include.relationType === 'HasOne') {
|
|
911
|
+
fullQuery = fullQuery.leftJoin(include.table, include.joinCondition);
|
|
912
|
+
if (include.nested.length > 0) {
|
|
913
|
+
fullQuery = this.applyNestedJoins(fullQuery, include.nested);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
// Filter by IDs from first query
|
|
918
|
+
fullQuery = fullQuery.where(inArray(this.getPrimaryKeyColumn(), ids));
|
|
919
|
+
// Execute second query
|
|
920
|
+
const records = await fullQuery;
|
|
921
|
+
// Load separate relations (HasMany, BelongsToMany) and nest joined relations
|
|
922
|
+
let finalRecords = records;
|
|
923
|
+
if (hasSeparateQueries(processedIncludes)) {
|
|
924
|
+
// This handles both nesting joined relations and loading separate ones
|
|
925
|
+
finalRecords = await loadSeparateRelations(db, records, processedIncludes, this.collection);
|
|
926
|
+
}
|
|
927
|
+
else {
|
|
928
|
+
// No separate queries, but we still need to nest joined BelongsTo/HasOne relations
|
|
929
|
+
finalRecords = nestJoinedRelations(records, processedIncludes);
|
|
930
|
+
}
|
|
931
|
+
// Maintain order from first query
|
|
932
|
+
const recordMap = new Map(finalRecords.map(r => [r[this.primaryKey], r]));
|
|
933
|
+
const orderedRecords = ids.map(id => recordMap.get(id)).filter(r => r != null);
|
|
934
|
+
// Get total count using Drizzle query builder (same joins as ID query)
|
|
935
|
+
let countQuery = db
|
|
936
|
+
.select({ count: sql `COUNT(DISTINCT ${this.getPrimaryKeyColumn()})`.mapWith(Number) })
|
|
937
|
+
.from(this.table)
|
|
938
|
+
.$dynamic();
|
|
939
|
+
// Apply same joins as ID query
|
|
940
|
+
for (const include of processedIncludes) {
|
|
941
|
+
if (include.separate) {
|
|
942
|
+
countQuery = countQuery.leftJoin(include.table, include.joinCondition);
|
|
943
|
+
if (include.nested.length > 0) {
|
|
944
|
+
countQuery = this.applyNestedJoins(countQuery, include.nested);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
else if (include.relationType === 'BelongsTo' || include.relationType === 'HasOne') {
|
|
948
|
+
countQuery = countQuery.leftJoin(include.table, include.joinCondition);
|
|
949
|
+
if (include.nested.length > 0) {
|
|
950
|
+
countQuery = this.applyNestedJoins(countQuery, include.nested);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
// Apply filterJoins
|
|
955
|
+
if (filterJoins.length > 0) {
|
|
956
|
+
for (const filterJoin of filterJoins) {
|
|
957
|
+
const aliasedTable = alias(filterJoin.table, filterJoin.alias);
|
|
958
|
+
const joinMethod = filterJoin.type === 'inner' ? 'innerJoin' :
|
|
959
|
+
filterJoin.type === 'right' ? 'rightJoin' : 'leftJoin';
|
|
960
|
+
countQuery = countQuery[joinMethod](aliasedTable, filterJoin.condition);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
// Apply WHERE clause
|
|
964
|
+
if (whereClause) {
|
|
965
|
+
countQuery = countQuery.where(whereClause);
|
|
966
|
+
}
|
|
967
|
+
const countResult = await countQuery;
|
|
968
|
+
const totalCount = countResult[0]?.count || 0;
|
|
969
|
+
// Strip hidden fields from records before returning
|
|
970
|
+
const strippedRecords = fieldUtils.stripHiddenFieldsFromRecords(this.collection, orderedRecords);
|
|
971
|
+
return {
|
|
972
|
+
data: strippedRecords,
|
|
973
|
+
totalCount
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* Check if we need two-query approach for sorting by HasMany relations
|
|
978
|
+
*/
|
|
979
|
+
needsHasManyHandling(sortFields, processedIncludes) {
|
|
980
|
+
// Build a map of relation paths to their includes
|
|
981
|
+
const relationMap = new Map();
|
|
982
|
+
const addToMap = (includes, prefix = '') => {
|
|
983
|
+
for (const include of includes) {
|
|
984
|
+
const path = prefix ? `${prefix}.${include.relation}` : include.relation;
|
|
985
|
+
relationMap.set(path, include);
|
|
986
|
+
if (include.nested.length > 0) {
|
|
987
|
+
addToMap(include.nested, path);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
};
|
|
991
|
+
addToMap(processedIncludes);
|
|
992
|
+
// Check if any sort field references a HasMany relation
|
|
993
|
+
for (const sortField of sortFields) {
|
|
994
|
+
if (!sortField.includes('.'))
|
|
995
|
+
continue;
|
|
996
|
+
// Extract relation path (e.g., "userRoles.role.name" -> "userRoles.role")
|
|
997
|
+
const parts = sortField.split('.');
|
|
998
|
+
for (let i = 1; i < parts.length; i++) {
|
|
999
|
+
const relationPath = parts.slice(0, i).join('.');
|
|
1000
|
+
const include = relationMap.get(relationPath);
|
|
1001
|
+
if (include && include.separate) {
|
|
1002
|
+
// This sort field references a HasMany relation
|
|
1003
|
+
return true;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
return false;
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Read records by query
|
|
1011
|
+
*/
|
|
1012
|
+
async readByQuery(query = {}, bypassPermissions = false) {
|
|
1013
|
+
// Execute before-read hooks
|
|
1014
|
+
let hookData = await hooksManager.executeHooks(this.collection, 'items.read', this.accountability, { query });
|
|
1015
|
+
const modifiedQuery = hookData.query;
|
|
1016
|
+
try {
|
|
1017
|
+
const isAdmin = await this.isAdministrator();
|
|
1018
|
+
// Check if this is an aggregate query
|
|
1019
|
+
if (modifiedQuery.aggregate) {
|
|
1020
|
+
return await this.executeAggregateQuery(modifiedQuery, isAdmin, bypassPermissions);
|
|
1021
|
+
}
|
|
1022
|
+
// Build query components
|
|
1023
|
+
const { whereClause, orderByClause, selectColumns, joins, processedIncludes, limit, offset, filterJoins = [], userRequestedFields = [], userRequestedIncludes = [] } = await this.buildQuery(modifiedQuery, {
|
|
1024
|
+
isAdmin,
|
|
1025
|
+
action: 'read',
|
|
1026
|
+
bypassPermissions
|
|
1027
|
+
});
|
|
1028
|
+
// Extract sort fields to check if we need two-query approach
|
|
1029
|
+
let sortFields = [];
|
|
1030
|
+
if (modifiedQuery.sort) {
|
|
1031
|
+
if (typeof modifiedQuery.sort === 'string') {
|
|
1032
|
+
try {
|
|
1033
|
+
const sortObj = JSON.parse(modifiedQuery.sort);
|
|
1034
|
+
sortFields = Object.keys(sortObj);
|
|
1035
|
+
}
|
|
1036
|
+
catch (e) {
|
|
1037
|
+
// Ignore parse errors
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
else if (Array.isArray(modifiedQuery.sort)) {
|
|
1041
|
+
sortFields = modifiedQuery.sort.map(f => f.startsWith('-') ? f.substring(1) : f);
|
|
1042
|
+
}
|
|
1043
|
+
else {
|
|
1044
|
+
sortFields = Object.keys(modifiedQuery.sort);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
// Check if we need two-query approach for HasMany sorting
|
|
1048
|
+
const needsHasManyHandling = this.needsHasManyHandling(sortFields, processedIncludes);
|
|
1049
|
+
// Check if we have filter joins (relational filters that need joins)
|
|
1050
|
+
const hasFilterJoins = filterJoins && filterJoins.length > 0;
|
|
1051
|
+
// Use readWithHasManyHandling for:
|
|
1052
|
+
// 1. HasMany sorting (to handle duplicates from joins)
|
|
1053
|
+
// 2. HasMany filtering with pagination (to deduplicate before applying LIMIT)
|
|
1054
|
+
// This matches Sequelize's approach of using a two-query pattern
|
|
1055
|
+
if (needsHasManyHandling || hasFilterJoins) {
|
|
1056
|
+
console.log(`[ItemsService] Using readWithHasManyHandling for ${needsHasManyHandling ? 'HasMany sorting' : 'HasMany filtering'}`);
|
|
1057
|
+
return await this.readWithHasManyHandling(modifiedQuery, whereClause, orderByClause, processedIncludes, limit, offset, isAdmin, bypassPermissions, filterJoins);
|
|
1058
|
+
}
|
|
1059
|
+
// Build base query
|
|
1060
|
+
console.log(`[ItemsService.readByQuery] Building base query for ${this.collection}, selectColumns has ${Object.keys(selectColumns).length} keys`);
|
|
1061
|
+
if (Object.keys(selectColumns).length === 0) {
|
|
1062
|
+
console.error(`[ItemsService.readByQuery] ERROR: selectColumns is EMPTY for ${this.collection}! This will cause SQL syntax error.`);
|
|
1063
|
+
console.error(`[ItemsService.readByQuery] query:`, query);
|
|
1064
|
+
}
|
|
1065
|
+
let baseQuery = db.select(selectColumns).from(this.table);
|
|
1066
|
+
// Apply filterJoins using Drizzle's query builder
|
|
1067
|
+
// FilterJoins are created when filtering by relation paths (e.g., "userRoles.role.name")
|
|
1068
|
+
if (hasFilterJoins) {
|
|
1069
|
+
console.log(`[ItemsService] Applying ${filterJoins.length} filterJoins using Drizzle query builder`);
|
|
1070
|
+
for (const filterJoin of filterJoins) {
|
|
1071
|
+
// Use alias() to create an aliased table with the exact alias used in the WHERE clause
|
|
1072
|
+
const aliasedTable = alias(filterJoin.table, filterJoin.alias);
|
|
1073
|
+
// Apply join using Drizzle's leftJoin (or other join type)
|
|
1074
|
+
const joinMethod = filterJoin.type === 'inner' ? 'innerJoin' :
|
|
1075
|
+
filterJoin.type === 'right' ? 'rightJoin' : 'leftJoin';
|
|
1076
|
+
baseQuery = baseQuery[joinMethod](aliasedTable, filterJoin.condition);
|
|
1077
|
+
console.log(`[ItemsService] Applied ${joinMethod}: ${filterJoin.tableName} AS ${filterJoin.alias}`);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
// Apply processedIncludes joins (for fetching related data in SELECT)
|
|
1081
|
+
if (processedIncludes.length > 0) {
|
|
1082
|
+
for (const include of processedIncludes) {
|
|
1083
|
+
if (include.separate)
|
|
1084
|
+
continue; // Skip HasMany relations loaded separately
|
|
1085
|
+
if (include.relationType === 'BelongsTo' || include.relationType === 'HasOne') {
|
|
1086
|
+
// Apply join with Drizzle API
|
|
1087
|
+
baseQuery = baseQuery.leftJoin(include.table, include.joinCondition);
|
|
1088
|
+
// Apply nested joins recursively
|
|
1089
|
+
if (include.nested.length > 0) {
|
|
1090
|
+
baseQuery = this.applyNestedJoins(baseQuery, include.nested);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
// Apply where clause
|
|
1096
|
+
if (whereClause) {
|
|
1097
|
+
baseQuery = baseQuery.where(whereClause);
|
|
1098
|
+
}
|
|
1099
|
+
// Apply ordering
|
|
1100
|
+
if (orderByClause && orderByClause.length > 0) {
|
|
1101
|
+
baseQuery = baseQuery.orderBy(...orderByClause);
|
|
1102
|
+
}
|
|
1103
|
+
// Apply pagination
|
|
1104
|
+
if (limit !== undefined && limit !== -1) {
|
|
1105
|
+
baseQuery = baseQuery.limit(limit);
|
|
1106
|
+
}
|
|
1107
|
+
if (offset !== undefined) {
|
|
1108
|
+
baseQuery = baseQuery.offset(offset);
|
|
1109
|
+
}
|
|
1110
|
+
// Generate cache key and get involved tables
|
|
1111
|
+
const cacheKey = this.generateCacheKey(modifiedQuery, processedIncludes);
|
|
1112
|
+
const involvedTables = this.getInvolvedTables(processedIncludes);
|
|
1113
|
+
// Execute main query with cache
|
|
1114
|
+
const { records: finalRecords, totalCount } = await this.executeWithCache(cacheKey, involvedTables, async () => {
|
|
1115
|
+
// Execute main query
|
|
1116
|
+
let records;
|
|
1117
|
+
try {
|
|
1118
|
+
records = await baseQuery;
|
|
1119
|
+
}
|
|
1120
|
+
catch (queryError) {
|
|
1121
|
+
console.error(`[ItemsService.readByQuery] Query execution failed for ${this.collection}`);
|
|
1122
|
+
console.error(`[ItemsService.readByQuery] selectColumns:`, Object.keys(selectColumns));
|
|
1123
|
+
console.error(`[ItemsService.readByQuery] whereClause exists:`, !!whereClause);
|
|
1124
|
+
console.error(`[ItemsService.readByQuery] orderByClause exists:`, !!orderByClause);
|
|
1125
|
+
console.error(`[ItemsService.readByQuery] Query error:`, queryError.message);
|
|
1126
|
+
throw queryError;
|
|
1127
|
+
}
|
|
1128
|
+
// Note: Deduplication for filterJoins is now handled by readWithHasManyHandling
|
|
1129
|
+
// which uses a two-query approach (fetch IDs first, deduplicate, then fetch full records)
|
|
1130
|
+
// Load separate relations (HasMany, BelongsToMany) and nest joined relations
|
|
1131
|
+
let processedRecords = records;
|
|
1132
|
+
if (hasSeparateQueries(processedIncludes)) {
|
|
1133
|
+
processedRecords = await loadSeparateRelations(db, records, processedIncludes, this.collection);
|
|
1134
|
+
}
|
|
1135
|
+
else {
|
|
1136
|
+
// No separate queries, but we still need to nest joined BelongsTo/HasOne relations
|
|
1137
|
+
processedRecords = nestJoinedRelations(records, processedIncludes);
|
|
1138
|
+
}
|
|
1139
|
+
// Get total count
|
|
1140
|
+
const primaryKeyColumn = this.getPrimaryKeyColumn();
|
|
1141
|
+
if (!primaryKeyColumn) {
|
|
1142
|
+
console.error(`[ItemsService.readByQuery] Primary key column is undefined for ${this.collection}!`);
|
|
1143
|
+
console.error(`[ItemsService.readByQuery] Primary key name:`, this.primaryKey);
|
|
1144
|
+
console.error(`[ItemsService.readByQuery] Available columns:`, Object.keys(this.table).filter(k => !k.startsWith('_')));
|
|
1145
|
+
}
|
|
1146
|
+
let countQuery = db.select({ count: sql `COUNT(DISTINCT ${primaryKeyColumn})` }).from(this.table);
|
|
1147
|
+
// Apply same joins and where for count
|
|
1148
|
+
if (hasFilterJoins) {
|
|
1149
|
+
// Use filterJoins for count query as well
|
|
1150
|
+
for (const filterJoin of filterJoins) {
|
|
1151
|
+
const aliasedTable = alias(filterJoin.table, filterJoin.alias);
|
|
1152
|
+
countQuery = countQuery.leftJoin(aliasedTable, filterJoin.condition);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
else {
|
|
1156
|
+
for (const include of processedIncludes) {
|
|
1157
|
+
if (include.separate)
|
|
1158
|
+
continue;
|
|
1159
|
+
if (include.relationType === 'BelongsTo' || include.relationType === 'HasOne') {
|
|
1160
|
+
countQuery = countQuery.leftJoin(include.table, include.joinCondition);
|
|
1161
|
+
if (include.nested.length > 0) {
|
|
1162
|
+
countQuery = this.applyNestedJoins(countQuery, include.nested);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
if (whereClause) {
|
|
1168
|
+
countQuery = countQuery.where(whereClause);
|
|
1169
|
+
}
|
|
1170
|
+
let countResult, count;
|
|
1171
|
+
try {
|
|
1172
|
+
countResult = await countQuery;
|
|
1173
|
+
count = Number(countResult[0]?.count || 0);
|
|
1174
|
+
}
|
|
1175
|
+
catch (countError) {
|
|
1176
|
+
console.error(`[ItemsService.readByQuery] Count query execution failed for ${this.collection}`);
|
|
1177
|
+
console.error(`[ItemsService.readByQuery] Count query error:`, countError.message);
|
|
1178
|
+
console.error(`[ItemsService.readByQuery] HasFilterJoins:`, hasFilterJoins);
|
|
1179
|
+
console.error(`[ItemsService.readByQuery] ProcessedIncludes count:`, processedIncludes.length);
|
|
1180
|
+
throw countError;
|
|
1181
|
+
}
|
|
1182
|
+
// Return both records and count to be cached together
|
|
1183
|
+
return {
|
|
1184
|
+
records: processedRecords,
|
|
1185
|
+
totalCount: count
|
|
1186
|
+
};
|
|
1187
|
+
});
|
|
1188
|
+
// Strip hidden fields from records
|
|
1189
|
+
const strippedRecords = fieldUtils.stripHiddenFieldsFromRecords(this.collection, finalRecords);
|
|
1190
|
+
// Sanitize auto-added fields (remove fields user didn't request)
|
|
1191
|
+
const sanitizedRecords = this.sanitizeAutoAddedFields(strippedRecords, userRequestedFields, userRequestedIncludes);
|
|
1192
|
+
// Execute after-read hooks
|
|
1193
|
+
hookData = await hooksManager.executeHooks(this.collection, 'items.read.after', this.accountability, { query: modifiedQuery, result: { data: sanitizedRecords, totalCount } });
|
|
1194
|
+
return hookData.result;
|
|
1195
|
+
}
|
|
1196
|
+
catch (error) {
|
|
1197
|
+
console.error('Error in readByQuery:', error);
|
|
1198
|
+
throw error;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
/**
|
|
1202
|
+
* Execute aggregate query
|
|
1203
|
+
*/
|
|
1204
|
+
async executeAggregateQuery(query, isAdmin, bypassPermissions) {
|
|
1205
|
+
const { aggregate, groupBy = [], filter = {}, sort } = query;
|
|
1206
|
+
if (!aggregate) {
|
|
1207
|
+
throw new APIError('Aggregate query requires aggregate parameter', 400);
|
|
1208
|
+
}
|
|
1209
|
+
// Extract all relation paths from the query
|
|
1210
|
+
const relationPaths = new Set();
|
|
1211
|
+
// Helper to check if a dotted path is a qualified main table reference vs a relation path
|
|
1212
|
+
// e.g., "baasix_User.createdAt" when querying baasix_User - check if "createdAt" is a column
|
|
1213
|
+
const isMainTableQualifiedField = (field) => {
|
|
1214
|
+
const parts = field.split('.');
|
|
1215
|
+
if (parts.length !== 2)
|
|
1216
|
+
return false;
|
|
1217
|
+
const [tablePart, columnPart] = parts;
|
|
1218
|
+
if (tablePart !== this.collection)
|
|
1219
|
+
return false;
|
|
1220
|
+
// Check if the column exists on the main table
|
|
1221
|
+
return columnPart in this.table;
|
|
1222
|
+
};
|
|
1223
|
+
// From groupBy fields
|
|
1224
|
+
for (const field of groupBy) {
|
|
1225
|
+
if (field.includes('.') && !field.startsWith('date:')) {
|
|
1226
|
+
// Skip if this is a qualified reference to a main table column (e.g., "baasix_User.createdAt")
|
|
1227
|
+
if (!isMainTableQualifiedField(field)) {
|
|
1228
|
+
relationPaths.add(field);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
// From aggregate field specifications
|
|
1233
|
+
for (const [, aggregateInfo] of Object.entries(aggregate)) {
|
|
1234
|
+
const field = aggregateInfo.field;
|
|
1235
|
+
if (field && field.includes('.')) {
|
|
1236
|
+
// Skip if this is a qualified reference to a main table column
|
|
1237
|
+
if (!isMainTableQualifiedField(field)) {
|
|
1238
|
+
relationPaths.add(field);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
// Apply filter with permissions and tenant context
|
|
1243
|
+
let combinedFilter = filter;
|
|
1244
|
+
if (!bypassPermissions && !isAdmin) {
|
|
1245
|
+
const roleId = this.getRoleId();
|
|
1246
|
+
// First, check if user has permission to read this collection
|
|
1247
|
+
const hasAccess = await permissionService.canAccess(roleId, this.collection, 'read');
|
|
1248
|
+
if (!hasAccess) {
|
|
1249
|
+
throw new APIError(`You don't have permission to read items in '${this.collection}'`, 403);
|
|
1250
|
+
}
|
|
1251
|
+
// Then apply permission filters
|
|
1252
|
+
const permissionFilter = await permissionService.getFilter(roleId, this.collection, 'read', this.accountability);
|
|
1253
|
+
if (permissionFilter.conditions) {
|
|
1254
|
+
combinedFilter = combineFilters(combinedFilter, permissionFilter.conditions);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
combinedFilter = await this.enforceTenantContextFilter(combinedFilter);
|
|
1258
|
+
combinedFilter = await resolveDynamicVariables(combinedFilter, this.accountability);
|
|
1259
|
+
// Build WHERE clause and accumulate filter joins
|
|
1260
|
+
const filterJoins = [];
|
|
1261
|
+
const whereClause = drizzleWhere(combinedFilter, {
|
|
1262
|
+
table: this.table,
|
|
1263
|
+
tableName: this.collection,
|
|
1264
|
+
schema: this.table,
|
|
1265
|
+
joins: filterJoins
|
|
1266
|
+
});
|
|
1267
|
+
// Deduplicate filter joins by alias
|
|
1268
|
+
const uniqueFilterJoins = [];
|
|
1269
|
+
const seenAliases = new Set();
|
|
1270
|
+
for (const join of filterJoins) {
|
|
1271
|
+
if (!seenAliases.has(join.alias)) {
|
|
1272
|
+
seenAliases.add(join.alias);
|
|
1273
|
+
uniqueFilterJoins.push(join);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
filterJoins.length = 0;
|
|
1277
|
+
filterJoins.push(...uniqueFilterJoins);
|
|
1278
|
+
// Resolve all relation paths to joins
|
|
1279
|
+
const allJoins = [...filterJoins];
|
|
1280
|
+
const pathToAliasMap = new Map(); // Maps relation path to final table alias
|
|
1281
|
+
// Add the main table to the alias map so fields like "baasix_User.createdAt" get resolved correctly
|
|
1282
|
+
pathToAliasMap.set(this.collection, this.collection);
|
|
1283
|
+
for (const relationPath of relationPaths) {
|
|
1284
|
+
try {
|
|
1285
|
+
const resolved = resolveRelationPath(relationPath, this.table, this.collection);
|
|
1286
|
+
// Store the mapping from path to final alias
|
|
1287
|
+
pathToAliasMap.set(relationPath, resolved.finalAlias);
|
|
1288
|
+
// Add any new joins (avoid duplicates)
|
|
1289
|
+
for (const join of resolved.joins) {
|
|
1290
|
+
const exists = allJoins.some(j => j.alias === join.alias);
|
|
1291
|
+
if (!exists) {
|
|
1292
|
+
allJoins.push(join);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
catch (error) {
|
|
1297
|
+
console.warn(`[ItemsService] Could not resolve relation path ${relationPath}:`, error.message);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
// Build aggregate attributes with alias mapping for relations
|
|
1301
|
+
const ctx = { tableName: this.collection, pathToAliasMap };
|
|
1302
|
+
const attributes = buildAggregateAttributes(aggregate, groupBy, ctx);
|
|
1303
|
+
// Build select object
|
|
1304
|
+
const selectObj = {};
|
|
1305
|
+
// Add group by fields with alias mapping for relations
|
|
1306
|
+
for (const groupField of groupBy) {
|
|
1307
|
+
const groupExpr = buildGroupByExpressions([groupField], undefined, pathToAliasMap)[0];
|
|
1308
|
+
selectObj[groupField] = groupExpr;
|
|
1309
|
+
}
|
|
1310
|
+
// Add aggregate functions
|
|
1311
|
+
for (const [expr, alias] of attributes) {
|
|
1312
|
+
selectObj[alias] = expr;
|
|
1313
|
+
}
|
|
1314
|
+
// Execute aggregate query using Drizzle query builder
|
|
1315
|
+
let results;
|
|
1316
|
+
if (allJoins.length > 0) {
|
|
1317
|
+
// Build aggregate query using Drizzle query builder
|
|
1318
|
+
let aggregateQuery = db.select(selectObj).from(this.table).$dynamic();
|
|
1319
|
+
// Apply joins (same as filterJoins)
|
|
1320
|
+
for (const join of allJoins) {
|
|
1321
|
+
// Create aliased table with the exact alias
|
|
1322
|
+
const aliasedTable = alias(join.table, join.alias);
|
|
1323
|
+
// Apply join using Drizzle's leftJoin (or other join type)
|
|
1324
|
+
const joinMethod = join.type === 'inner' ? 'innerJoin' :
|
|
1325
|
+
join.type === 'right' ? 'rightJoin' : 'leftJoin';
|
|
1326
|
+
aggregateQuery = aggregateQuery[joinMethod](aliasedTable, join.condition);
|
|
1327
|
+
}
|
|
1328
|
+
// Apply WHERE clause
|
|
1329
|
+
if (whereClause) {
|
|
1330
|
+
aggregateQuery = aggregateQuery.where(whereClause);
|
|
1331
|
+
}
|
|
1332
|
+
// Apply GROUP BY with alias mapping for relations
|
|
1333
|
+
if (groupBy.length > 0) {
|
|
1334
|
+
const groupByExprs = buildGroupByExpressions(groupBy, undefined, pathToAliasMap);
|
|
1335
|
+
aggregateQuery = aggregateQuery.groupBy(...groupByExprs);
|
|
1336
|
+
}
|
|
1337
|
+
// Apply ORDER BY - for aggregate queries, check if sorting by aggregate alias
|
|
1338
|
+
if (sort) {
|
|
1339
|
+
const sortObj = typeof sort === 'string' ? JSON.parse(sort) : sort;
|
|
1340
|
+
const orderByClause = [];
|
|
1341
|
+
for (const [field, direction] of Object.entries(sortObj)) {
|
|
1342
|
+
// Check if this field is an aggregate alias in selectObj
|
|
1343
|
+
if (selectObj[field]) {
|
|
1344
|
+
// Use the aggregate expression from selectObj directly
|
|
1345
|
+
const normalizedDirection = direction.toUpperCase();
|
|
1346
|
+
const expr = selectObj[field];
|
|
1347
|
+
orderByClause.push(normalizedDirection === 'ASC' ? asc(expr) : desc(expr));
|
|
1348
|
+
}
|
|
1349
|
+
else {
|
|
1350
|
+
// Use drizzleOrder for non-aggregate fields
|
|
1351
|
+
const clause = drizzleOrder({ [field]: direction }, {
|
|
1352
|
+
table: this.table,
|
|
1353
|
+
tableName: this.collection
|
|
1354
|
+
});
|
|
1355
|
+
orderByClause.push(...clause);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
if (orderByClause.length > 0) {
|
|
1359
|
+
aggregateQuery = aggregateQuery.orderBy(...orderByClause);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
// Execute query
|
|
1363
|
+
results = await aggregateQuery;
|
|
1364
|
+
}
|
|
1365
|
+
else {
|
|
1366
|
+
// No relation joins - use standard Drizzle API
|
|
1367
|
+
let aggregateQuery = db.select(selectObj).from(this.table).$dynamic();
|
|
1368
|
+
if (whereClause) {
|
|
1369
|
+
aggregateQuery = aggregateQuery.where(whereClause);
|
|
1370
|
+
}
|
|
1371
|
+
if (groupBy.length > 0) {
|
|
1372
|
+
const groupByExprs = buildGroupByExpressions(groupBy, undefined, pathToAliasMap);
|
|
1373
|
+
aggregateQuery = aggregateQuery.groupBy(...groupByExprs);
|
|
1374
|
+
}
|
|
1375
|
+
// Apply sorting if provided - for aggregate queries, check if sorting by aggregate alias
|
|
1376
|
+
if (sort) {
|
|
1377
|
+
const sortObj = typeof sort === 'string' ? JSON.parse(sort) : sort;
|
|
1378
|
+
const orderByClause = [];
|
|
1379
|
+
for (const [field, direction] of Object.entries(sortObj)) {
|
|
1380
|
+
// Check if this field is an aggregate alias in selectObj
|
|
1381
|
+
if (selectObj[field]) {
|
|
1382
|
+
// Use the aggregate expression from selectObj directly
|
|
1383
|
+
const normalizedDirection = direction.toUpperCase();
|
|
1384
|
+
const expr = selectObj[field];
|
|
1385
|
+
orderByClause.push(normalizedDirection === 'ASC' ? asc(expr) : desc(expr));
|
|
1386
|
+
}
|
|
1387
|
+
else {
|
|
1388
|
+
// Use drizzleOrder for non-aggregate fields
|
|
1389
|
+
const clause = drizzleOrder({ [field]: direction }, {
|
|
1390
|
+
table: this.table,
|
|
1391
|
+
tableName: this.collection
|
|
1392
|
+
});
|
|
1393
|
+
orderByClause.push(...clause);
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
if (orderByClause.length > 0) {
|
|
1397
|
+
aggregateQuery = aggregateQuery.orderBy(...orderByClause);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
// Execute query
|
|
1401
|
+
results = await aggregateQuery;
|
|
1402
|
+
}
|
|
1403
|
+
// For grouped results, count is the number of groups
|
|
1404
|
+
const totalCount = results.length;
|
|
1405
|
+
return {
|
|
1406
|
+
data: results,
|
|
1407
|
+
totalCount
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
/**
|
|
1411
|
+
* Read a single record by ID
|
|
1412
|
+
*/
|
|
1413
|
+
async readOne(id, query = {}, bypassPermissions = false) {
|
|
1414
|
+
const parsedId = this.parseId(id);
|
|
1415
|
+
if (!parsedId) {
|
|
1416
|
+
throw new APIError('Invalid ID', 400);
|
|
1417
|
+
}
|
|
1418
|
+
// Execute before-read-one hooks
|
|
1419
|
+
let hookData = await hooksManager.executeHooks(this.collection, 'items.read.one', this.accountability, { id: parsedId, query });
|
|
1420
|
+
try {
|
|
1421
|
+
const isAdmin = await this.isAdministrator();
|
|
1422
|
+
// Build query with ID filter
|
|
1423
|
+
const { whereClause, selectColumns, joins, processedIncludes, filterJoins } = await this.buildQuery(query, {
|
|
1424
|
+
isAdmin,
|
|
1425
|
+
action: 'read',
|
|
1426
|
+
bypassPermissions,
|
|
1427
|
+
idFilter: parsedId
|
|
1428
|
+
});
|
|
1429
|
+
// Build base query
|
|
1430
|
+
let baseQuery = db.select(selectColumns).from(this.table);
|
|
1431
|
+
// Apply filterJoins first (these come from relation path filters in WHERE clause)
|
|
1432
|
+
// use Drizzle query builder methods instead of raw SQL
|
|
1433
|
+
if (filterJoins && filterJoins.length > 0) {
|
|
1434
|
+
filterJoins.forEach((join) => {
|
|
1435
|
+
const { table: joinTable, condition, type = 'left' } = join;
|
|
1436
|
+
const joinMethod = type === 'inner' ? 'innerJoin' : 'leftJoin';
|
|
1437
|
+
baseQuery = baseQuery[joinMethod](joinTable, condition);
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1440
|
+
// Apply joins for includes (these are for loading related data)
|
|
1441
|
+
for (const include of processedIncludes) {
|
|
1442
|
+
if (include.separate)
|
|
1443
|
+
continue;
|
|
1444
|
+
if (include.relationType === 'BelongsTo' || include.relationType === 'HasOne') {
|
|
1445
|
+
baseQuery = baseQuery.leftJoin(include.table, include.joinCondition);
|
|
1446
|
+
if (include.nested.length > 0) {
|
|
1447
|
+
baseQuery = this.applyNestedJoins(baseQuery, include.nested);
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
// Apply where clause
|
|
1452
|
+
if (whereClause) {
|
|
1453
|
+
baseQuery = baseQuery.where(whereClause);
|
|
1454
|
+
}
|
|
1455
|
+
// Execute query
|
|
1456
|
+
const records = await baseQuery.limit(1);
|
|
1457
|
+
if (!records || records.length === 0) {
|
|
1458
|
+
throw new APIError("Item not found or you don't have permission to read it", 403);
|
|
1459
|
+
}
|
|
1460
|
+
// Load separate relations and nest joined relations
|
|
1461
|
+
let finalRecords = records;
|
|
1462
|
+
if (hasSeparateQueries(processedIncludes)) {
|
|
1463
|
+
finalRecords = await loadSeparateRelations(db, records, processedIncludes, this.collection);
|
|
1464
|
+
}
|
|
1465
|
+
else {
|
|
1466
|
+
// No separate queries, but we still need to nest joined BelongsTo/HasOne relations
|
|
1467
|
+
finalRecords = nestJoinedRelations(records, processedIncludes);
|
|
1468
|
+
}
|
|
1469
|
+
const document = finalRecords[0];
|
|
1470
|
+
// Strip hidden fields from the document
|
|
1471
|
+
const strippedDocument = fieldUtils.stripHiddenFields(this.collection, document);
|
|
1472
|
+
// Execute after-read-one hooks
|
|
1473
|
+
hookData = await hooksManager.executeHooks(this.collection, 'items.read.one.after', this.accountability, { id: parsedId, query, document: strippedDocument });
|
|
1474
|
+
return hookData.document;
|
|
1475
|
+
}
|
|
1476
|
+
catch (error) {
|
|
1477
|
+
console.error('Error in readOne:', error);
|
|
1478
|
+
throw error;
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
/**
|
|
1482
|
+
* Create a new record
|
|
1483
|
+
* Uses createOneCore for the transactional logic and executes after hooks after commit
|
|
1484
|
+
*/
|
|
1485
|
+
async createOne(data, options = {}) {
|
|
1486
|
+
console.log(`[ItemsService.createOne] START - Collection: ${this.collection}`);
|
|
1487
|
+
console.log('[ItemsService.createOne] Input data:', JSON.stringify(data));
|
|
1488
|
+
// Create transaction if not provided (matches Sequelize pattern)
|
|
1489
|
+
const transaction = options.transaction || (await createTransaction());
|
|
1490
|
+
const shouldCommit = !options.transaction; // Only commit if we created the transaction
|
|
1491
|
+
try {
|
|
1492
|
+
// Execute core create logic within transaction
|
|
1493
|
+
const result = await this.createOneCore(data, transaction, options);
|
|
1494
|
+
// Commit transaction if we created it
|
|
1495
|
+
if (shouldCommit) {
|
|
1496
|
+
await transaction.commit();
|
|
1497
|
+
}
|
|
1498
|
+
// Create audit log for create action (after commit for single operations)
|
|
1499
|
+
await this.createAuditLog('create', result.itemId, {
|
|
1500
|
+
before: null,
|
|
1501
|
+
after: result.document
|
|
1502
|
+
}, options.transaction);
|
|
1503
|
+
// Execute after-create hooks (after commit to prevent side effects on rollback)
|
|
1504
|
+
await hooksManager.executeHooks(this.collection, 'items.create.after', this.accountability, { data: result.modifiedData, document: result.document, transaction: options.transaction });
|
|
1505
|
+
// Invalidate cache for this collection and all related tables
|
|
1506
|
+
await this.invalidateCache(result.relatedTables);
|
|
1507
|
+
return result.itemId;
|
|
1508
|
+
}
|
|
1509
|
+
catch (error) {
|
|
1510
|
+
console.error('Error in createOne:', error);
|
|
1511
|
+
// Rollback transaction if we created it
|
|
1512
|
+
if (shouldCommit) {
|
|
1513
|
+
try {
|
|
1514
|
+
await transaction.rollback();
|
|
1515
|
+
}
|
|
1516
|
+
catch (rollbackError) {
|
|
1517
|
+
// Ignore __ROLLBACK__ errors as they're intentional
|
|
1518
|
+
if (rollbackError.message !== '__ROLLBACK__') {
|
|
1519
|
+
console.error('Error during rollback:', rollbackError);
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
throw error;
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
/**
|
|
1527
|
+
* Internal method to perform core create logic without after hooks
|
|
1528
|
+
* Used by both createOne and createMany to separate transactional data operations
|
|
1529
|
+
* from after hooks that may have side effects (emails, third-party calls)
|
|
1530
|
+
*
|
|
1531
|
+
* @param data - Item data to create
|
|
1532
|
+
* @param transaction - Transaction to use
|
|
1533
|
+
* @param options - Operation options
|
|
1534
|
+
* @returns Object containing item ID, document, modified data, and related tables for cache invalidation
|
|
1535
|
+
*/
|
|
1536
|
+
async createOneCore(data, transaction, options = {}) {
|
|
1537
|
+
console.log(`[ItemsService.createOneCore] START - Collection: ${this.collection}`);
|
|
1538
|
+
console.log('[ItemsService.createOneCore] Input data:', JSON.stringify(data));
|
|
1539
|
+
// Execute before-create hooks with transaction
|
|
1540
|
+
let hookData = await hooksManager.executeHooks(this.collection, 'items.create', this.accountability, { data, transaction: options.transaction });
|
|
1541
|
+
let modifiedData = hookData.data;
|
|
1542
|
+
console.log('[ItemsService.createOneCore] After before-hooks, modifiedData:', JSON.stringify(modifiedData));
|
|
1543
|
+
// Hash password for baasix_User
|
|
1544
|
+
if (this.collection === 'baasix_User' && modifiedData.password) {
|
|
1545
|
+
console.log('[ItemsService.createOneCore] Hashing password for baasix_User');
|
|
1546
|
+
modifiedData.password = await argon2.hash(modifiedData.password);
|
|
1547
|
+
}
|
|
1548
|
+
const isAdmin = await this.isAdministrator();
|
|
1549
|
+
// Apply field permissions
|
|
1550
|
+
if (!options.bypassPermissions) {
|
|
1551
|
+
await this.applyFieldPermissions(modifiedData, 'create', isAdmin);
|
|
1552
|
+
const defaultValues = await this.getDefaultValues('create');
|
|
1553
|
+
modifiedData = { ...defaultValues, ...modifiedData };
|
|
1554
|
+
}
|
|
1555
|
+
// Validate and enforce tenant context
|
|
1556
|
+
modifiedData = await this.validateAndEnforceTenantContext(modifiedData);
|
|
1557
|
+
// Validate relational data
|
|
1558
|
+
await validateRelationalData(modifiedData, this.collection, this);
|
|
1559
|
+
// Validate field values against schema validation rules (min, max, etc.)
|
|
1560
|
+
await valueValidator.validateOrThrow(this.collection, modifiedData, false);
|
|
1561
|
+
// Handle circular dependencies
|
|
1562
|
+
const { resolvedData, deferredFields } = await resolveCircularDependencies(modifiedData, this.collection, this);
|
|
1563
|
+
// Process relational data (extract nested objects/arrays)
|
|
1564
|
+
const relationalResult = await processRelationalData(this.collection, resolvedData, this, ItemsService);
|
|
1565
|
+
const { result: mainData, deferredM2M, deferredM2A, deferredHasMany } = relationalResult;
|
|
1566
|
+
// Handle usertrack: set userCreated_Id if enabled
|
|
1567
|
+
const schemaDefinition = await schemaManager.getSchemaDefinition(this.collection);
|
|
1568
|
+
if (schemaDefinition?.usertrack && this.accountability?.user?.id) {
|
|
1569
|
+
mainData.userCreated_Id = this.accountability.user.id;
|
|
1570
|
+
}
|
|
1571
|
+
// Handle sortEnabled: auto-assign sequential sort values
|
|
1572
|
+
if (schemaDefinition?.sortEnabled && !mainData.sort) {
|
|
1573
|
+
try {
|
|
1574
|
+
const maxSortResult = await transaction.execute(sql `SELECT COALESCE(MAX("sort"), 0) as "maxSort" FROM ${sql.raw(`"${this.collection}"`)}`);
|
|
1575
|
+
let maxSort = 0;
|
|
1576
|
+
if (maxSortResult && Array.isArray(maxSortResult)) {
|
|
1577
|
+
maxSort = maxSortResult[0]?.maxSort ?? 0;
|
|
1578
|
+
}
|
|
1579
|
+
else if (maxSortResult?.rows && Array.isArray(maxSortResult.rows)) {
|
|
1580
|
+
maxSort = maxSortResult.rows[0]?.maxSort ?? 0;
|
|
1581
|
+
}
|
|
1582
|
+
mainData.sort = Number(maxSort) + 1;
|
|
1583
|
+
}
|
|
1584
|
+
catch (sortError) {
|
|
1585
|
+
console.error('[ItemsService.createOneCore] Error assigning sort value:', sortError);
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
// Filter out VIRTUAL (generated) fields
|
|
1589
|
+
if (schemaDefinition?.fields) {
|
|
1590
|
+
for (const [fieldName, fieldSchema] of Object.entries(schemaDefinition.fields)) {
|
|
1591
|
+
if (fieldSchema.type === 'VIRTUAL' && fieldName in mainData) {
|
|
1592
|
+
delete mainData[fieldName];
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
// Remove undefined values before insert
|
|
1597
|
+
const cleanedData = {};
|
|
1598
|
+
for (const [key, value] of Object.entries(mainData)) {
|
|
1599
|
+
if (value !== undefined) {
|
|
1600
|
+
cleanedData[key] = value;
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
// Convert date strings to Date objects for DateTime/Timestamp fields
|
|
1604
|
+
await this.convertDateFields(cleanedData, schemaDefinition);
|
|
1605
|
+
// Insert main record
|
|
1606
|
+
let item;
|
|
1607
|
+
try {
|
|
1608
|
+
const insertResult = await transaction
|
|
1609
|
+
.insert(this.table)
|
|
1610
|
+
.values(cleanedData)
|
|
1611
|
+
.returning();
|
|
1612
|
+
if (!insertResult || insertResult.length === 0) {
|
|
1613
|
+
throw new APIError('Failed to create item', 500);
|
|
1614
|
+
}
|
|
1615
|
+
item = insertResult[0];
|
|
1616
|
+
}
|
|
1617
|
+
catch (insertError) {
|
|
1618
|
+
console.error('Insert error details:', {
|
|
1619
|
+
collection: this.collection,
|
|
1620
|
+
error: insertError.message,
|
|
1621
|
+
cleanedDataKeys: Object.keys(cleanedData),
|
|
1622
|
+
cleanedData: cleanedData
|
|
1623
|
+
});
|
|
1624
|
+
throw insertError;
|
|
1625
|
+
}
|
|
1626
|
+
const itemId = item[this.primaryKey];
|
|
1627
|
+
// Process deferred fields (circular dependencies)
|
|
1628
|
+
await processDeferredFields(item, deferredFields, this, transaction);
|
|
1629
|
+
// Handle deferred HasMany relations
|
|
1630
|
+
for (const { association, associationInfo, value } of deferredHasMany) {
|
|
1631
|
+
await handleHasManyRelationship(item, association, associationInfo, value, this, ItemsService, transaction);
|
|
1632
|
+
}
|
|
1633
|
+
// Handle M2M relationships
|
|
1634
|
+
for (const { association, associationInfo, value } of deferredM2M) {
|
|
1635
|
+
await handleM2MRelationship(item, association, associationInfo, value, this, ItemsService, transaction);
|
|
1636
|
+
}
|
|
1637
|
+
// Handle M2A relationships
|
|
1638
|
+
for (const { association, associationInfo, value } of deferredM2A) {
|
|
1639
|
+
await handleM2ARelationship(item, association, associationInfo, value, this, ItemsService, transaction);
|
|
1640
|
+
}
|
|
1641
|
+
// Collect related tables for cache invalidation
|
|
1642
|
+
const relatedTables = [];
|
|
1643
|
+
if (deferredM2M.length > 0) {
|
|
1644
|
+
for (const { associationInfo } of deferredM2M) {
|
|
1645
|
+
if (associationInfo.junctionTable) {
|
|
1646
|
+
relatedTables.push(associationInfo.junctionTable);
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
if (deferredHasMany.length > 0) {
|
|
1651
|
+
for (const { associationInfo } of deferredHasMany) {
|
|
1652
|
+
if (associationInfo.relatedCollection) {
|
|
1653
|
+
relatedTables.push(associationInfo.relatedCollection);
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
if (deferredM2A.length > 0) {
|
|
1658
|
+
for (const { associationInfo } of deferredM2A) {
|
|
1659
|
+
if (associationInfo.relatedCollections) {
|
|
1660
|
+
relatedTables.push(...associationInfo.relatedCollections);
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
return {
|
|
1665
|
+
itemId,
|
|
1666
|
+
document: item,
|
|
1667
|
+
modifiedData,
|
|
1668
|
+
relatedTables
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
/**
|
|
1672
|
+
* Create multiple records with transactional safety
|
|
1673
|
+
*
|
|
1674
|
+
* This method ensures that:
|
|
1675
|
+
* 1. All items are created within a single transaction
|
|
1676
|
+
* 2. If any creation fails, all previous creations are rolled back
|
|
1677
|
+
* 3. After hooks (which may have external side effects like emails, API calls)
|
|
1678
|
+
* are only executed AFTER the transaction is successfully committed
|
|
1679
|
+
*
|
|
1680
|
+
* This prevents situations where:
|
|
1681
|
+
* - Emails are sent for items that were later rolled back
|
|
1682
|
+
* - Third-party systems are notified about changes that didn't persist
|
|
1683
|
+
*
|
|
1684
|
+
* @param items - Array of items to create
|
|
1685
|
+
* @param options - Operation options
|
|
1686
|
+
* @returns Array of created item IDs
|
|
1687
|
+
*/
|
|
1688
|
+
async createMany(items, options = {}) {
|
|
1689
|
+
if (items.length === 0) {
|
|
1690
|
+
return [];
|
|
1691
|
+
}
|
|
1692
|
+
// Create transaction if not provided
|
|
1693
|
+
const transaction = options.transaction || (await createTransaction());
|
|
1694
|
+
const shouldCommit = !options.transaction; // Only commit if we created the transaction
|
|
1695
|
+
// Store results for after hooks
|
|
1696
|
+
const results = [];
|
|
1697
|
+
try {
|
|
1698
|
+
// Phase 1: Execute all core create operations within transaction
|
|
1699
|
+
for (const item of items) {
|
|
1700
|
+
const result = await this.createOneCore(item, transaction, options);
|
|
1701
|
+
results.push(result);
|
|
1702
|
+
}
|
|
1703
|
+
// Phase 2: Commit transaction (if we created it)
|
|
1704
|
+
if (shouldCommit) {
|
|
1705
|
+
await transaction.commit();
|
|
1706
|
+
}
|
|
1707
|
+
// Phase 3: Execute after hooks AFTER successful commit
|
|
1708
|
+
// These may have external side effects (emails, third-party calls)
|
|
1709
|
+
// that cannot be rolled back, so we only execute them after commit
|
|
1710
|
+
for (const result of results) {
|
|
1711
|
+
// Create audit log
|
|
1712
|
+
await this.createAuditLog('create', result.itemId, {
|
|
1713
|
+
before: null,
|
|
1714
|
+
after: result.document
|
|
1715
|
+
}, options.transaction);
|
|
1716
|
+
// Execute after-create hooks
|
|
1717
|
+
await hooksManager.executeHooks(this.collection, 'items.create.after', this.accountability, { data: result.modifiedData, document: result.document, transaction: options.transaction });
|
|
1718
|
+
}
|
|
1719
|
+
// Invalidate cache for all affected tables
|
|
1720
|
+
const allRelatedTables = [...new Set(results.flatMap(r => r.relatedTables))];
|
|
1721
|
+
await this.invalidateCache(allRelatedTables);
|
|
1722
|
+
return results.map(r => r.itemId);
|
|
1723
|
+
}
|
|
1724
|
+
catch (error) {
|
|
1725
|
+
console.error('Error in createMany:', error);
|
|
1726
|
+
// Rollback transaction if we created it
|
|
1727
|
+
if (shouldCommit) {
|
|
1728
|
+
try {
|
|
1729
|
+
await transaction.rollback();
|
|
1730
|
+
}
|
|
1731
|
+
catch (rollbackError) {
|
|
1732
|
+
if (rollbackError.message !== '__ROLLBACK__') {
|
|
1733
|
+
console.error('Error during rollback:', rollbackError);
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
throw error;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
/**
|
|
1741
|
+
* Internal method to perform core update logic without after hooks
|
|
1742
|
+
* Used by both updateOne and updateMany to separate transactional data operations
|
|
1743
|
+
* from after hooks that may have side effects (emails, third-party calls)
|
|
1744
|
+
*
|
|
1745
|
+
* @param id - Item ID to update
|
|
1746
|
+
* @param data - Item data to update
|
|
1747
|
+
* @param transaction - Transaction to use
|
|
1748
|
+
* @param options - Operation options
|
|
1749
|
+
* @returns Object containing update info for after hooks execution
|
|
1750
|
+
*/
|
|
1751
|
+
async updateOneCore(id, data, transaction, options = {}) {
|
|
1752
|
+
const parsedId = this.parseId(id);
|
|
1753
|
+
// Execute before-update hooks with transaction
|
|
1754
|
+
let hookData = await hooksManager.executeHooks(this.collection, 'items.update', this.accountability, { id: parsedId, data, transaction: options.transaction });
|
|
1755
|
+
let modifiedData = hookData.data;
|
|
1756
|
+
// Hash password for baasix_User
|
|
1757
|
+
if (this.collection === 'baasix_User' && modifiedData.password) {
|
|
1758
|
+
modifiedData.password = await argon2.hash(modifiedData.password);
|
|
1759
|
+
}
|
|
1760
|
+
const isAdmin = await this.isAdministrator();
|
|
1761
|
+
// Apply field permissions
|
|
1762
|
+
if (!options.bypassPermissions) {
|
|
1763
|
+
await this.applyFieldPermissions(modifiedData, 'update', isAdmin);
|
|
1764
|
+
const defaultValues = await this.getDefaultValues('update');
|
|
1765
|
+
modifiedData = { ...defaultValues, ...modifiedData };
|
|
1766
|
+
}
|
|
1767
|
+
// Validate and enforce tenant context
|
|
1768
|
+
modifiedData = await this.validateAndEnforceTenantContext(modifiedData);
|
|
1769
|
+
// Validate field values against schema validation rules (min, max, etc.)
|
|
1770
|
+
await valueValidator.validateOrThrow(this.collection, modifiedData, true);
|
|
1771
|
+
// Filter out VIRTUAL (generated) fields
|
|
1772
|
+
let schemaDefinition = await schemaManager.getSchemaDefinition(this.collection);
|
|
1773
|
+
if (schemaDefinition?.fields) {
|
|
1774
|
+
for (const [fieldName, fieldSchema] of Object.entries(schemaDefinition.fields)) {
|
|
1775
|
+
if (fieldSchema.type === 'VIRTUAL' && fieldName in modifiedData) {
|
|
1776
|
+
delete modifiedData[fieldName];
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
// Build filter for existing record check
|
|
1781
|
+
let filter = {
|
|
1782
|
+
[this.primaryKey]: parsedId
|
|
1783
|
+
};
|
|
1784
|
+
if (!options.bypassPermissions && !isAdmin) {
|
|
1785
|
+
const roleId = this.getRoleId();
|
|
1786
|
+
const permissionFilter = await permissionService.getFilter(roleId, this.collection, 'update', this.accountability);
|
|
1787
|
+
if (permissionFilter.conditions) {
|
|
1788
|
+
filter = combineFilters(filter, permissionFilter.conditions);
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
filter = await this.enforceTenantContextFilter(filter);
|
|
1792
|
+
filter = await resolveDynamicVariables(filter, this.accountability);
|
|
1793
|
+
// Check if record exists and user has permission
|
|
1794
|
+
const filterJoins = [];
|
|
1795
|
+
const whereClause = drizzleWhere(filter, {
|
|
1796
|
+
table: this.table,
|
|
1797
|
+
tableName: this.collection,
|
|
1798
|
+
schema: this.table,
|
|
1799
|
+
joins: filterJoins,
|
|
1800
|
+
forPermissionCheck: true
|
|
1801
|
+
});
|
|
1802
|
+
// Deduplicate filter joins by alias
|
|
1803
|
+
const uniqueJoins = [];
|
|
1804
|
+
const seenAliases = new Set();
|
|
1805
|
+
for (const join of filterJoins) {
|
|
1806
|
+
if (!seenAliases.has(join.alias)) {
|
|
1807
|
+
seenAliases.add(join.alias);
|
|
1808
|
+
uniqueJoins.push(join);
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
filterJoins.length = 0;
|
|
1812
|
+
filterJoins.push(...uniqueJoins);
|
|
1813
|
+
let existingItems;
|
|
1814
|
+
if (filterJoins.length > 0) {
|
|
1815
|
+
// Use transaction for reads to prevent deadlocks and ensure consistency
|
|
1816
|
+
let query = transaction.select().from(this.table);
|
|
1817
|
+
filterJoins.forEach((join) => {
|
|
1818
|
+
const { table: joinTable, condition, type = 'left' } = join;
|
|
1819
|
+
const joinMethod = type === 'inner' ? 'innerJoin' : 'leftJoin';
|
|
1820
|
+
query = query[joinMethod](joinTable, condition);
|
|
1821
|
+
});
|
|
1822
|
+
existingItems = await query.where(whereClause).limit(1);
|
|
1823
|
+
}
|
|
1824
|
+
else {
|
|
1825
|
+
// Use transaction for reads to prevent deadlocks and ensure consistency
|
|
1826
|
+
existingItems = await transaction
|
|
1827
|
+
.select()
|
|
1828
|
+
.from(this.table)
|
|
1829
|
+
.where(whereClause)
|
|
1830
|
+
.limit(1);
|
|
1831
|
+
}
|
|
1832
|
+
if (!existingItems || existingItems.length === 0) {
|
|
1833
|
+
throw new APIError("Item not found or you don't have permission to update it", 403);
|
|
1834
|
+
}
|
|
1835
|
+
// When there are joins, Drizzle returns results in nested format: { tableName: { ...data }, joinedTable: { ...data } }
|
|
1836
|
+
// Extract the main table's data when joins are present
|
|
1837
|
+
let existingItem = existingItems[0];
|
|
1838
|
+
if (filterJoins.length > 0 && existingItem[this.collection]) {
|
|
1839
|
+
existingItem = existingItem[this.collection];
|
|
1840
|
+
}
|
|
1841
|
+
// Ensure tenant_Id cannot be changed (except by admin)
|
|
1842
|
+
if (this.isMultiTenant &&
|
|
1843
|
+
modifiedData.tenant_Id &&
|
|
1844
|
+
modifiedData.tenant_Id !== existingItem.tenant_Id &&
|
|
1845
|
+
!isAdmin) {
|
|
1846
|
+
throw new APIError("Cannot change item's tenant", 403);
|
|
1847
|
+
}
|
|
1848
|
+
// Process relational data
|
|
1849
|
+
const relationalResult = await processRelationalData(this.collection, modifiedData, this, ItemsService);
|
|
1850
|
+
const { result: mainData, deferredM2M, deferredM2A, deferredHasMany } = relationalResult;
|
|
1851
|
+
// Handle usertrack: set userUpdated_Id if enabled
|
|
1852
|
+
if (!schemaDefinition) {
|
|
1853
|
+
schemaDefinition = await schemaManager.getSchemaDefinition(this.collection);
|
|
1854
|
+
}
|
|
1855
|
+
if (schemaDefinition?.usertrack && this.accountability?.user?.id) {
|
|
1856
|
+
mainData.userUpdated_Id = this.accountability.user.id;
|
|
1857
|
+
}
|
|
1858
|
+
// Auto-update updatedAt timestamp if timestamps enabled (default: true)
|
|
1859
|
+
if (schemaDefinition?.timestamps !== false && 'updatedAt' in this.table) {
|
|
1860
|
+
mainData.updatedAt = new Date();
|
|
1861
|
+
}
|
|
1862
|
+
// Convert date strings to Date objects for DateTime/Timestamp fields
|
|
1863
|
+
await this.convertDateFields(mainData, schemaDefinition);
|
|
1864
|
+
// Update main record only if there are fields to update
|
|
1865
|
+
let updatedItem;
|
|
1866
|
+
if (Object.keys(mainData).length > 0) {
|
|
1867
|
+
const updateResult = await transaction
|
|
1868
|
+
.update(this.table)
|
|
1869
|
+
.set(mainData)
|
|
1870
|
+
.where(eq(this.getPrimaryKeyColumn(), parsedId))
|
|
1871
|
+
.returning();
|
|
1872
|
+
if (!updateResult || updateResult.length === 0) {
|
|
1873
|
+
if (deferredM2M.length === 0 && deferredM2A.length === 0 && deferredHasMany.length === 0) {
|
|
1874
|
+
// No update needed, return existing item info
|
|
1875
|
+
return {
|
|
1876
|
+
parsedId,
|
|
1877
|
+
modifiedData,
|
|
1878
|
+
finalDocument: existingItem,
|
|
1879
|
+
previousDocument: existingItem,
|
|
1880
|
+
relatedTables: []
|
|
1881
|
+
};
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
updatedItem = updateResult[0];
|
|
1885
|
+
}
|
|
1886
|
+
else {
|
|
1887
|
+
// Use transaction for reads to prevent deadlocks and ensure consistency
|
|
1888
|
+
const updatedItems = await transaction
|
|
1889
|
+
.select()
|
|
1890
|
+
.from(this.table)
|
|
1891
|
+
.where(eq(this.getPrimaryKeyColumn(), parsedId))
|
|
1892
|
+
.limit(1);
|
|
1893
|
+
updatedItem = updatedItems[0];
|
|
1894
|
+
}
|
|
1895
|
+
// Process deferred HasMany relations
|
|
1896
|
+
for (const { association, associationInfo, value } of deferredHasMany) {
|
|
1897
|
+
await handleHasManyRelationship(updatedItem, association, associationInfo, value, this, ItemsService, transaction);
|
|
1898
|
+
}
|
|
1899
|
+
// Handle M2M relationships
|
|
1900
|
+
for (const { association, associationInfo, value } of deferredM2M) {
|
|
1901
|
+
await handleM2MRelationship(updatedItem, association, associationInfo, value, this, ItemsService, transaction);
|
|
1902
|
+
}
|
|
1903
|
+
// Handle M2A relationships
|
|
1904
|
+
for (const { association, associationInfo, value } of deferredM2A) {
|
|
1905
|
+
await handleM2ARelationship(updatedItem, association, associationInfo, value, this, ItemsService, transaction);
|
|
1906
|
+
}
|
|
1907
|
+
// Get final item for hooks - use transaction for consistency
|
|
1908
|
+
const finalItems = await transaction
|
|
1909
|
+
.select()
|
|
1910
|
+
.from(this.table)
|
|
1911
|
+
.where(eq(this.getPrimaryKeyColumn(), parsedId))
|
|
1912
|
+
.limit(1);
|
|
1913
|
+
const finalItem = finalItems[0];
|
|
1914
|
+
// Collect related tables for cache invalidation
|
|
1915
|
+
const relatedTables = [];
|
|
1916
|
+
if (deferredM2M.length > 0) {
|
|
1917
|
+
for (const { associationInfo } of deferredM2M) {
|
|
1918
|
+
if (associationInfo.junctionTable) {
|
|
1919
|
+
relatedTables.push(associationInfo.junctionTable);
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
if (deferredHasMany.length > 0) {
|
|
1924
|
+
for (const { associationInfo } of deferredHasMany) {
|
|
1925
|
+
if (associationInfo.relatedCollection) {
|
|
1926
|
+
relatedTables.push(associationInfo.relatedCollection);
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
if (deferredM2A.length > 0) {
|
|
1931
|
+
for (const { associationInfo } of deferredM2A) {
|
|
1932
|
+
if (associationInfo.relatedCollections) {
|
|
1933
|
+
relatedTables.push(...associationInfo.relatedCollections);
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
return {
|
|
1938
|
+
parsedId,
|
|
1939
|
+
modifiedData,
|
|
1940
|
+
finalDocument: finalItem,
|
|
1941
|
+
previousDocument: existingItem,
|
|
1942
|
+
relatedTables
|
|
1943
|
+
};
|
|
1944
|
+
}
|
|
1945
|
+
/**
|
|
1946
|
+
* Update multiple records with transactional safety
|
|
1947
|
+
*
|
|
1948
|
+
* This method ensures that:
|
|
1949
|
+
* 1. All items are updated within a single transaction
|
|
1950
|
+
* 2. If any update fails, all previous updates are rolled back
|
|
1951
|
+
* 3. After hooks (which may have external side effects like emails, API calls)
|
|
1952
|
+
* are only executed AFTER the transaction is successfully committed
|
|
1953
|
+
*
|
|
1954
|
+
* @param updates - Array of objects with id and data to update
|
|
1955
|
+
* @param options - Operation options
|
|
1956
|
+
* @returns Array of updated item IDs
|
|
1957
|
+
*/
|
|
1958
|
+
async updateMany(updates, options = {}) {
|
|
1959
|
+
if (updates.length === 0) {
|
|
1960
|
+
return [];
|
|
1961
|
+
}
|
|
1962
|
+
// Create transaction if not provided
|
|
1963
|
+
const transaction = options.transaction || (await createTransaction());
|
|
1964
|
+
const shouldCommit = !options.transaction;
|
|
1965
|
+
// Store results for after hooks
|
|
1966
|
+
const results = [];
|
|
1967
|
+
try {
|
|
1968
|
+
// Phase 1: Execute all core update operations within transaction
|
|
1969
|
+
for (const update of updates) {
|
|
1970
|
+
const { id, data, ...rest } = update;
|
|
1971
|
+
if (!id)
|
|
1972
|
+
continue;
|
|
1973
|
+
// Support both {id, data: {...}} and {id, field1, field2, ...} formats
|
|
1974
|
+
const updateData = data || rest;
|
|
1975
|
+
const result = await this.updateOneCore(id, updateData, transaction, options);
|
|
1976
|
+
results.push(result);
|
|
1977
|
+
}
|
|
1978
|
+
// Phase 2: Commit transaction (if we created it)
|
|
1979
|
+
if (shouldCommit) {
|
|
1980
|
+
await transaction.commit();
|
|
1981
|
+
}
|
|
1982
|
+
// Phase 3: Execute after hooks AFTER successful commit
|
|
1983
|
+
for (const result of results) {
|
|
1984
|
+
// Create audit log
|
|
1985
|
+
await this.createAuditLog('update', result.parsedId, {
|
|
1986
|
+
before: result.previousDocument,
|
|
1987
|
+
after: result.finalDocument
|
|
1988
|
+
}, options.transaction);
|
|
1989
|
+
// Execute after-update hooks
|
|
1990
|
+
await hooksManager.executeHooks(this.collection, 'items.update.after', this.accountability, {
|
|
1991
|
+
id: result.parsedId,
|
|
1992
|
+
data: result.modifiedData,
|
|
1993
|
+
document: result.finalDocument,
|
|
1994
|
+
previousDocument: result.previousDocument,
|
|
1995
|
+
transaction: options.transaction
|
|
1996
|
+
});
|
|
1997
|
+
}
|
|
1998
|
+
// Invalidate cache for all affected tables
|
|
1999
|
+
const allRelatedTables = [...new Set(results.flatMap(r => r.relatedTables))];
|
|
2000
|
+
await this.invalidateCache(allRelatedTables);
|
|
2001
|
+
return results.map(r => r.parsedId);
|
|
2002
|
+
}
|
|
2003
|
+
catch (error) {
|
|
2004
|
+
console.error('Error in updateMany:', error);
|
|
2005
|
+
if (shouldCommit) {
|
|
2006
|
+
try {
|
|
2007
|
+
await transaction.rollback();
|
|
2008
|
+
}
|
|
2009
|
+
catch (rollbackError) {
|
|
2010
|
+
if (rollbackError.message !== '__ROLLBACK__') {
|
|
2011
|
+
console.error('Error during rollback:', rollbackError);
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
throw error;
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
/**
|
|
2019
|
+
* Update a record by ID
|
|
2020
|
+
* Uses updateOneCore for the transactional logic and executes after hooks after commit
|
|
2021
|
+
*/
|
|
2022
|
+
async updateOne(id, data, options = {}) {
|
|
2023
|
+
// Create transaction if not provided (matches Sequelize pattern)
|
|
2024
|
+
const transaction = options.transaction || (await createTransaction());
|
|
2025
|
+
const shouldCommit = !options.transaction;
|
|
2026
|
+
try {
|
|
2027
|
+
// Execute core update logic within transaction
|
|
2028
|
+
const result = await this.updateOneCore(id, data, transaction, options);
|
|
2029
|
+
// Commit transaction if we created it
|
|
2030
|
+
if (shouldCommit) {
|
|
2031
|
+
await transaction.commit();
|
|
2032
|
+
}
|
|
2033
|
+
// Create audit log for update action (after commit)
|
|
2034
|
+
await this.createAuditLog('update', result.parsedId, {
|
|
2035
|
+
before: result.previousDocument,
|
|
2036
|
+
after: result.finalDocument
|
|
2037
|
+
}, options.transaction);
|
|
2038
|
+
// Execute after-update hooks (after commit to prevent side effects on rollback)
|
|
2039
|
+
await hooksManager.executeHooks(this.collection, 'items.update.after', this.accountability, {
|
|
2040
|
+
id: result.parsedId,
|
|
2041
|
+
data: result.modifiedData,
|
|
2042
|
+
document: result.finalDocument,
|
|
2043
|
+
previousDocument: result.previousDocument,
|
|
2044
|
+
transaction: options.transaction
|
|
2045
|
+
});
|
|
2046
|
+
// Invalidate cache for this collection and all related tables
|
|
2047
|
+
await this.invalidateCache(result.relatedTables);
|
|
2048
|
+
return result.parsedId;
|
|
2049
|
+
}
|
|
2050
|
+
catch (error) {
|
|
2051
|
+
console.error('Error in updateOne:', error);
|
|
2052
|
+
// Rollback transaction if we created it
|
|
2053
|
+
if (shouldCommit) {
|
|
2054
|
+
try {
|
|
2055
|
+
await transaction.rollback();
|
|
2056
|
+
}
|
|
2057
|
+
catch (rollbackError) {
|
|
2058
|
+
if (rollbackError.message !== '__ROLLBACK__') {
|
|
2059
|
+
console.error('Error during rollback:', rollbackError);
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
throw error;
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
/**
|
|
2067
|
+
* Internal method to perform core delete logic without after hooks
|
|
2068
|
+
* Used by both deleteOne and deleteMany to separate transactional data operations
|
|
2069
|
+
* from after hooks that may have side effects (emails, third-party calls)
|
|
2070
|
+
*
|
|
2071
|
+
* @param id - Item ID to delete
|
|
2072
|
+
* @param transaction - Transaction to use
|
|
2073
|
+
* @param options - Operation options
|
|
2074
|
+
* @returns Object containing delete info for after hooks execution
|
|
2075
|
+
*/
|
|
2076
|
+
async deleteOneCore(id, transaction, options = {}) {
|
|
2077
|
+
const parsedId = this.parseId(id);
|
|
2078
|
+
// Execute before-delete hooks with transaction
|
|
2079
|
+
await hooksManager.executeHooks(this.collection, 'items.delete', this.accountability, { id: parsedId, transaction: options.transaction });
|
|
2080
|
+
const isAdmin = await this.isAdministrator();
|
|
2081
|
+
// Check permission
|
|
2082
|
+
if (!options.bypassPermissions && !isAdmin) {
|
|
2083
|
+
const roleId = this.getRoleId();
|
|
2084
|
+
const hasPermission = await permissionService.canAccess(roleId, this.collection, 'delete');
|
|
2085
|
+
if (!hasPermission) {
|
|
2086
|
+
throw new APIError("You don't have permission to delete this item", 403);
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
// Build filter for existing record check
|
|
2090
|
+
let filter = {
|
|
2091
|
+
[this.primaryKey]: parsedId
|
|
2092
|
+
};
|
|
2093
|
+
if (!options.bypassPermissions && !isAdmin) {
|
|
2094
|
+
const roleId = this.getRoleId();
|
|
2095
|
+
const permissionFilter = await permissionService.getFilter(roleId, this.collection, 'delete', this.accountability);
|
|
2096
|
+
if (permissionFilter.conditions) {
|
|
2097
|
+
filter = combineFilters(filter, permissionFilter.conditions);
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
filter = await this.enforceTenantContextFilter(filter);
|
|
2101
|
+
filter = await resolveDynamicVariables(filter, this.accountability);
|
|
2102
|
+
// Check if record exists and user has permission
|
|
2103
|
+
const filterJoins = [];
|
|
2104
|
+
const whereClause = drizzleWhere(filter, {
|
|
2105
|
+
table: this.table,
|
|
2106
|
+
tableName: this.collection,
|
|
2107
|
+
schema: this.table,
|
|
2108
|
+
joins: filterJoins,
|
|
2109
|
+
forPermissionCheck: true
|
|
2110
|
+
});
|
|
2111
|
+
// Deduplicate filter joins by alias
|
|
2112
|
+
const uniqueJoins = [];
|
|
2113
|
+
const seenAliases = new Set();
|
|
2114
|
+
for (const join of filterJoins) {
|
|
2115
|
+
if (!seenAliases.has(join.alias)) {
|
|
2116
|
+
seenAliases.add(join.alias);
|
|
2117
|
+
uniqueJoins.push(join);
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
filterJoins.length = 0;
|
|
2121
|
+
filterJoins.push(...uniqueJoins);
|
|
2122
|
+
let existingItems;
|
|
2123
|
+
if (filterJoins.length > 0) {
|
|
2124
|
+
// Use transaction for reads to prevent deadlocks and ensure consistency
|
|
2125
|
+
let query = transaction.select().from(this.table);
|
|
2126
|
+
filterJoins.forEach((join) => {
|
|
2127
|
+
const { table: joinTable, condition, type = 'left' } = join;
|
|
2128
|
+
const joinMethod = type === 'inner' ? 'innerJoin' : 'leftJoin';
|
|
2129
|
+
query = query[joinMethod](joinTable, condition);
|
|
2130
|
+
});
|
|
2131
|
+
existingItems = await query.where(whereClause).limit(1);
|
|
2132
|
+
}
|
|
2133
|
+
else {
|
|
2134
|
+
// Use transaction for reads to prevent deadlocks and ensure consistency
|
|
2135
|
+
existingItems = await transaction
|
|
2136
|
+
.select()
|
|
2137
|
+
.from(this.table)
|
|
2138
|
+
.where(whereClause)
|
|
2139
|
+
.limit(1);
|
|
2140
|
+
}
|
|
2141
|
+
if (!existingItems || existingItems.length === 0) {
|
|
2142
|
+
throw new APIError("Item not found or you don't have permission to delete it", 403);
|
|
2143
|
+
}
|
|
2144
|
+
const item = existingItems[0];
|
|
2145
|
+
// Handle related records cleanup based on onDelete settings
|
|
2146
|
+
await handleRelatedRecordsBeforeDelete(item, this, transaction);
|
|
2147
|
+
// Check if paranoid mode is enabled
|
|
2148
|
+
const isParanoid = schemaManager.isParanoid(this.collection);
|
|
2149
|
+
const forceDelete = options.force === true;
|
|
2150
|
+
let result;
|
|
2151
|
+
if (isParanoid && !forceDelete) {
|
|
2152
|
+
// Soft delete: Set deletedAt timestamp
|
|
2153
|
+
const userId = this.accountability?.user?.id;
|
|
2154
|
+
const softDeleteData = softDelete(userId ? String(userId) : undefined);
|
|
2155
|
+
result = await transaction
|
|
2156
|
+
.update(this.table)
|
|
2157
|
+
.set(softDeleteData)
|
|
2158
|
+
.where(eq(this.getPrimaryKeyColumn(), parsedId))
|
|
2159
|
+
.returning();
|
|
2160
|
+
if (!result || result.length === 0) {
|
|
2161
|
+
throw new APIError('Item not found or already deleted', 404);
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
else {
|
|
2165
|
+
// Hard delete: Physically remove from database
|
|
2166
|
+
result = await transaction
|
|
2167
|
+
.delete(this.table)
|
|
2168
|
+
.where(eq(this.getPrimaryKeyColumn(), parsedId))
|
|
2169
|
+
.returning();
|
|
2170
|
+
if (!result || result.length === 0) {
|
|
2171
|
+
throw new APIError('Item not found or already deleted', 404);
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
return {
|
|
2175
|
+
parsedId,
|
|
2176
|
+
document: item
|
|
2177
|
+
};
|
|
2178
|
+
}
|
|
2179
|
+
/**
|
|
2180
|
+
* Delete multiple records with transactional safety
|
|
2181
|
+
*
|
|
2182
|
+
* This method ensures that:
|
|
2183
|
+
* 1. All items are deleted within a single transaction
|
|
2184
|
+
* 2. If any deletion fails, all previous deletions are rolled back
|
|
2185
|
+
* 3. After hooks (which may have external side effects like emails, API calls)
|
|
2186
|
+
* are only executed AFTER the transaction is successfully committed
|
|
2187
|
+
*
|
|
2188
|
+
* @param ids - Array of item IDs to delete
|
|
2189
|
+
* @param options - Operation options
|
|
2190
|
+
* @returns Array of deleted item IDs
|
|
2191
|
+
*/
|
|
2192
|
+
async deleteMany(ids, options = {}) {
|
|
2193
|
+
if (ids.length === 0) {
|
|
2194
|
+
return [];
|
|
2195
|
+
}
|
|
2196
|
+
// Create transaction if not provided
|
|
2197
|
+
const transaction = options.transaction || (await createTransaction());
|
|
2198
|
+
const shouldCommit = !options.transaction;
|
|
2199
|
+
// Store results for after hooks
|
|
2200
|
+
const results = [];
|
|
2201
|
+
try {
|
|
2202
|
+
// Phase 1: Execute all core delete operations within transaction
|
|
2203
|
+
for (const id of ids) {
|
|
2204
|
+
const result = await this.deleteOneCore(id, transaction, options);
|
|
2205
|
+
results.push(result);
|
|
2206
|
+
}
|
|
2207
|
+
// Phase 2: Commit transaction (if we created it)
|
|
2208
|
+
if (shouldCommit) {
|
|
2209
|
+
await transaction.commit();
|
|
2210
|
+
}
|
|
2211
|
+
// Phase 3: Execute after hooks AFTER successful commit
|
|
2212
|
+
for (const result of results) {
|
|
2213
|
+
// Create audit log
|
|
2214
|
+
await this.createAuditLog('delete', result.parsedId, {
|
|
2215
|
+
before: result.document,
|
|
2216
|
+
after: null
|
|
2217
|
+
}, options.transaction);
|
|
2218
|
+
// Execute after-delete hooks
|
|
2219
|
+
await hooksManager.executeHooks(this.collection, 'items.delete.after', this.accountability, { id: result.parsedId, document: result.document, transaction: options.transaction });
|
|
2220
|
+
}
|
|
2221
|
+
// Invalidate cache for this collection
|
|
2222
|
+
await this.invalidateCache();
|
|
2223
|
+
return results.map(r => r.parsedId);
|
|
2224
|
+
}
|
|
2225
|
+
catch (error) {
|
|
2226
|
+
console.error('Error in deleteMany:', error);
|
|
2227
|
+
if (shouldCommit) {
|
|
2228
|
+
try {
|
|
2229
|
+
await transaction.rollback();
|
|
2230
|
+
}
|
|
2231
|
+
catch (rollbackError) {
|
|
2232
|
+
if (rollbackError.message !== '__ROLLBACK__') {
|
|
2233
|
+
console.error('Error during rollback:', rollbackError);
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
throw error;
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
/**
|
|
2241
|
+
* Delete a record by ID
|
|
2242
|
+
* Uses deleteOneCore for the transactional logic and executes after hooks after commit
|
|
2243
|
+
*/
|
|
2244
|
+
async deleteOne(id, options = {}) {
|
|
2245
|
+
// Create transaction if not provided (matches Sequelize pattern)
|
|
2246
|
+
const transaction = options.transaction || (await createTransaction());
|
|
2247
|
+
const shouldCommit = !options.transaction;
|
|
2248
|
+
try {
|
|
2249
|
+
// Execute core delete logic within transaction
|
|
2250
|
+
const result = await this.deleteOneCore(id, transaction, options);
|
|
2251
|
+
// Commit transaction if we created it
|
|
2252
|
+
if (shouldCommit) {
|
|
2253
|
+
await transaction.commit();
|
|
2254
|
+
}
|
|
2255
|
+
// Create audit log for delete action (after commit)
|
|
2256
|
+
await this.createAuditLog('delete', result.parsedId, {
|
|
2257
|
+
before: result.document,
|
|
2258
|
+
after: null
|
|
2259
|
+
}, options.transaction);
|
|
2260
|
+
// Execute after-delete hooks (after commit to prevent side effects on rollback)
|
|
2261
|
+
await hooksManager.executeHooks(this.collection, 'items.delete.after', this.accountability, { id: result.parsedId, document: result.document, transaction: options.transaction });
|
|
2262
|
+
// Invalidate cache for this collection
|
|
2263
|
+
await this.invalidateCache();
|
|
2264
|
+
return result.parsedId;
|
|
2265
|
+
}
|
|
2266
|
+
catch (error) {
|
|
2267
|
+
console.error('Error in deleteOne:', error);
|
|
2268
|
+
// Rollback transaction if we created it
|
|
2269
|
+
if (shouldCommit) {
|
|
2270
|
+
try {
|
|
2271
|
+
await transaction.rollback();
|
|
2272
|
+
}
|
|
2273
|
+
catch (rollbackError) {
|
|
2274
|
+
if (rollbackError.message !== '__ROLLBACK__') {
|
|
2275
|
+
console.error('Error during rollback:', rollbackError);
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
throw error;
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
/**
|
|
2283
|
+
* Alias for readByQuery
|
|
2284
|
+
*/
|
|
2285
|
+
async list(query = {}, bypassPermissions = false) {
|
|
2286
|
+
return this.readByQuery(query, bypassPermissions);
|
|
2287
|
+
}
|
|
2288
|
+
/**
|
|
2289
|
+
* Alias for readOne
|
|
2290
|
+
*/
|
|
2291
|
+
async read(id, query = {}, bypassPermissions = false) {
|
|
2292
|
+
return this.readOne(id, query, bypassPermissions);
|
|
2293
|
+
}
|
|
2294
|
+
/**
|
|
2295
|
+
* Alias for createOne
|
|
2296
|
+
*/
|
|
2297
|
+
async create(data, options = {}) {
|
|
2298
|
+
return this.createOne(data, options);
|
|
2299
|
+
}
|
|
2300
|
+
/**
|
|
2301
|
+
* Alias for updateOne
|
|
2302
|
+
*/
|
|
2303
|
+
async update(id, data, options = {}) {
|
|
2304
|
+
return this.updateOne(id, data, options);
|
|
2305
|
+
}
|
|
2306
|
+
/**
|
|
2307
|
+
* Alias for deleteOne
|
|
2308
|
+
*/
|
|
2309
|
+
async delete(id, options = {}) {
|
|
2310
|
+
return this.deleteOne(id, options);
|
|
2311
|
+
}
|
|
2312
|
+
/**
|
|
2313
|
+
* Restore a soft-deleted record
|
|
2314
|
+
* Only works for collections with paranoid mode enabled
|
|
2315
|
+
*/
|
|
2316
|
+
async restore(id, options = {}) {
|
|
2317
|
+
const parsedId = this.parseId(id);
|
|
2318
|
+
try {
|
|
2319
|
+
// Check if paranoid mode is enabled
|
|
2320
|
+
const isParanoid = schemaManager.isParanoid(this.collection);
|
|
2321
|
+
if (!isParanoid) {
|
|
2322
|
+
throw new APIError(`Collection ${this.collection} does not have soft delete enabled`, 400);
|
|
2323
|
+
}
|
|
2324
|
+
const isAdmin = await this.isAdministrator();
|
|
2325
|
+
// Check permission
|
|
2326
|
+
if (!options.bypassPermissions && !isAdmin) {
|
|
2327
|
+
const roleId = typeof this.accountability?.role === 'object' ? this.accountability.role.id : this.accountability?.role;
|
|
2328
|
+
const hasPermission = await permissionService.canAccess(roleId, this.collection, 'update' // Restore requires update permission
|
|
2329
|
+
);
|
|
2330
|
+
if (!hasPermission) {
|
|
2331
|
+
throw new APIError("You don't have permission to restore this item", 403);
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
// Build filter for existing record check (include soft-deleted)
|
|
2335
|
+
let filter = {
|
|
2336
|
+
[`${this.collection}.${this.primaryKey}`]: parsedId,
|
|
2337
|
+
[`${this.collection}.deletedAt`]: { _is_not_null: true } // Only restore soft-deleted items
|
|
2338
|
+
};
|
|
2339
|
+
filter = await this.enforceTenantContextFilter(filter);
|
|
2340
|
+
// Check if record exists and is soft-deleted
|
|
2341
|
+
const whereClause = drizzleWhere(filter, {
|
|
2342
|
+
table: this.table,
|
|
2343
|
+
tableName: this.collection,
|
|
2344
|
+
schema: this.table
|
|
2345
|
+
});
|
|
2346
|
+
const existingItems = await db
|
|
2347
|
+
.select()
|
|
2348
|
+
.from(this.table)
|
|
2349
|
+
.where(whereClause)
|
|
2350
|
+
.limit(1);
|
|
2351
|
+
if (!existingItems || existingItems.length === 0) {
|
|
2352
|
+
throw new APIError("Item not found or not soft-deleted", 404);
|
|
2353
|
+
}
|
|
2354
|
+
// Restore: Set deletedAt to null
|
|
2355
|
+
const restoreData = restore();
|
|
2356
|
+
const result = await db
|
|
2357
|
+
.update(this.table)
|
|
2358
|
+
.set(restoreData)
|
|
2359
|
+
.where(eq(this.getPrimaryKeyColumn(), parsedId))
|
|
2360
|
+
.returning();
|
|
2361
|
+
if (!result || result.length === 0) {
|
|
2362
|
+
throw new APIError('Failed to restore item', 500);
|
|
2363
|
+
}
|
|
2364
|
+
return parsedId;
|
|
2365
|
+
}
|
|
2366
|
+
catch (error) {
|
|
2367
|
+
console.error('Error in restore:', error);
|
|
2368
|
+
throw error;
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
/**
|
|
2372
|
+
* Create an audit log entry for create/update/delete operations
|
|
2373
|
+
* @param action - The action performed (create, update, delete)
|
|
2374
|
+
* @param entityId - The ID of the entity
|
|
2375
|
+
* @param changes - The changes made (before and after states)
|
|
2376
|
+
* @param transaction - Optional transaction to use
|
|
2377
|
+
*/
|
|
2378
|
+
async createAuditLog(action, entityId, changes, transaction) {
|
|
2379
|
+
// Collections to exclude from audit logging
|
|
2380
|
+
const excludeCollections = ['baasix_AuditLog', 'baasix_Sessions'];
|
|
2381
|
+
// Skip audit log creation for excluded collections
|
|
2382
|
+
if (excludeCollections.includes(this.collection)) {
|
|
2383
|
+
return;
|
|
2384
|
+
}
|
|
2385
|
+
try {
|
|
2386
|
+
// Get special handling for baasix_SchemaDefinition - use collectionName as ID
|
|
2387
|
+
let auditEntityId = entityId;
|
|
2388
|
+
if (this.collection === 'baasix_SchemaDefinition' && changes.after?.collectionName) {
|
|
2389
|
+
auditEntityId = changes.after.collectionName;
|
|
2390
|
+
}
|
|
2391
|
+
// Serialize changes to JSON to avoid Postgres type issues
|
|
2392
|
+
// Drizzle expects JSON fields to be properly serialized
|
|
2393
|
+
const serializedChanges = {
|
|
2394
|
+
before: changes.before ? JSON.parse(JSON.stringify(changes.before)) : null,
|
|
2395
|
+
after: changes.after ? JSON.parse(JSON.stringify(changes.after)) : null
|
|
2396
|
+
};
|
|
2397
|
+
// Prepare audit log data
|
|
2398
|
+
const auditLogData = {
|
|
2399
|
+
type: 'data',
|
|
2400
|
+
entity: this.collection,
|
|
2401
|
+
entityId: String(auditEntityId),
|
|
2402
|
+
action: action,
|
|
2403
|
+
changes: serializedChanges,
|
|
2404
|
+
userId: this.accountability?.user?.id || null,
|
|
2405
|
+
ipaddress: this.accountability?.ipaddress || null,
|
|
2406
|
+
};
|
|
2407
|
+
// Add tenant_Id if multi-tenant is enabled
|
|
2408
|
+
const tenantId = this.tenant || this.accountability?.tenant;
|
|
2409
|
+
if (tenantId) {
|
|
2410
|
+
auditLogData.tenant_Id = tenantId;
|
|
2411
|
+
}
|
|
2412
|
+
// Create audit log entry using ItemsService to avoid circular dependency
|
|
2413
|
+
const auditLogTable = schemaManager.getTable('baasix_AuditLog');
|
|
2414
|
+
if (!auditLogTable) {
|
|
2415
|
+
console.warn('[AuditLog] baasix_AuditLog table not found, skipping audit log creation');
|
|
2416
|
+
return;
|
|
2417
|
+
}
|
|
2418
|
+
// Use transaction if provided, otherwise use db directly
|
|
2419
|
+
const dbClient = transaction || db;
|
|
2420
|
+
await dbClient
|
|
2421
|
+
.insert(auditLogTable)
|
|
2422
|
+
.values(auditLogData);
|
|
2423
|
+
}
|
|
2424
|
+
catch (error) {
|
|
2425
|
+
// Log error but don't fail the main operation
|
|
2426
|
+
console.error('[AuditLog] Failed to create audit log:', error.message);
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
/**
|
|
2430
|
+
* Convert date string fields to Date objects for DateTime/Timestamp fields
|
|
2431
|
+
* This is needed because Drizzle expects Date objects for timestamp columns
|
|
2432
|
+
* Note: Date type (date-only) expects strings, not Date objects
|
|
2433
|
+
*/
|
|
2434
|
+
async convertDateFields(data, schemaDefinition) {
|
|
2435
|
+
if (!schemaDefinition || !schemaDefinition.fields) {
|
|
2436
|
+
return;
|
|
2437
|
+
}
|
|
2438
|
+
const fields = schemaDefinition.fields;
|
|
2439
|
+
for (const [fieldName, fieldDef] of Object.entries(fields)) {
|
|
2440
|
+
const fieldConfig = fieldDef;
|
|
2441
|
+
// Only convert DateTime/DateTime_NO_TZ/Timestamp to Date objects
|
|
2442
|
+
// Do NOT convert Date type (date-only) - it expects strings
|
|
2443
|
+
if ((fieldConfig.type === 'DateTime' || fieldConfig.type === 'DateTime_NO_TZ' || fieldConfig.type === 'Timestamp') &&
|
|
2444
|
+
data[fieldName] !== undefined &&
|
|
2445
|
+
data[fieldName] !== null) {
|
|
2446
|
+
// Convert string to Date object if it's not already a Date
|
|
2447
|
+
if (typeof data[fieldName] === 'string') {
|
|
2448
|
+
const dateValue = new Date(data[fieldName]);
|
|
2449
|
+
if (!isNaN(dateValue.getTime())) {
|
|
2450
|
+
data[fieldName] = dateValue;
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
export default ItemsService;
|
|
2458
|
+
//# sourceMappingURL=ItemsService.js.map
|