update
This commit is contained in:
@@ -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/<Module>` and `apps/admin/src/modules/<Module>` 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 <spec>`
|
||||
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/<timestamp>_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/<timestamp>_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/<timestamp>_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.
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof Role>
|
||||
|
||||
@manyToMany(() => Tenant, {
|
||||
pivotTable: 'admin_tenants',
|
||||
pivotForeignKey: 'admin_id',
|
||||
pivotRelatedForeignKey: 'tenant_id',
|
||||
})
|
||||
declare tenants: ManyToMany<typeof Tenant>
|
||||
|
||||
declare permissions: Permission[]
|
||||
|
||||
static accessTokens = DbAccessTokensProvider.forModel(Admins)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()])
|
||||
@@ -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(),
|
||||
})
|
||||
)
|
||||
@@ -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(),
|
||||
})
|
||||
)
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> | 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
|
||||
}
|
||||
@@ -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()])
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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<typeof Admin>
|
||||
}
|
||||
@@ -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()])
|
||||
@@ -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<ResolvedTenant | null> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -1,18 +1,46 @@
|
||||
const typeMap: Record<string, any> = {
|
||||
const typeMap: Record<string, { type: string; format?: string }> = {
|
||||
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<string, any> = {}
|
||||
const properties: Record<string, unknown> = {}
|
||||
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<string, string | number | boolean> = {}
|
||||
|
||||
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, ' ')}`
|
||||
}
|
||||
|
||||
@@ -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 <create{{pascalCaseName}}ApiValidator>
|
||||
* @responseBody 200 - {{{swaggerModel}}}
|
||||
* @responseBody 404 - { "message": "Not found" }
|
||||
* @responseBody 422 - <Error422> - 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 <update{{pascalCaseName}}ApiValidator>
|
||||
* @responseBody 200 - {{{swaggerModel}}}
|
||||
* @responseBody 200 - {{{swaggerExample}}}
|
||||
* @responseBody 401 - { "message": "Unauthorized" }
|
||||
* @responseBody 403 - { "message": "Forbidden" }
|
||||
* @responseBody 404 - { "message": "Not found" }
|
||||
* @responseBody 422 - <Error422> - 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' })
|
||||
}
|
||||
|
||||
@@ -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()])
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -44,4 +44,5 @@ router.use([
|
||||
*/
|
||||
export const middleware = router.named({
|
||||
auth: () => import('#middleware/auth_middleware'),
|
||||
tenant: () => import('#middleware/resolve_tenant_middleware'),
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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, '<Error422>')
|
||||
})
|
||||
|
||||
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)')
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
+8
-8
@@ -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"
|
||||
|
||||
Generated
+5
-51
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user