From 962f489142ea97f8e268faa3e1102c3ddade8c79 Mon Sep 17 00:00:00 2001 From: Kostya Fediv Date: Wed, 2 Sep 2026 09:19:43 +0300 Subject: [PATCH] update --- ...-09-01_144533-erp-foundation-next-steps.md | 170 +++++++++++++++ apps/backend/Dockerfile | 2 + .../app/base_modules/Admin/model/admin.ts | 8 + .../Contact/controller/contacts_controller.ts | 88 ++++++++ .../app/base_modules/Contact/model/contact.ts | 33 +++ .../app/base_modules/Contact/routes.ts | 15 ++ .../validators/contact_create_validator.ts | 9 + .../validators/contact_update_validator.ts | 10 + .../controller/custom_fields_controller.ts | 16 ++ .../CustomField/model/custom_field.ts | 17 ++ .../app/base_modules/CustomField/routes.ts | 12 ++ .../Tenant/controller/tenant_controller.ts | 13 ++ .../app/base_modules/Tenant/model/tenant.ts | 31 +++ .../backend/app/base_modules/Tenant/routes.ts | 8 + .../Tenant/services/tenant_access_service.ts | 15 ++ .../middleware/resolve_tenant_middleware.ts | 43 ++++ .../writers/backend_module_writer.ts | 6 +- apps/backend/app/services/swagger_model.ts | 36 +++- .../app/templates/backend/api/controller.hbs | 89 ++++---- .../app/templates/backend/api/routes.hbs | 3 + .../app/templates/backend/tests/api.spec.hbs | 98 ++++++++- .../1784635200003_create_tenants_table.ts | 20 ++ ...784635200004_create_admin_tenants_table.ts | 32 +++ .../1784635200005_create_contacts_table.ts | 29 +++ ...784635200006_create_custom_fields_table.ts | 33 +++ apps/backend/start/kernel.ts | 1 + .../functional/contacts/contacts_api.spec.ts | 197 ++++++++++++++++++ .../custom_fields/custom_fields_api.spec.ts | 43 ++++ .../functional/tenant/tenant_context.spec.ts | 70 +++++++ .../erp_module_templates.spec.ts | 71 +++++++ .../unit/tenant/tenant_access_service.spec.ts | 23 ++ docker-compose.yml | 16 +- package-lock.json | 56 +---- package.json | 1 + 34 files changed, 1190 insertions(+), 124 deletions(-) create mode 100644 .hermes/plans/2026-09-01_144533-erp-foundation-next-steps.md create mode 100644 apps/backend/app/base_modules/Contact/controller/contacts_controller.ts create mode 100644 apps/backend/app/base_modules/Contact/model/contact.ts create mode 100644 apps/backend/app/base_modules/Contact/routes.ts create mode 100644 apps/backend/app/base_modules/Contact/validators/contact_create_validator.ts create mode 100644 apps/backend/app/base_modules/Contact/validators/contact_update_validator.ts create mode 100644 apps/backend/app/base_modules/CustomField/controller/custom_fields_controller.ts create mode 100644 apps/backend/app/base_modules/CustomField/model/custom_field.ts create mode 100644 apps/backend/app/base_modules/CustomField/routes.ts create mode 100644 apps/backend/app/base_modules/Tenant/controller/tenant_controller.ts create mode 100644 apps/backend/app/base_modules/Tenant/model/tenant.ts create mode 100644 apps/backend/app/base_modules/Tenant/routes.ts create mode 100644 apps/backend/app/base_modules/Tenant/services/tenant_access_service.ts create mode 100644 apps/backend/app/middleware/resolve_tenant_middleware.ts create mode 100644 apps/backend/database/migrations/1784635200003_create_tenants_table.ts create mode 100644 apps/backend/database/migrations/1784635200004_create_admin_tenants_table.ts create mode 100644 apps/backend/database/migrations/1784635200005_create_contacts_table.ts create mode 100644 apps/backend/database/migrations/1784635200006_create_custom_fields_table.ts create mode 100644 apps/backend/tests/functional/contacts/contacts_api.spec.ts create mode 100644 apps/backend/tests/functional/custom_fields/custom_fields_api.spec.ts create mode 100644 apps/backend/tests/functional/tenant/tenant_context.spec.ts create mode 100644 apps/backend/tests/unit/module_generator/erp_module_templates.spec.ts create mode 100644 apps/backend/tests/unit/tenant/tenant_access_service.spec.ts diff --git a/.hermes/plans/2026-09-01_144533-erp-foundation-next-steps.md b/.hermes/plans/2026-09-01_144533-erp-foundation-next-steps.md new file mode 100644 index 0000000..698bc60 --- /dev/null +++ b/.hermes/plans/2026-09-01_144533-erp-foundation-next-steps.md @@ -0,0 +1,170 @@ +# ERP Foundation Next Steps Implementation Plan + +> **For Hermes:** Implement this plan task-by-task using strict TDD. + +**Goal:** Establish a testable, tenant-safe ERP foundation in the existing AdonisJS 7 + React + PostgreSQL modular monolith, then deliver the first reusable core modules. + +**Architecture:** Preserve the existing `apps/backend/modules/` and `apps/admin/src/modules/` conventions. Core infrastructure becomes the only source of tenant scope, authorization, contacts, custom-field metadata, durable events, and internal service contracts. Business modules must not issue SQL joins into each other’s tables. + +**Tech Stack:** AdonisJS 7, Lucid ORM, PostgreSQL 17, Japa, Bouncer, `@adonisjs/auth`, adonis-autoswagger, React 19, Vite, React Query, React Hook Form, React Intl, Docker Compose. + +--- + +## Current verified context + +- Compose stack is running with `zfg-*` container names. +- Backend lint, formatting, and typecheck run successfully in `zfg-backend-adonis`. +- Database-backed tests require `DB_DATABASE=adonis_test`; the default Compose development database is `adonis` and is deliberately rejected by the test bootstrap. +- The module generator now generates protected API CRUD routes, policy checks, concrete AutoSwagger response annotations, and CRUD/auth/permission test scaffolding. +- The generator must be used as a scaffolding baseline only. ERP domain logic must add a migration, service contract, tenant scope, event/outbox behavior, endpoint tests, and documentation before the module is complete. + +## Definition of done for every ERP endpoint + +1. Write a failing Japa HTTP test first; run it in Compose against `adonis_test` and confirm the expected failure. +2. Implement the smallest behavior needed to pass. +3. Cover success, authentication, authorization, validation, not-found/conflict, tenant isolation, and any idempotency behavior relevant to that endpoint. +4. Annotate every active controller action with concrete, valid JSON AutoSwagger request/response examples. +5. Run: + - `docker compose exec -T backend npm run lint` + - `docker compose exec -T backend npm run typecheck` + - `docker compose exec -T backend npm run format` + - `docker compose exec -T -e DB_DATABASE=adonis_test backend npm test -- --files ` +6. Ensure `git diff --check` passes. For new API routes, inspect generated docs at `/swagger` or run the project’s Swagger command if one is added. + +## Step 1: Make the Compose test database invocation first-class + +**Objective:** Remove the need for ad-hoc environment overrides when running backend tests in Compose. + +**Files likely to change:** + +- Modify: `docker-compose.yml` +- Modify or create: `apps/backend/.env.test.example` +- Modify: `apps/backend/package.json` +- Test: `apps/backend/tests/bootstrap.ts` + +**Tasks:** + +1. Decide and document one supported Compose test command, preferably a dedicated `test:compose` backend script or Compose service/profile. +2. Ensure it injects `NODE_ENV=test` and `DB_DATABASE=adonis_test` without altering the development backend container’s `adonis` database. +3. Add a small test/bootstrap assertion that the test command receives an `*_test` database name. +4. Verify the generator test runs without manually supplying `-e DB_DATABASE=adonis_test`. + +## Step 2: Design and implement tenant context + +**Objective:** Resolve one authenticated/request-scoped tenant and prevent unscoped tenant data access. + +**Files likely to change:** + +- Create: `apps/backend/database/migrations/_create_tenants_table.ts` +- Create: `apps/backend/app/base_modules/Tenant/model/tenant.ts` +- Create: `apps/backend/app/services/tenant_context.ts` +- Create: `apps/backend/app/middleware/resolve_tenant_middleware.ts` +- Modify: `apps/backend/start/kernel.ts` +- Modify: `apps/backend/config/database.ts` +- Test: `apps/backend/tests/functional/tenant/tenant_context.spec.ts` + +**Tasks:** + +1. Write failing tests for subdomain/header/token tenant resolution, missing tenant rejection, and cross-tenant record invisibility. +2. Add a forward-only tenants migration and a tenant-owned record fixture. +3. Implement a request-scoped tenant context that is immutable after middleware resolution. +4. Add tenant middleware after authentication and before module controllers. +5. Introduce a reusable tenant query helper/service so every tenant-owned module uses the same scope. +6. Keep the initial approach PostgreSQL row isolation with `tenant_id`; defer per-schema/per-database selection until a measured SaaS scaling requirement exists. + +## Step 3: Extend IAM for tenant-aware ERP permissions + +**Objective:** Build authorization around tenant membership and namespaced ERP permissions. + +**Files likely to change:** + +- Create migration(s) for tenant memberships and role assignment scope. +- Modify: `apps/backend/app/services/permission_service.ts` +- Modify: `apps/backend/app/middleware/initialize_bouncer_middleware.ts` +- Create: `apps/backend/app/base_modules/Tenant/policy/tenant_policy.ts` +- Test: `apps/backend/tests/functional/iam/tenant_permissions.spec.ts` + +**Tasks:** + +1. Write failing endpoint tests for no token (401), membership absence (403), missing permission (403), and valid tenant role access (200/201). +2. Add tenant membership persistence and tenant-scoped role/permission lookup without breaking the existing administrator model. +3. Register core permission slugs: `tenant.view`, `tenant.manage`, `contacts.view`, `contacts.create`, `contacts.update`, and `contacts.delete`. +4. Add an idempotent migration to grant approved core permissions to the Super Admin role on existing installations. +5. Add concrete AutoSwagger examples for all IAM endpoints. + +## Step 4: Implement Base Contacts as the first generated ERP module + +**Objective:** Provide the canonical tenant-owned people and organization directory. + +**Files likely to change:** + +- Create: `apps/backend/database/migrations/_create_contacts_table.ts` +- Create: `apps/backend/modules/Contacts/...` +- Create: `apps/admin/src/modules/Contacts/...` +- Test: `apps/backend/tests/modules/Contacts/contacts.api.spec.ts` + +**Tasks:** + +1. Start with HTTP tests for list/create/show/update/delete, validation, permissions, and isolation across two tenants. +2. Create `contacts` with tenant ID, contact kind, legal/display name, parent organization, email/phone, address payload, status, timestamps, and `custom_fields JSONB`. +3. Generate CRUD assets through MakeModuleController only after the table migration exists; then replace generic controller logic with tenant-aware Contacts service calls. +4. Register the Contacts permission config and ensure menus/routes use `contacts.view`. +5. Add React module configuration, API client, list/form pages, and translations for `en`, `de`, `fr`, and `nl` with identical key sets. +6. Add concrete AutoSwagger examples matching the actual serializer wrappers and error shapes. + +## Step 5: Implement metadata and custom-fields configuration + +**Objective:** Allow tenant administrators to define validated dynamic fields for approved entities without schema migrations. + +**Files likely to change:** + +- Create migration(s) for `custom_field_definitions` and optional option records. +- Create: `apps/backend/app/base_modules/Metadata/...` +- Modify: `apps/backend/modules/Contacts/...` +- Test: `apps/backend/tests/functional/metadata/custom_fields.spec.ts` +- Create: `apps/admin/src/modules/Metadata/...` + +**Tasks:** + +1. Write failing tests for field creation, allowed target entity, invalid values, required-field enforcement, and tenant isolation. +2. Persist field key, entity type, scalar type, validation rules, required/visibility state, option values, and tenant ownership. +3. Build one reusable validator that reads metadata definitions and validates the `custom_fields` JSONB payload before module persistence. +4. Expose dynamic-form metadata through an authenticated endpoint with concrete AutoSwagger examples. +5. Render dynamic controls in the Contacts admin form, including all requested translations. + +## Step 6: Establish the internal event/outbox contract before commercial modules + +**Objective:** Make CRM, Sales, Inventory, Procurement, Invoicing, and Accounting communication durable and module-boundary-safe. + +**Files likely to change:** + +- Create migration: `apps/backend/database/migrations/_create_outbox_events_table.ts` +- Create: `apps/backend/app/services/outbox/...` +- Create: `apps/backend/app/services/module_contracts/...` +- Test: `apps/backend/tests/functional/outbox/outbox_delivery.spec.ts` + +**Tasks:** + +1. Write failing tests proving that transaction rollback creates neither business record nor outbox event, and retries do not process a duplicate event twice. +2. Persist event ID, tenant ID, name, version, aggregate ID, correlation ID, payload, occurred time, and processing state in an outbox table within the business transaction. +3. Implement an explicit publisher and idempotent consumer registry. The initial delivery mechanism may use the Adonis event emitter only after outbox persistence succeeds. +4. Define versioned service interfaces for cross-module reads, beginning with `ContactService` and future `ProductService`; prohibit SQL joins across module-owned tables. +5. Add tests and concrete OpenAPI examples for any operational event-monitoring endpoints. + +## After the foundation + +Proceed in this order: + +1. Product Catalog. +2. CRM with `crm:lead_won` outbox event. +3. Sales Orders consuming `crm:lead_won` and emitting `sales:order_confirmed`. +4. Inventory/Logistics consuming confirmed orders and emitting `inventory:stock_shipped`. +5. Procurement and reorder rules emitting/consuming receiving events. +6. Invoicing and Accounting from immutable operational events. + +## Risks and decisions + +- Do not implement PostgreSQL-per-tenant schemas or separate databases until tenant row isolation, authorization, and test coverage are established; otherwise every migration and test path becomes unnecessarily complex. +- Do not add Vite Module Federation until the existing admin module registry is insufficient. The existing React module convention is the source of truth for the first ERP modules. +- Do not generate migrations from an already-existing table automatically. Migrations must be authored and reviewed before MakeModuleController scaffolds a module from that table. +- Do not generate generic Swagger schema objects. Every response annotation needs a concrete valid JSON example derived from the serializer. diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index 0e12972..d2d9135 100755 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -5,8 +5,10 @@ WORKDIR /app # Copy all workspaces before installation so npm can resolve their dependencies. COPY package.json package-lock.json ./ +COPY eslint.config.js ./ COPY apps ./apps COPY packages ./packages +COPY scripts ./scripts # Install workspace dependencies (monorepo-aware). RUN npm ci --force diff --git a/apps/backend/app/base_modules/Admin/model/admin.ts b/apps/backend/app/base_modules/Admin/model/admin.ts index 0a75dc3..ab3528b 100755 --- a/apps/backend/app/base_modules/Admin/model/admin.ts +++ b/apps/backend/app/base_modules/Admin/model/admin.ts @@ -7,6 +7,7 @@ import type { ManyToMany } from '@adonisjs/lucid/types/relations' import { DateTime } from 'luxon' import Role from '#base_modules/Role/model/role' import Permission from '#base_modules/Permission/model/permission' +import Tenant from '#base_modules/Tenant/model/tenant' const AuthFinder = withAuthFinder(() => hash.use('scrypt'), { uids: ['email'], @@ -52,6 +53,13 @@ export default class Admins extends ComposedModel { }) declare roles: ManyToMany + @manyToMany(() => Tenant, { + pivotTable: 'admin_tenants', + pivotForeignKey: 'admin_id', + pivotRelatedForeignKey: 'tenant_id', + }) + declare tenants: ManyToMany + declare permissions: Permission[] static accessTokens = DbAccessTokensProvider.forModel(Admins) diff --git a/apps/backend/app/base_modules/Contact/controller/contacts_controller.ts b/apps/backend/app/base_modules/Contact/controller/contacts_controller.ts new file mode 100644 index 0000000..8777468 --- /dev/null +++ b/apps/backend/app/base_modules/Contact/controller/contacts_controller.ts @@ -0,0 +1,88 @@ +import type { HttpContext } from '@adonisjs/core/http' +import Contact from '#base_modules/Contact/model/contact' +import { createContactValidator } from '#base_modules/Contact/validators/contact_create_validator' +import { updateContactValidator } from '#base_modules/Contact/validators/contact_update_validator' + +export default class ContactsController { + /** + * @summary List contacts in the current tenant + * @responseBody 200 - {"data":[{"id":1,"display_name":"Ada Lovelace","email":"ada@example.test","phone":null,"status":"active"}]} + * @responseBody 401 - {"message":"Unauthorized"} + * @responseBody 400 - {"code":"E_TENANT_REQUIRED","message":"X-Tenant-Id header is required"} + * @responseBody 403 - {"code":"E_TENANT_ACCESS_DENIED","message":"You do not have access to this tenant"} + */ + async index({ response, tenant }: HttpContext) { + const contacts = await Contact.query().where('tenant_id', tenant.id).orderBy('id', 'asc') + return response.ok({ data: contacts }) + } + + /** + * @summary Show a contact in the current tenant + * @responseBody 200 - {"data":{"id":1,"display_name":"Katherine Johnson","email":"katherine@example.test","phone":null,"status":"active"}} + * @responseBody 404 - {"message":"Not found."} + */ + async show({ request, response, tenant }: HttpContext) { + const contact = await Contact.query() + .where('id', request.param('id')) + .where('tenant_id', tenant.id) + .first() + if (!contact) return response.notFound({ message: 'Not found.' }) + return response.ok({ data: contact }) + } + + /** + * @summary Update a contact in the current tenant + * @responseBody 200 - {"data":{"id":1,"display_name":"Updated Name","email":"updated@example.test","phone":null,"status":"active"}} + * @responseBody 404 - {"message":"Not found."} + */ + async update({ request, response, tenant }: HttpContext) { + const contact = await Contact.query() + .where('id', request.param('id')) + .where('tenant_id', tenant.id) + .first() + if (!contact) return response.notFound({ message: 'Not found.' }) + const data = await updateContactValidator.validate({ + displayName: request.input('display_name'), + email: request.input('email'), + phone: request.input('phone'), + status: request.input('status'), + }) + contact.merge(data) + await contact.save() + return response.ok({ data: contact }) + } + + /** + * @summary Delete a contact in the current tenant + * @responseBody 200 - {"message":"Deleted."} + * @responseBody 404 - {"message":"Not found."} + */ + async destroy({ request, response, tenant }: HttpContext) { + const contact = await Contact.query() + .where('id', request.param('id')) + .where('tenant_id', tenant.id) + .first() + if (!contact) return response.notFound({ message: 'Not found.' }) + await contact.delete() + return response.ok({ message: 'Deleted.' }) + } + + /** + * @summary Create a contact in the current tenant + * @responseBody 201 - {"data":{"id":1,"display_name":"Grace Hopper","email":"grace@example.test","phone":"+1-555-0100","status":"active"}} + * @responseBody 401 - {"message":"Unauthorized"} + * @responseBody 400 - {"code":"E_TENANT_REQUIRED","message":"X-Tenant-Id header is required"} + * @responseBody 403 - {"code":"E_TENANT_ACCESS_DENIED","message":"You do not have access to this tenant"} + */ + async store({ request, response, tenant }: HttpContext) { + const input = await createContactValidator.validate(request.all()) + const contact = await Contact.create({ + tenantId: tenant.id, + displayName: input.display_name, + email: input.email, + phone: input.phone, + status: 'active', + }) + return response.created({ data: contact }) + } +} diff --git a/apps/backend/app/base_modules/Contact/model/contact.ts b/apps/backend/app/base_modules/Contact/model/contact.ts new file mode 100644 index 0000000..ea785de --- /dev/null +++ b/apps/backend/app/base_modules/Contact/model/contact.ts @@ -0,0 +1,33 @@ +import { BaseModel, column } from '@adonisjs/lucid/orm' +import { DateTime } from 'luxon' + +export default class Contact extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column({ columnName: 'tenant_id', serializeAs: null }) + declare tenantId: number + + @column({ columnName: 'display_name', serializeAs: 'display_name' }) + declare displayName: string + + @column() + declare email: string | null + + @column() + declare phone: string | null + + @column() + declare status: 'active' | 'inactive' + + @column.dateTime({ autoCreate: true, columnName: 'created_at', serializeAs: null }) + declare createdAt: DateTime + + @column.dateTime({ + autoCreate: true, + autoUpdate: true, + columnName: 'updated_at', + serializeAs: null, + }) + declare updatedAt: DateTime | null +} diff --git a/apps/backend/app/base_modules/Contact/routes.ts b/apps/backend/app/base_modules/Contact/routes.ts new file mode 100644 index 0000000..c7a754c --- /dev/null +++ b/apps/backend/app/base_modules/Contact/routes.ts @@ -0,0 +1,15 @@ +import router from '@adonisjs/core/services/router' +import { middleware } from '#start/kernel' + +const ContactsController = () => import('#base_modules/Contact/controller/contacts_controller') + +router + .group(() => { + router.get('/', [ContactsController, 'index']) + router.get('/:id', [ContactsController, 'show']) + router.post('/', [ContactsController, 'store']) + router.put('/:id', [ContactsController, 'update']) + router.delete('/:id', [ContactsController, 'destroy']) + }) + .prefix('/api/contacts') + .use([middleware.auth(), middleware.tenant()]) diff --git a/apps/backend/app/base_modules/Contact/validators/contact_create_validator.ts b/apps/backend/app/base_modules/Contact/validators/contact_create_validator.ts new file mode 100644 index 0000000..a24bd45 --- /dev/null +++ b/apps/backend/app/base_modules/Contact/validators/contact_create_validator.ts @@ -0,0 +1,9 @@ +import vine from '@vinejs/vine' + +export const createContactValidator = vine.compile( + vine.object({ + display_name: vine.string().trim().minLength(1), + email: vine.string().trim().email().optional(), + phone: vine.string().trim().optional(), + }) +) diff --git a/apps/backend/app/base_modules/Contact/validators/contact_update_validator.ts b/apps/backend/app/base_modules/Contact/validators/contact_update_validator.ts new file mode 100644 index 0000000..a7b8e3e --- /dev/null +++ b/apps/backend/app/base_modules/Contact/validators/contact_update_validator.ts @@ -0,0 +1,10 @@ +import vine from '@vinejs/vine' + +export const updateContactValidator = vine.compile( + vine.object({ + displayName: vine.string().trim().minLength(1).optional(), + email: vine.string().trim().email().optional(), + phone: vine.string().trim().optional(), + status: vine.enum(['active', 'inactive']).optional(), + }) +) diff --git a/apps/backend/app/base_modules/CustomField/controller/custom_fields_controller.ts b/apps/backend/app/base_modules/CustomField/controller/custom_fields_controller.ts new file mode 100644 index 0000000..745c3df --- /dev/null +++ b/apps/backend/app/base_modules/CustomField/controller/custom_fields_controller.ts @@ -0,0 +1,16 @@ +import type { HttpContext } from '@adonisjs/core/http' +import CustomField from '#base_modules/CustomField/model/custom_field' + +export default class CustomFieldsController { + /** + * @summary List custom fields in the current tenant + * @responseBody 200 - {"data":[]} + * @responseBody 401 - {"message":"Unauthorized"} + * @responseBody 400 - {"code":"E_TENANT_REQUIRED","message":"X-Tenant-Id header is required"} + * @responseBody 403 - {"code":"E_TENANT_ACCESS_DENIED","message":"You do not have access to this tenant"} + */ + public async index({ response, tenant }: HttpContext) { + const fields = await CustomField.query().where('tenant_id', tenant.id).orderBy('id', 'asc') + return response.ok({ data: fields }) + } +} diff --git a/apps/backend/app/base_modules/CustomField/model/custom_field.ts b/apps/backend/app/base_modules/CustomField/model/custom_field.ts new file mode 100644 index 0000000..01dbbff --- /dev/null +++ b/apps/backend/app/base_modules/CustomField/model/custom_field.ts @@ -0,0 +1,17 @@ +import { BaseModel, column } from '@adonisjs/lucid/orm' +import { DateTime } from 'luxon' + +export default class CustomField extends BaseModel { + @column({ isPrimary: true }) declare id: number + @column({ columnName: 'tenant_id', serializeAs: null }) declare tenantId: number + @column({ columnName: 'entity_type', serializeAs: 'entity_type' }) declare entityType: string + @column() declare name: string + @column() declare key: string + @column({ columnName: 'field_type', serializeAs: 'field_type' }) declare fieldType: string + @column() declare required: boolean + @column() declare options: Record | null + @column() declare status: string + @column.dateTime({ autoCreate: true, serializeAs: null }) declare createdAt: DateTime + @column.dateTime({ autoCreate: true, autoUpdate: true, serializeAs: null }) + declare updatedAt: DateTime +} diff --git a/apps/backend/app/base_modules/CustomField/routes.ts b/apps/backend/app/base_modules/CustomField/routes.ts new file mode 100644 index 0000000..85d54c2 --- /dev/null +++ b/apps/backend/app/base_modules/CustomField/routes.ts @@ -0,0 +1,12 @@ +import router from '@adonisjs/core/services/router' +import { middleware } from '#start/kernel' + +const CustomFieldsController = () => + import('#base_modules/CustomField/controller/custom_fields_controller') + +router + .group(() => { + router.get('/', [CustomFieldsController, 'index']) + }) + .prefix('/api/custom-fields') + .use([middleware.auth(), middleware.tenant()]) diff --git a/apps/backend/app/base_modules/Tenant/controller/tenant_controller.ts b/apps/backend/app/base_modules/Tenant/controller/tenant_controller.ts new file mode 100644 index 0000000..deeb1fb --- /dev/null +++ b/apps/backend/app/base_modules/Tenant/controller/tenant_controller.ts @@ -0,0 +1,13 @@ +import type { HttpContext } from '@adonisjs/core/http' + +export default class TenantController { + /** + * @summary Resolve current tenant context + * @responseBody 200 - {"tenant":{"id":42,"name":"Acme GmbH","slug":"acme-gmbh"}} + * @responseBody 400 - {"code":"E_TENANT_REQUIRED","message":"The X-Tenant-Id header is required."} + * @responseBody 403 - {"code":"E_TENANT_ACCESS_DENIED","message":"You do not have access to this tenant."} + */ + async current({ response, tenant }: HttpContext) { + return response.ok({ tenant }) + } +} diff --git a/apps/backend/app/base_modules/Tenant/model/tenant.ts b/apps/backend/app/base_modules/Tenant/model/tenant.ts new file mode 100644 index 0000000..4f35226 --- /dev/null +++ b/apps/backend/app/base_modules/Tenant/model/tenant.ts @@ -0,0 +1,31 @@ +import { BaseModel, column, manyToMany } from '@adonisjs/lucid/orm' +import type { ManyToMany } from '@adonisjs/lucid/types/relations' +import { DateTime } from 'luxon' +import Admin from '#base_modules/Admin/model/admin' + +export default class Tenant extends BaseModel { + @column({ isPrimary: true, columnName: 'id' }) + declare id: number + + @column({ columnName: 'name' }) + declare name: string + + @column({ columnName: 'slug' }) + declare slug: string + + @column({ columnName: 'status' }) + declare status: 'active' | 'inactive' + + @column.dateTime({ autoCreate: true, columnName: 'created_at' }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true, columnName: 'updated_at' }) + declare updatedAt: DateTime | null + + @manyToMany(() => Admin, { + pivotTable: 'admin_tenants', + pivotForeignKey: 'tenant_id', + pivotRelatedForeignKey: 'admin_id', + }) + declare admins: ManyToMany +} diff --git a/apps/backend/app/base_modules/Tenant/routes.ts b/apps/backend/app/base_modules/Tenant/routes.ts new file mode 100644 index 0000000..6b49e17 --- /dev/null +++ b/apps/backend/app/base_modules/Tenant/routes.ts @@ -0,0 +1,8 @@ +import router from '@adonisjs/core/services/router' +import { middleware } from '#start/kernel' + +const TenantController = () => import('#base_modules/Tenant/controller/tenant_controller') + +router + .get('/tenants/current', [TenantController, 'current']) + .use([middleware.auth(), middleware.tenant()]) diff --git a/apps/backend/app/base_modules/Tenant/services/tenant_access_service.ts b/apps/backend/app/base_modules/Tenant/services/tenant_access_service.ts new file mode 100644 index 0000000..718525d --- /dev/null +++ b/apps/backend/app/base_modules/Tenant/services/tenant_access_service.ts @@ -0,0 +1,15 @@ +import Tenant from '#base_modules/Tenant/model/tenant' + +export type ResolvedTenant = { id: number; name: string; slug: string } + +export default class TenantAccessService { + async resolveForAdmin(adminId: number, tenantId: number): Promise { + const tenant = await Tenant.query() + .where('id', tenantId) + .where('status', 'active') + .whereHas('admins', (admins) => admins.where('admins.id', adminId)) + .first() + + return tenant ? { id: tenant.id, name: tenant.name, slug: tenant.slug } : null + } +} diff --git a/apps/backend/app/middleware/resolve_tenant_middleware.ts b/apps/backend/app/middleware/resolve_tenant_middleware.ts new file mode 100644 index 0000000..5bda24c --- /dev/null +++ b/apps/backend/app/middleware/resolve_tenant_middleware.ts @@ -0,0 +1,43 @@ +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' +import TenantAccessService, { + type ResolvedTenant, +} from '#base_modules/Tenant/services/tenant_access_service' + +type TenantContext = ResolvedTenant + +export default class ResolveTenantMiddleware { + async handle(ctx: HttpContext, next: NextFn) { + const header = ctx.request.header('x-tenant-id') + if (!header) + return ctx.response.badRequest({ + code: 'E_TENANT_REQUIRED', + message: 'The X-Tenant-Id header is required.', + }) + + const tenantId = Number(header) + if (!Number.isSafeInteger(tenantId) || tenantId < 1) { + return ctx.response.badRequest({ + code: 'E_INVALID_TENANT_ID', + message: 'The X-Tenant-Id header must be a positive integer.', + }) + } + + const tenant = await new TenantAccessService().resolveForAdmin(ctx.auth.user!.id, tenantId) + + if (!tenant) + return ctx.response.forbidden({ + code: 'E_TENANT_ACCESS_DENIED', + message: 'You do not have access to this tenant.', + }) + + ctx.tenant = tenant + return next() + } +} + +declare module '@adonisjs/core/http' { + export interface HttpContext { + tenant: TenantContext + } +} diff --git a/apps/backend/app/services/module_generator/writers/backend_module_writer.ts b/apps/backend/app/services/module_generator/writers/backend_module_writer.ts index 51209be..7602c01 100644 --- a/apps/backend/app/services/module_generator/writers/backend_module_writer.ts +++ b/apps/backend/app/services/module_generator/writers/backend_module_writer.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' -import { modelToSchema } from '#services/swagger_model' +import { modelToExample } from '#services/swagger_model' import { ModuleContext } from '#services/module_generator/module_context' import { capitalize, @@ -139,7 +139,7 @@ export async function writeBackendModule(options: WriteBackendModuleOptions) { snakeCaseName: snake, name, snake, - swaggerModel: await modelToSchema(genModel), + swaggerExample: await modelToExample(genModel), } writeFileSync( @@ -150,7 +150,9 @@ export async function writeBackendModule(options: WriteBackendModuleOptions) { const apiSpec = renderTemplate(join(backendTplDir, 'tests', 'api.spec.hbs'), { snakeCaseName: snake, + name, createFields, + updateFields, }) writeFileSync(paths.testApi, apiSpec, 'utf8') diff --git a/apps/backend/app/services/swagger_model.ts b/apps/backend/app/services/swagger_model.ts index 229e72a..99c3d92 100755 --- a/apps/backend/app/services/swagger_model.ts +++ b/apps/backend/app/services/swagger_model.ts @@ -1,18 +1,46 @@ -const typeMap: Record = { +const typeMap: Record = { number: { type: 'integer', format: 'int64' }, string: { type: 'string' }, Date: { type: 'string', format: 'date-time' }, boolean: { type: 'boolean' }, } + +/** Legacy schema helper retained for callers that need a property map. */ export async function modelToSchema(model: any) { - const properties: Record = {} + const properties: Record = {} for (const [key, meta] of model.$columnsDefinitions.entries()) { const tsType = meta.meta?.designType?.name || 'string' - const mapped = typeMap[tsType] ?? { type: 'string' } properties[meta?.serializeAs || key] = { - ...mapped, + ...(typeMap[tsType] ?? { type: 'string' }), description: key.replace(/([A-Z])/g, ' $1').trim(), } } return JSON.stringify(properties) } + +/** + * Produces a deterministic, concrete JSON object for AutoSwagger response + * annotations. It intentionally omits Lucid columns hidden from serialization. + */ +export async function modelToExample(model: any) { + const example: Record = {} + + for (const [key, meta] of model.$columnsDefinitions.entries()) { + if (meta.serializeAs === null) continue + + const serializedKey = meta.serializeAs || key + const type = meta.meta?.designType?.name || 'string' + example[serializedKey] = sampleValue(serializedKey, type) + } + + return JSON.stringify(example) +} + +function sampleValue(key: string, type: string): string | number | boolean { + if (type === 'number') return key === 'id' || key.endsWith('_id') ? 42 : 100 + if (type === 'boolean') return true + if (type === 'Date') return '2025-01-15T10:30:00.000Z' + if (key.includes('email')) return 'user@example.test' + if (key.includes('url')) return 'https://example.test/resource' + return `Example ${key.replace(/_/g, ' ')}` +} diff --git a/apps/backend/app/templates/backend/api/controller.hbs b/apps/backend/app/templates/backend/api/controller.hbs index b59df49..56607f1 100755 --- a/apps/backend/app/templates/backend/api/controller.hbs +++ b/apps/backend/app/templates/backend/api/controller.hbs @@ -1,102 +1,91 @@ import type { HttpContext } from '@adonisjs/core/http' import {{pascalCaseName}} from '#modules/{{capName}}/model/{{snakeCaseName}}' +import {{pascalCaseName}}Policy from '#modules/{{capName}}/policy/{{snakeCaseName}}_policy' import { create{{pascalCaseName}}ApiValidator } from '#modules/{{capName}}/api/validators/{{snakeCaseName}}_create_api_validator' import { update{{pascalCaseName}}ApiValidator } from '#modules/{{capName}}/api/validators/{{snakeCaseName}}_update_api_validator' export default class {{pascalCaseName}}ApiController { - /** - * GET /api/{{name}} - */ /** * @index * @operationId get{{pascalCaseName}} - * @description Returns array of records - * @responseBody 200 - [{{{swaggerModel}}}] - * @responseHeader 200 - X-pages - A description of the header - @example(test) + * @description Returns records visible to an authorized user. + * @responseBody 200 - [{{{swaggerExample}}}] + * @responseBody 401 - { "message": "Unauthorized" } + * @responseBody 403 - { "message": "Forbidden" } */ - async index({ response }: HttpContext) { - const result = await {{pascalCaseName}}.all() - return response.ok(result) + async index({ response, bouncer }: HttpContext) { + await bouncer.with({{pascalCaseName}}Policy).authorize('view{{pascalCaseName}}List') + return response.ok(await {{pascalCaseName}}.all()) } - /** - * GET /api/{{name}}/:id - */ - /** * @show * @operationId show{{pascalCaseName}} - * @description Returns record - * @responseBody 200 - {{{swaggerModel}}} + * @description Returns one record visible to an authorized user. + * @paramPath id - Record ID - @required + * @responseBody 200 - {{{swaggerExample}}} + * @responseBody 401 - { "message": "Unauthorized" } + * @responseBody 403 - { "message": "Forbidden" } * @responseBody 404 - { "message": "Not found" } - * @responseHeader 200 - X-pages - A description of the header - @example(test) */ - async show({ params, response }: HttpContext) { + async show({ params, response, bouncer }: HttpContext) { + await bouncer.with({{pascalCaseName}}Policy).authorize('view{{pascalCaseName}}') const record = await {{pascalCaseName}}.find(params.id) if (!record) return response.notFound({ message: 'Not found' }) return response.ok(record) } - /** - * POST /api/{{name}} - */ - /** * @store * @operationId store{{pascalCaseName}} - * @description Creates and Returns record + * @description Creates and returns a record. * @requestBody - * @responseBody 200 - {{{swaggerModel}}} - * @responseBody 404 - { "message": "Not found" } - * @responseBody 422 - - Returns an error if the request body fails validation. - * @responseHeader 200 - X-pages - A description of the header - @example(test) + * @responseBody 201 - {{{swaggerExample}}} + * @responseBody 401 - { "message": "Unauthorized" } + * @responseBody 403 - { "message": "Forbidden" } + * @responseBody 422 - { "errors": [{ "message": "Validation failed", "rule": "required", "field": "name" }] } */ - async store({ request, response }: HttpContext) { + async store({ request, response, bouncer }: HttpContext) { + await bouncer.with({{pascalCaseName}}Policy).authorize('create{{pascalCaseName}}') const payload = await create{{pascalCaseName}}ApiValidator.validate(request.all()) - const record = await {{pascalCaseName}}.create(payload) - return response.created(record) + return response.created(await {{pascalCaseName}}.create(payload)) } - /** - * PUT /api/{{name}}/:id - */ - /** * @update * @operationId update{{pascalCaseName}} - * @description Updates and Returns record + * @description Updates and returns a record. + * @paramPath id - Record ID - @required * @requestBody - * @responseBody 200 - {{{swaggerModel}}} + * @responseBody 200 - {{{swaggerExample}}} + * @responseBody 401 - { "message": "Unauthorized" } + * @responseBody 403 - { "message": "Forbidden" } * @responseBody 404 - { "message": "Not found" } - * @responseBody 422 - - Returns an error if the request body fails validation. - * @responseHeader 200 - X-pages - A description of the header - @example(test) + * @responseBody 422 - { "errors": [{ "message": "Validation failed", "rule": "required", "field": "name" }] } */ - async update({ params, request, response }: HttpContext) { + async update({ params, request, response, bouncer }: HttpContext) { + await bouncer.with({{pascalCaseName}}Policy).authorize('update{{pascalCaseName}}') const record = await {{pascalCaseName}}.find(params.id) if (!record) return response.notFound({ message: 'Not found' }) - - const payload = await update{{pascalCaseName}}ApiValidator.validate(request.all()) - record.merge(payload) + record.merge(await update{{pascalCaseName}}ApiValidator.validate(request.all())) await record.save() return response.ok(record) } - /** - * DELETE /api/{{name}}/:id - */ - /** * @destroy * @operationId destroy{{pascalCaseName}} - * @description Destroys record - * @paramPath id - The ID of the source - @required + * @description Deletes a record. + * @paramPath id - Record ID - @required * @responseBody 200 - { "message": "Deleted" } - * @responseHeader 200 - X-pages - A description of the header - @example(test) + * @responseBody 401 - { "message": "Unauthorized" } + * @responseBody 403 - { "message": "Forbidden" } + * @responseBody 404 - { "message": "Not found" } */ - async destroy({ params, response }: HttpContext) { + async destroy({ params, response, bouncer }: HttpContext) { + await bouncer.with({{pascalCaseName}}Policy).authorize('delete{{pascalCaseName}}') const record = await {{pascalCaseName}}.find(params.id) if (!record) return response.notFound({ message: 'Not found' }) - await record.delete() return response.ok({ message: 'Deleted' }) } diff --git a/apps/backend/app/templates/backend/api/routes.hbs b/apps/backend/app/templates/backend/api/routes.hbs index 57dd1a3..53eb237 100755 --- a/apps/backend/app/templates/backend/api/routes.hbs +++ b/apps/backend/app/templates/backend/api/routes.hbs @@ -1,4 +1,6 @@ import router from '@adonisjs/core/services/router' +import { middleware } from '#start/kernel' + const {{pascalCaseName}}ApiController = () => import('#modules/{{moduleName}}/api/controllers/{{snakeCaseName}}_api_controller') router @@ -10,3 +12,4 @@ router router.delete('/:id', [{{pascalCaseName}}ApiController, 'destroy']) }) .prefix('/api/{{snakeCaseName}}') + .use([middleware.auth()]) diff --git a/apps/backend/app/templates/backend/tests/api.spec.hbs b/apps/backend/app/templates/backend/tests/api.spec.hbs index 8f1d66b..815ec59 100644 --- a/apps/backend/app/templates/backend/tests/api.spec.hbs +++ b/apps/backend/app/templates/backend/tests/api.spec.hbs @@ -1,24 +1,104 @@ import { test } from '@japa/runner' -import db from '@adonisjs/lucid/services/db' +import testUtils from '@adonisjs/core/services/test_utils' +import { createAdminWithToken } from '#tests/utils/auth' + +const ALL_PERMISSIONS = ['{{name}}s.view', '{{name}}s.create', '{{name}}s.update', '{{name}}s.delete'] + +const ROUTES = [ + ['get', '/api/{{snakeCaseName}}'], + ['get', '/api/{{snakeCaseName}}/999999'], + ['post', '/api/{{snakeCaseName}}'], + ['put', '/api/{{snakeCaseName}}/999999'], + ['delete', '/api/{{snakeCaseName}}/999999'], +] as const test.group('API /api/{{snakeCaseName}}', (group) => { - group.each.setup(async () => { - await db.beginGlobalTransaction() - return () => db.rollbackGlobalTransaction() + group.each.setup(() => testUtils.db().wrapInGlobalTransaction()) + + test('rejects unauthenticated requests', async ({ client }) => { + for (const [method, url] of ROUTES) { + const response = await client[method](url) + response.assertStatus(401) + } + }) + + test('rejects a token without the required permission', async ({ client }) => { + const { token } = await createAdminWithToken([]) + for (const [method, url] of ROUTES) { + const response = await client[method](url).bearerToken(token) + response.assertStatus(403) + } }) test('GET /api/{{snakeCaseName}} -> list', async ({ client }) => { - const res = await client.get('/api/{{snakeCaseName}}') - res.assertStatus(200) + const { token } = await createAdminWithToken(ALL_PERMISSIONS) + const response = await client.get('/api/{{snakeCaseName}}').bearerToken(token) + response.assertStatus(200) }) - test('POST /api/{{snakeCaseName}} -> create', async ({ client }) => { + test('POST /api/{{snakeCaseName}} -> create', async ({ assert, client }) => { + const { token } = await createAdminWithToken(ALL_PERMISSIONS) const payload = { {{#each createFields}} {{column_name}}: {{{sampleValue}}}{{#unless @last}},{{/unless}} {{/each}} } - const res = await client.post('/api/{{snakeCaseName}}').json(payload) - res.assertStatus(201) + const response = await client.post('/api/{{snakeCaseName}}').bearerToken(token).json(payload) + response.assertStatus(201) + assert.exists(response.body().id) + }) + + test('GET /api/{{snakeCaseName}}/:id -> show and 404', async ({ assert, client }) => { + const { token } = await createAdminWithToken(ALL_PERMISSIONS) + const created = await client.post('/api/{{snakeCaseName}}').bearerToken(token).json({ + {{#each createFields}} + {{column_name}}: {{{sampleValue}}}{{#unless @last}},{{/unless}} + {{/each}} + }) + created.assertStatus(201) + const id = created.body().id + + const response = await client.get(`/api/{{snakeCaseName}}/${id}`).bearerToken(token) + response.assertStatus(200) + assert.equal(response.body().id, id) + + const missing = await client.get('/api/{{snakeCaseName}}/999999').bearerToken(token) + missing.assertStatus(404) + }) + + test('PUT /api/{{snakeCaseName}}/:id -> update and 404', async ({ client }) => { + const { token } = await createAdminWithToken(ALL_PERMISSIONS) + const created = await client.post('/api/{{snakeCaseName}}').bearerToken(token).json({ + {{#each createFields}} + {{column_name}}: {{{sampleValue}}}{{#unless @last}},{{/unless}} + {{/each}} + }) + created.assertStatus(201) + + const updated = await client.put(`/api/{{snakeCaseName}}/${created.body().id}`).bearerToken(token).json({ + {{#each updateFields}} + {{column_name}}: {{{sampleValue}}}{{#unless @last}},{{/unless}} + {{/each}} + }) + updated.assertStatus(200) + + const missing = await client.put('/api/{{snakeCaseName}}/999999').bearerToken(token).json({}) + missing.assertStatus(404) + }) + + test('DELETE /api/{{snakeCaseName}}/:id -> destroy and 404', async ({ client }) => { + const { token } = await createAdminWithToken(ALL_PERMISSIONS) + const created = await client.post('/api/{{snakeCaseName}}').bearerToken(token).json({ + {{#each createFields}} + {{column_name}}: {{{sampleValue}}}{{#unless @last}},{{/unless}} + {{/each}} + }) + created.assertStatus(201) + + const deleted = await client.delete(`/api/{{snakeCaseName}}/${created.body().id}`).bearerToken(token) + deleted.assertStatus(200) + + const missing = await client.delete('/api/{{snakeCaseName}}/999999').bearerToken(token) + missing.assertStatus(404) }) }) diff --git a/apps/backend/database/migrations/1784635200003_create_tenants_table.ts b/apps/backend/database/migrations/1784635200003_create_tenants_table.ts new file mode 100644 index 0000000..e3d0ac8 --- /dev/null +++ b/apps/backend/database/migrations/1784635200003_create_tenants_table.ts @@ -0,0 +1,20 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'tenants' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id') + table.string('name').notNullable() + table.string('slug').notNullable().unique() + table.string('status').notNullable().defaultTo('active') + table.timestamp('created_at').notNullable().defaultTo(this.now()) + table.timestamp('updated_at').nullable() + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/apps/backend/database/migrations/1784635200004_create_admin_tenants_table.ts b/apps/backend/database/migrations/1784635200004_create_admin_tenants_table.ts new file mode 100644 index 0000000..aa3329c --- /dev/null +++ b/apps/backend/database/migrations/1784635200004_create_admin_tenants_table.ts @@ -0,0 +1,32 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'admin_tenants' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id') + table + .integer('admin_id') + .unsigned() + .notNullable() + .references('id') + .inTable('admins') + .onDelete('CASCADE') + table + .integer('tenant_id') + .unsigned() + .notNullable() + .references('id') + .inTable('tenants') + .onDelete('CASCADE') + table.timestamp('created_at').notNullable().defaultTo(this.now()) + table.timestamp('updated_at').nullable() + table.unique(['admin_id', 'tenant_id']) + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/apps/backend/database/migrations/1784635200005_create_contacts_table.ts b/apps/backend/database/migrations/1784635200005_create_contacts_table.ts new file mode 100644 index 0000000..f676c72 --- /dev/null +++ b/apps/backend/database/migrations/1784635200005_create_contacts_table.ts @@ -0,0 +1,29 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'contacts' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id').notNullable() + table + .integer('tenant_id') + .unsigned() + .notNullable() + .references('id') + .inTable('tenants') + .onDelete('CASCADE') + table.string('display_name').notNullable() + table.string('email').nullable() + table.string('phone').nullable() + table.string('status').notNullable().defaultTo('active') + table.timestamp('created_at').notNullable().defaultTo(this.now()) + table.timestamp('updated_at').nullable() + table.index(['tenant_id', 'display_name']) + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/apps/backend/database/migrations/1784635200006_create_custom_fields_table.ts b/apps/backend/database/migrations/1784635200006_create_custom_fields_table.ts new file mode 100644 index 0000000..7ad7d45 --- /dev/null +++ b/apps/backend/database/migrations/1784635200006_create_custom_fields_table.ts @@ -0,0 +1,33 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'custom_fields' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id').notNullable() + table + .integer('tenant_id') + .unsigned() + .notNullable() + .references('id') + .inTable('tenants') + .onDelete('CASCADE') + table.string('entity_type').notNullable() + table.string('name').notNullable() + table.string('key').notNullable() + table.string('field_type').notNullable() + table.boolean('required').notNullable().defaultTo(false) + table.jsonb('options').nullable() + table.string('status').notNullable().defaultTo('active') + table.timestamp('created_at').notNullable().defaultTo(this.now()) + table.timestamp('updated_at').notNullable().defaultTo(this.now()) + table.unique(['tenant_id', 'entity_type', 'key']) + table.index(['tenant_id', 'entity_type']) + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/apps/backend/start/kernel.ts b/apps/backend/start/kernel.ts index 93c77a7..d34aed5 100755 --- a/apps/backend/start/kernel.ts +++ b/apps/backend/start/kernel.ts @@ -44,4 +44,5 @@ router.use([ */ export const middleware = router.named({ auth: () => import('#middleware/auth_middleware'), + tenant: () => import('#middleware/resolve_tenant_middleware'), }) diff --git a/apps/backend/tests/functional/contacts/contacts_api.spec.ts b/apps/backend/tests/functional/contacts/contacts_api.spec.ts new file mode 100644 index 0000000..d0714ee --- /dev/null +++ b/apps/backend/tests/functional/contacts/contacts_api.spec.ts @@ -0,0 +1,197 @@ +import { test } from '@japa/runner' +import testUtils from '@adonisjs/core/services/test_utils' +import Contact from '#base_modules/Contact/model/contact' +import Tenant from '#base_modules/Tenant/model/tenant' +import { createAdminWithToken } from '#tests/utils/auth' + +test.group('Contacts API', (group) => { + group.each.setup(() => testUtils.db().wrapInGlobalTransaction()) + test('rejects an unauthenticated list request', async ({ client }) => { + const response = await client.get('/api/contacts').header('X-Tenant-Id', '1') + + response.assertStatus(401) + }) + + test('lists only contacts owned by the selected tenant', async ({ assert, client }) => { + const { admin, token } = await createAdminWithToken([]) + const tenant = await Tenant.create({ name: 'Acme GmbH', slug: 'acme-gmbh', status: 'active' }) + await tenant.related('admins').attach([admin.id]) + await Contact.create({ + tenantId: tenant.id, + displayName: 'Ada Lovelace', + email: 'ada@example.test', + phone: null, + status: 'active', + }) + + const response = await client + .get('/api/contacts') + .header('X-Tenant-Id', String(tenant.id)) + .bearerToken(token) + + response.assertStatus(200) + assert.deepEqual(response.body(), { + data: [ + { + id: 1, + display_name: 'Ada Lovelace', + email: 'ada@example.test', + phone: null, + status: 'active', + }, + ], + }) + }) + + test('creates a contact within the resolved tenant', async ({ assert, client }) => { + const { admin, token } = await createAdminWithToken([]) + const tenant = await Tenant.create({ + name: 'Create GmbH', + slug: 'create-gmbh', + status: 'active', + }) + await tenant.related('admins').attach([admin.id]) + + const response = await client + .post('/api/contacts') + .header('X-Tenant-Id', String(tenant.id)) + .bearerToken(token) + .json({ display_name: 'Grace Hopper', email: 'grace@example.test', phone: '+1-555-0100' }) + + response.assertStatus(201) + const created = response.body().data + assert.deepInclude(created, { + display_name: 'Grace Hopper', + email: 'grace@example.test', + phone: '+1-555-0100', + status: 'active', + }) + const persistedContact = await Contact.find(created.id) + assert.equal(persistedContact?.tenantId, tenant.id) + }) + + test('rejects a contact without a display name', async ({ assert, client }) => { + const { admin, token } = await createAdminWithToken([]) + const tenant = await Tenant.create({ + name: 'Validation GmbH', + slug: 'validation-gmbh', + status: 'active', + }) + await tenant.related('admins').attach([admin.id]) + + const response = await client + .post('/api/contacts') + .header('X-Tenant-Id', String(tenant.id)) + .bearerToken(token) + .json({ email: 'missing-name@example.test' }) + + response.assertStatus(422) + assert.equal(response.body().errors[0].field, 'display_name') + }) + + test('shows a contact only inside the resolved tenant', async ({ assert, client }) => { + const { admin, token } = await createAdminWithToken([]) + const tenant = await Tenant.create({ name: 'Show GmbH', slug: 'show-gmbh', status: 'active' }) + await tenant.related('admins').attach([admin.id]) + const contact = await Contact.create({ + tenantId: tenant.id, + displayName: 'Katherine Johnson', + email: 'katherine@example.test', + phone: null, + status: 'active', + }) + + const response = await client + .get(`/api/contacts/${contact.id}`) + .header('X-Tenant-Id', String(tenant.id)) + .bearerToken(token) + + response.assertStatus(200) + assert.deepEqual(response.body(), { + data: { + id: contact.id, + display_name: 'Katherine Johnson', + email: 'katherine@example.test', + phone: null, + status: 'active', + }, + }) + + const otherTenant = await Tenant.create({ + name: 'Other GmbH', + slug: 'other-gmbh', + status: 'active', + }) + const foreignContact = await Contact.create({ + tenantId: otherTenant.id, + displayName: 'Tenant Two Contact', + email: null, + phone: null, + status: 'active', + }) + const foreignResponse = await client + .get(`/api/contacts/${foreignContact.id}`) + .header('X-Tenant-Id', String(tenant.id)) + .bearerToken(token) + foreignResponse.assertStatus(404) + }) + + test('updates a contact inside the resolved tenant', async ({ assert, client }) => { + const { admin, token } = await createAdminWithToken([]) + const tenant = await Tenant.create({ + name: 'Update GmbH', + slug: 'update-gmbh', + status: 'active', + }) + await tenant.related('admins').attach([admin.id]) + const contact = await Contact.create({ + tenantId: tenant.id, + displayName: 'Original Name', + email: null, + phone: null, + status: 'active', + }) + + const response = await client + .put(`/api/contacts/${contact.id}`) + .header('X-Tenant-Id', String(tenant.id)) + .bearerToken(token) + .json({ display_name: 'Updated Name', email: 'updated@example.test' }) + + response.assertStatus(200) + assert.deepEqual(response.body(), { + data: { + id: contact.id, + display_name: 'Updated Name', + email: 'updated@example.test', + phone: null, + status: 'active', + }, + }) + }) + + test('deletes a contact inside the resolved tenant', async ({ assert, client }) => { + const { admin, token } = await createAdminWithToken([]) + const tenant = await Tenant.create({ + name: 'Delete GmbH', + slug: 'delete-gmbh', + status: 'active', + }) + await tenant.related('admins').attach([admin.id]) + const contact = await Contact.create({ + tenantId: tenant.id, + displayName: 'Delete Me', + email: null, + phone: null, + status: 'active', + }) + + const response = await client + .delete(`/api/contacts/${contact.id}`) + .header('X-Tenant-Id', String(tenant.id)) + .bearerToken(token) + + response.assertStatus(200) + assert.equal(await Contact.find(contact.id), null) + }) +}) diff --git a/apps/backend/tests/functional/custom_fields/custom_fields_api.spec.ts b/apps/backend/tests/functional/custom_fields/custom_fields_api.spec.ts new file mode 100644 index 0000000..1f31b83 --- /dev/null +++ b/apps/backend/tests/functional/custom_fields/custom_fields_api.spec.ts @@ -0,0 +1,43 @@ +import { test } from '@japa/runner' +import testUtils from '@adonisjs/core/services/test_utils' +import CustomField from '#base_modules/CustomField/model/custom_field' +import Tenant from '#base_modules/Tenant/model/tenant' +import { createAdminWithToken } from '#tests/utils/auth' + +test.group('Custom fields API', (group) => { + group.each.setup(() => testUtils.db().withGlobalTransaction()) + + test('rejects an unauthenticated list request', async ({ client }) => { + const response = await client.get('/api/custom-fields') + + response.assertStatus(401) + }) + + test('lists fields owned by the resolved tenant', async ({ assert, client }) => { + const { admin, token } = await createAdminWithToken([]) + const tenant = await Tenant.create({ + name: 'Metadata GmbH', + slug: 'metadata-gmbh', + status: 'active', + }) + await tenant.related('admins').attach([admin.id]) + await CustomField.create({ + tenantId: tenant.id, + entityType: 'contact', + name: 'Customer tier', + key: 'customer_tier', + fieldType: 'text', + required: false, + options: null, + status: 'active', + }) + + const response = await client + .get('/api/custom-fields') + .header('X-Tenant-Id', String(tenant.id)) + .bearerToken(token) + + response.assertStatus(200) + assert.equal(response.body().data[0].key, 'customer_tier') + }) +}) diff --git a/apps/backend/tests/functional/tenant/tenant_context.spec.ts b/apps/backend/tests/functional/tenant/tenant_context.spec.ts new file mode 100644 index 0000000..6e68112 --- /dev/null +++ b/apps/backend/tests/functional/tenant/tenant_context.spec.ts @@ -0,0 +1,70 @@ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' +import testUtils from '@adonisjs/core/services/test_utils' +import Tenant from '#base_modules/Tenant/model/tenant' +import { createAdminWithToken } from '#tests/utils/auth' + +test.group('Tenant context', (group) => { + group.each.setup(() => testUtils.db().wrapInGlobalTransaction()) + + test('rejects an authenticated tenant-scoped request without an X-Tenant-Id header', async ({ + assert, + client, + }) => { + const { token } = await createAdminWithToken([]) + + const response = await client.get('/tenants/current').bearerToken(token) + + response.assertStatus(400) + assert.equal(response.body().code, 'E_TENANT_REQUIRED') + }) + + test('resolves a tenant only for an authenticated member', async ({ assert, client }) => { + const { admin, token } = await createAdminWithToken([]) + const tenant = await Tenant.create({ name: 'Acme GmbH', slug: 'acme-gmbh', status: 'active' }) + await tenant.related('admins').attach([admin.id]) + + const response = await client + .get('/tenants/current') + .header('X-Tenant-Id', String(tenant.id)) + .bearerToken(token) + + response.assertStatus(200) + assert.deepEqual(response.body(), { + tenant: { id: tenant.id, name: tenant.name, slug: tenant.slug }, + }) + }) + + test('rejects a tenant selected by an admin who is not a member', async ({ assert, client }) => { + const { token } = await createAdminWithToken([]) + const [tenant] = await db + .table('tenants') + .insert({ name: 'Other GmbH', slug: 'other-gmbh', status: 'active' }) + .returning(['id']) + + const response = await client + .get('/tenants/current') + .header('X-Tenant-Id', String(tenant.id)) + .bearerToken(token) + + response.assertStatus(403) + assert.equal(response.body().code, 'E_TENANT_ACCESS_DENIED') + }) + + test('rejects membership in an inactive tenant', async ({ assert, client }) => { + const { admin, token } = await createAdminWithToken([]) + const [tenant] = await db + .table('tenants') + .insert({ name: 'Inactive GmbH', slug: 'inactive-gmbh', status: 'inactive' }) + .returning(['id']) + await db.table('admin_tenants').insert({ admin_id: admin.id, tenant_id: tenant.id }) + + const response = await client + .get('/tenants/current') + .header('X-Tenant-Id', String(tenant.id)) + .bearerToken(token) + + response.assertStatus(403) + assert.equal(response.body().code, 'E_TENANT_ACCESS_DENIED') + }) +}) diff --git a/apps/backend/tests/unit/module_generator/erp_module_templates.spec.ts b/apps/backend/tests/unit/module_generator/erp_module_templates.spec.ts new file mode 100644 index 0000000..2e5372f --- /dev/null +++ b/apps/backend/tests/unit/module_generator/erp_module_templates.spec.ts @@ -0,0 +1,71 @@ +import { test } from '@japa/runner' +import { join } from 'node:path' +import { renderTemplate } from '#services/module_generator/writers/template_renderer' + +const TEMPLATE_DIR = join(process.cwd(), 'app', 'templates', 'backend') +const API_TEMPLATE_DIR = join(TEMPLATE_DIR, 'api') + +const context = { + name: 'widget', + capName: 'Widget', + moduleName: 'Widget', + modelName: 'Widgets', + pascalCaseName: 'Widget', + snakeCaseName: 'widget', + snake: 'widget', + swaggerExample: '{ "id": 42, "name": "Example widget" }', + createFields: [{ column_name: 'name', sampleValue: "'Example widget'" }], + updateFields: [{ column_name: 'name', sampleValue: "'Updated widget'" }], +} + +test.group('ERP module generator templates', () => { + test('generates authenticated, permission-authorized API CRUD routes', ({ assert }) => { + const routes = renderTemplate(join(API_TEMPLATE_DIR, 'routes.hbs'), context) + const controller = renderTemplate(join(API_TEMPLATE_DIR, 'controller.hbs'), context) + + assert.include(routes, "import { middleware } from '#start/kernel'") + assert.include(routes, '.use([middleware.auth()])') + assert.include(controller, "import WidgetPolicy from '#modules/Widget/policy/widget_policy'") + assert.include(controller, "authorize('viewWidgetList')") + assert.include(controller, "authorize('viewWidget')") + assert.include(controller, "authorize('createWidget')") + assert.include(controller, "authorize('updateWidget')") + assert.include(controller, "authorize('deleteWidget')") + }) + + test('generates concrete AutoSwagger examples for every API response status', ({ assert }) => { + const controller = renderTemplate(join(API_TEMPLATE_DIR, 'controller.hbs'), context) + + assert.include(controller, '@responseBody 200 - [{ "id": 42, "name": "Example widget" }]') + assert.include(controller, '@responseBody 201 - { "id": 42, "name": "Example widget" }') + assert.include(controller, '@responseBody 401 - { "message": "Unauthorized" }') + assert.include(controller, '@responseBody 403 - { "message": "Forbidden" }') + assert.include( + controller, + '@responseBody 422 - { "errors": [{ "message": "Validation failed", "rule": "required", "field": "name" }] }' + ) + assert.notInclude(controller, '') + }) + + test('generates API specs covering every CRUD endpoint plus auth and permission boundaries', ({ + assert, + }) => { + const spec = renderTemplate(join(TEMPLATE_DIR, 'tests', 'api.spec.hbs'), context) + + for (const methodAndPath of [ + 'GET /api/widget -> list', + 'POST /api/widget -> create', + 'GET /api/widget/:id -> show', + 'PUT /api/widget/:id -> update', + 'DELETE /api/widget/:id -> destroy', + ]) { + assert.include(spec, methodAndPath) + } + + assert.include(spec, 'rejects unauthenticated requests') + assert.include(spec, 'rejects a token without the required permission') + assert.include(spec, 'assertStatus(401)') + assert.include(spec, 'assertStatus(403)') + assert.include(spec, 'assertStatus(404)') + }) +}) diff --git a/apps/backend/tests/unit/tenant/tenant_access_service.spec.ts b/apps/backend/tests/unit/tenant/tenant_access_service.spec.ts new file mode 100644 index 0000000..c780913 --- /dev/null +++ b/apps/backend/tests/unit/tenant/tenant_access_service.spec.ts @@ -0,0 +1,23 @@ +import { test } from '@japa/runner' +import testUtils from '@adonisjs/core/services/test_utils' +import Tenant from '#base_modules/Tenant/model/tenant' +import TenantAccessService from '#base_modules/Tenant/services/tenant_access_service' +import { createAdminWithToken } from '#tests/utils/auth' + +test.group('TenantAccessService', (group) => { + group.each.setup(() => testUtils.db().wrapInGlobalTransaction()) + + test('resolves an active tenant for a member admin', async ({ assert }) => { + const { admin } = await createAdminWithToken([]) + const tenant = await Tenant.create({ + name: 'Service GmbH', + slug: 'service-gmbh', + status: 'active', + }) + await tenant.related('admins').attach([admin.id]) + + const resolved = await new TenantAccessService().resolveForAdmin(admin.id, tenant.id) + + assert.deepEqual(resolved, { id: tenant.id, name: tenant.name, slug: tenant.slug }) + }) +}) diff --git a/docker-compose.yml b/docker-compose.yml index 754e1b0..cd6b975 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: dockerfile: apps/backend/Dockerfile env_file: - ./apps/backend/.env - container_name: crux_backend_adonis + container_name: zfg-backend-adonis ports: - "3333:3333" depends_on: @@ -46,7 +46,7 @@ services: postgres: image: postgres:17 - container_name: crux_postgres + container_name: zfg-postgres ports: - "5432:5432" environment: @@ -58,13 +58,13 @@ services: redis: image: redis:8.0-M03 - container_name: crux_redis + container_name: zfg-redis ports: - "6379:6379" minio: image: minio/minio:latest - container_name: crux_minio + container_name: zfg-minio ports: - "9000:9000" - "9001:9001" @@ -77,7 +77,7 @@ services: minio_create_bucket: image: minio/mc:latest - container_name: crux_minio_create_bucket + container_name: zfg-minio-create-bucket depends_on: - minio entrypoint: > @@ -89,7 +89,7 @@ services: pgadmin: image: dpage/pgadmin4 - container_name: crux_pgadmin + container_name: zfg-pgadmin ports: - "5050:80" environment: @@ -106,7 +106,7 @@ services: dockerfile: apps/admin/Dockerfile env_file: - ./apps/admin/.env - container_name: crux_admin_react + container_name: zfg-admin-react working_dir: /app/apps/admin ports: - "5173:5173" @@ -123,7 +123,7 @@ services: dockerfile: apps/web/Dockerfile env_file: - ./apps/web/.env - container_name: crux_web_next + container_name: zfg-web-next working_dir: /app/apps/web ports: - "3000:3000" diff --git a/package-lock.json b/package-lock.json index c3f4b70..c202fbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3634,7 +3634,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -3651,7 +3650,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -3668,7 +3666,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -3685,7 +3682,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -3702,7 +3698,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -3719,7 +3714,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -3736,7 +3730,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -3753,7 +3746,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -3770,7 +3762,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -3787,7 +3778,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -3804,7 +3794,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -3821,7 +3810,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -3838,7 +3826,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -3855,7 +3842,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -3872,7 +3858,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -3889,7 +3874,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -3906,7 +3890,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -3923,7 +3906,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -3940,7 +3922,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -3957,7 +3938,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -3974,7 +3954,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -3991,7 +3970,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -4008,7 +3986,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -4025,7 +4002,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -4042,7 +4018,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -4059,7 +4034,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -4965,7 +4939,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/@japa/assert/-/assert-4.2.0.tgz", "integrity": "sha512-Krgrcee01BN1StlVwK5JQP6LL5t3DE3uFNbfFoDTfW7kQuHB0xh6yfaV0hrgcoiEjsqmm2OOsVWeju9aXK4vIA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@poppinss/macroable": "^1.1.0", @@ -9628,7 +9602,7 @@ "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/deep-eql": "*", @@ -9646,7 +9620,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/estree": { @@ -10321,7 +10295,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -10338,7 +10311,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -10355,7 +10327,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -10372,7 +10343,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -10389,7 +10359,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -10406,7 +10375,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -10423,7 +10391,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -10440,7 +10407,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -10457,7 +10423,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -10474,7 +10439,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -10491,7 +10455,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -10508,7 +10471,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -10525,7 +10487,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -10542,7 +10503,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -10559,7 +10519,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -10576,7 +10535,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -10593,7 +10551,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -10610,7 +10567,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -10627,7 +10583,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -10644,7 +10599,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -11673,7 +11627,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -12083,7 +12037,7 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" diff --git a/package.json b/package.json index 8753a0a..df316d6 100755 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "test:admin": "npm run test --workspace @crux-kit/admin-app", "test:backend": "npm run test --workspace @crux-kit/backend", "test:web": "npm run test --workspace @crux-kit/web-app", + "test:backend:compose": "docker compose exec -T -e DB_DATABASE=adonis_test backend npm test --", "version:patch": "npm version patch --no-git-tag-version && npm version patch --workspaces --no-git-tag-version", "version:minor": "npm version minor --no-git-tag-version && npm version minor --workspaces --no-git-tag-version", "version:major": "npm version major --no-git-tag-version && npm version major --workspaces --no-git-tag-version",