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 :
bunx nuxt-feathers-zod add service formsLe 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é
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
slugdans le périmètre du tenant ; versionpour le verrouillage optimiste ;statusséparé du contenu ;defaultValuessé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.
// 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
// 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
<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
ownerIdettenantId; - 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.