Skip to content

Persister les formulaires avec NFZ

La Community Edition ne couple pas FormBuilder à une base de données. Une application Nuxt 4 peut persister les schémas dans un service Feathers créé avec nuxt-feathers-zod, puis exposer ce service au builder à travers un store ou un repository typé.

1. Générer le service forms

Dans l’application cliente NFZ :

bash
bunx nuxt-feathers-zod add service forms

Le service doit rester la source de vérité. Éviter de créer un endpoint Nitro parallèle pour la même collection.

2. Contrat de données recommandé

ts
import type {
  FormBuilderSchema,
  FormBuilderSettings,
  FormBuilderValues,
} from '@vevedh/qform-builder-layer/types'

export interface FormRecord {
  _id: string
  slug: string
  name: string
  description?: string
  schema: FormBuilderSchema
  defaultValues: FormBuilderValues
  settings: FormBuilderSettings
  status: 'draft' | 'published' | 'archived'
  version: number
  ownerId?: string
  tenantId?: string
  createdAt: string
  updatedAt: string
}

export type FormData = Omit<FormRecord, '_id' | 'createdAt' | 'updatedAt'>
export type FormPatch = Partial<Omit<FormData, 'slug'>> & { version: number }

Recommandations :

  • index unique sur slug dans le périmètre du tenant ;
  • version pour le verrouillage optimiste ;
  • status séparé du contenu ;
  • defaultValues séparé des réponses utilisateurs ;
  • aucune donnée personnelle dans les exemples ou templates livrés.

3. Repository Feathers typé

Les pages critiques ne doivent pas appeler directement $api.service(...). Encapsuler l’accès au service dans un repository construit à partir de useAdminFeathers() ou de la couche d’accès Feathers centrale de l’application.

ts
// app/repositories/formsRepository.ts
import type {
  FormBuilderSavePayload,
  FormBuilderSchema,
  FormBuilderSettings,
  FormBuilderValues,
} from '@vevedh/qform-builder-layer/types'
import { sanitizeQFormBuilderSchema } from '@vevedh/qform-builder-layer/schema-transfer'

interface FormRecord {
  _id: string
  slug: string
  name: string
  schema: FormBuilderSchema
  defaultValues: FormBuilderValues
  settings: FormBuilderSettings
  status: 'draft' | 'published' | 'archived'
  version: number
  createdAt: string
  updatedAt: string
}

interface FormsService {
  find(params?: { query?: Record<string, unknown> }): Promise<{ data: FormRecord[] } | FormRecord[]>
  get(id: string): Promise<FormRecord>
  create(data: Omit<FormRecord, '_id' | 'createdAt' | 'updatedAt'>): Promise<FormRecord>
  patch(id: string, data: Partial<FormRecord>, params?: { query?: Record<string, unknown> }): Promise<FormRecord>
}

export function createFormsRepository(service: FormsService) {
  async function findBySlug(slug: string): Promise<FormRecord | null> {
    const result = await service.find({ query: { slug, $limit: 1 } })
    const records = Array.isArray(result) ? result : result.data
    return records[0] || null
  }

  async function load(id: string): Promise<FormRecord> {
    const record = await service.get(id)
    return {
      ...record,
      schema: sanitizeQFormBuilderSchema(record.schema),
    }
  }

  async function createDraft(slug: string, payload: FormBuilderSavePayload): Promise<FormRecord> {
    return service.create({
      slug,
      name: payload.settings.formName || slug,
      schema: sanitizeQFormBuilderSchema(payload.schema),
      defaultValues: payload.values,
      settings: payload.settings,
      status: 'draft',
      version: 1,
    })
  }

  async function save(id: string, expectedVersion: number, payload: FormBuilderSavePayload): Promise<FormRecord> {
    return service.patch(id, {
      name: payload.settings.formName || 'Formulaire',
      schema: sanitizeQFormBuilderSchema(payload.schema),
      defaultValues: payload.values,
      settings: payload.settings,
      version: expectedVersion + 1,
    }, {
      query: { version: expectedVersion },
    })
  }

  return { findBySlug, load, createDraft, save }
}

Le filtre query: { version: expectedVersion } doit être contrôlé côté service/hook. Si aucun document ne correspond, retourner un conflit HTTP/Feathers 409 afin d’éviter d’écraser une modification concurrente.

4. Store Pinia de session d’édition

ts
// app/stores/formsEditor.ts
import { defineStore } from 'pinia'
import type { FormBuilderSavePayload } from '@vevedh/qform-builder-layer/types'
import { createFormsRepository } from '~/repositories/formsRepository'

export const useFormsEditorStore = defineStore('forms-editor', () => {
  const currentId = ref<string | null>(null)
  const currentVersion = ref(0)
  const saving = ref(false)

  async function save(payload: FormBuilderSavePayload) {
    if (!currentId.value) throw new Error('Aucun formulaire actif.')

    const { api } = useAdminFeathers()
    const repository = createFormsRepository(api.service('forms'))

    saving.value = true
    try {
      const saved = await repository.save(currentId.value, currentVersion.value, payload)
      currentVersion.value = saved.version
      return saved
    }
    finally {
      saving.value = false
    }
  }

  return { currentId, currentVersion, saving, save }
})

useAdminFeathers() représente ici la couche d’accès protégée de l’application. Elle doit appeler ensureAuthenticated() et appliquer le RBAC avant de rendre le service disponible.

5. Brancher FormBuilder

vue
<script setup lang="ts">
import type {
  FormBuilderSchema,
  FormBuilderSettings,
  FormBuilderValues,
} from '@vevedh/qform-builder-layer/types'

const editor = useFormsEditorStore()
const schema = ref<FormBuilderSchema>([])
const values = ref<FormBuilderValues>({})
const settings = ref<Partial<FormBuilderSettings>>({})
</script>

<template>
  <FormBuilder
    builder-id="admin-forms-editor"
    v-model:schema="schema"
    v-model:values="values"
    v-model:settings="settings"
    :disabled="editor.saving"
    @save="editor.save"
  />
</template>

6. Sécurité backend obligatoire

Le service forms doit appliquer au minimum :

  • authentification NFZ/Feathers ;
  • RBAC sur find/get/create/patch/remove ;
  • limitation de taille du schéma ;
  • validation Zod du contrat ;
  • assainissement du schéma côté serveur ;
  • attribution serveur de ownerId et tenantId ;
  • interdiction de modifier ces champs depuis le client ;
  • verrouillage optimiste par version ;
  • journalisation des publications et suppressions ;
  • projection externe masquant les métadonnées internes inutiles.

Les réponses remplies par les utilisateurs doivent être stockées dans un service séparé, par exemple form-submissions, afin de ne pas mélanger définition du formulaire et données personnelles collectées.

QForm Builder — layer Nuxt 4, Quasar et FormKit pour formulaires dynamiques.