diff --git a/apps/app/src/composables/api/useFormSchemas.ts b/apps/app/src/composables/api/useFormSchemas.ts new file mode 100644 index 00000000..821ba253 --- /dev/null +++ b/apps/app/src/composables/api/useFormSchemas.ts @@ -0,0 +1,177 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' +import type { Ref } from 'vue' +import { apiClient } from '@/lib/axios' +import type { + CreateFormSchemaPayload, + FormSchema, + FormSchemaSummary, + RotatePublicTokenPayload, + RotatePublicTokenResponse, + UpdateFormSchemaPayload, +} from '@/types/formSchema' + +// Form schemas are organisation-scoped on the backend (see +// api/routes/api.php — /organisations/{organisation}/forms/schemas). +// There is no event in the path or controller signature; a single +// schema is reusable across all events of an organisation. + +interface ApiResponse { + success: boolean + data: T + message?: string +} + +interface PaginatedResponse { + data: T[] +} + +export function useFormSchemaList(orgId: Ref) { + return useQuery({ + queryKey: ['form-schemas', orgId], + queryFn: async () => { + const { data } = await apiClient.get>( + `/organisations/${orgId.value}/forms/schemas`, + ) + + return data.data + }, + enabled: () => !!orgId.value, + }) +} + +export function useFormSchema(orgId: Ref, schemaId: Ref) { + return useQuery({ + queryKey: ['form-schemas', orgId, schemaId], + queryFn: async () => { + const { data } = await apiClient.get>( + `/organisations/${orgId.value}/forms/schemas/${schemaId.value}`, + ) + + return data.data + }, + enabled: () => !!orgId.value && !!schemaId.value, + }) +} + +export function useCreateFormSchema(orgId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (payload: CreateFormSchemaPayload): Promise => { + const { data } = await apiClient.post>( + `/organisations/${orgId.value}/forms/schemas`, + payload, + ) + + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['form-schemas', orgId.value] }) + }, + }) +} + +export function useUpdateFormSchema(orgId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ id, ...payload }: UpdateFormSchemaPayload & { id: string }): Promise => { + const { data } = await apiClient.put>( + `/organisations/${orgId.value}/forms/schemas/${id}`, + payload, + ) + + return data.data + }, + onSuccess: (_updated, variables) => { + queryClient.invalidateQueries({ queryKey: ['form-schemas', orgId.value] }) + queryClient.invalidateQueries({ queryKey: ['form-schemas', orgId.value, variables.id] }) + }, + }) +} + +export function useDeleteFormSchema(orgId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ id, confirmed_name }: { id: string; confirmed_name?: string }) => { + await apiClient.delete(`/organisations/${orgId.value}/forms/schemas/${id}`, { + params: confirmed_name ? { confirmed_name } : undefined, + }) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['form-schemas', orgId.value] }) + }, + }) +} + +export function useDuplicateFormSchema(orgId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (id: string): Promise => { + const { data } = await apiClient.post>( + `/organisations/${orgId.value}/forms/schemas/${id}/duplicate`, + ) + + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['form-schemas', orgId.value] }) + }, + }) +} + +export function usePublishFormSchema(orgId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (id: string): Promise => { + const { data } = await apiClient.post>( + `/organisations/${orgId.value}/forms/schemas/${id}/publish`, + ) + + return data.data + }, + onSuccess: (_updated, id) => { + queryClient.invalidateQueries({ queryKey: ['form-schemas', orgId.value] }) + queryClient.invalidateQueries({ queryKey: ['form-schemas', orgId.value, id] }) + }, + }) +} + +export function useUnpublishFormSchema(orgId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (id: string): Promise => { + const { data } = await apiClient.post>( + `/organisations/${orgId.value}/forms/schemas/${id}/unpublish`, + ) + + return data.data + }, + onSuccess: (_updated, id) => { + queryClient.invalidateQueries({ queryKey: ['form-schemas', orgId.value] }) + queryClient.invalidateQueries({ queryKey: ['form-schemas', orgId.value, id] }) + }, + }) +} + +export function useRotatePublicToken(orgId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ id, ...payload }: RotatePublicTokenPayload & { id: string }): Promise => { + const { data } = await apiClient.post>( + `/organisations/${orgId.value}/forms/schemas/${id}/rotate-public-token`, + payload, + ) + + return data.data + }, + onSuccess: (_updated, variables) => { + queryClient.invalidateQueries({ queryKey: ['form-schemas', orgId.value, variables.id] }) + }, + }) +} diff --git a/apps/app/src/types/formSchema.ts b/apps/app/src/types/formSchema.ts new file mode 100644 index 00000000..f6efe54d --- /dev/null +++ b/apps/app/src/types/formSchema.ts @@ -0,0 +1,191 @@ +// Organizer-facing form schema types. Mirrors the backend resources at +// api/app/Http/Resources/FormBuilder/FormSchema(Summary)Resource.php and +// the enums at api/app/Enums/FormBuilder/. +// +// Shared schema types (FormFieldType, FormFieldDisplayWidth, etc.) are +// imported from @form-schema so portal and app stay in sync on the +// submit-side contract. Organizer-only types (lifecycle, payloads, +// purpose/submission_mode enums) live here. + +import type { + ConditionalLogic, + FormFieldDisplayWidth, + FormFieldType, + FormFieldValidationRules, +} from '@form-schema/types/formBuilder' + +// Re-export shared field primitives so consumers of this module don't +// need to reach into @form-schema directly. +export type { ConditionalLogic, FormFieldDisplayWidth, FormFieldType, FormFieldValidationRules } + +// Mirrors api/app/Enums/FormBuilder/FormPurpose.php +export const FormSchemaPurpose = { + EVENT_REGISTRATION: 'event_registration', + USER_PROFILE: 'user_profile', + ARTIST_PROFILE: 'artist_profile', + COMPANY_PROFILE: 'company_profile', + ARTIST_ADVANCE: 'artist_advance', + SUPPLIER_INTAKE: 'supplier_intake', + INCIDENT_REPORT: 'incident_report', + FEEDBACK: 'feedback', + POST_EVENT_EVALUATION: 'post_event_evaluation', + SIGNATURE_CONTRACT: 'signature_contract', + SIGNATURE_CODE_OF_CONDUCT: 'signature_code_of_conduct', + SIGNATURE_RECEIPT: 'signature_receipt', + ABSENCE_REPORT: 'absence_report', + CHECK_OUT_INVENTORY: 'check_out_inventory', + PUBLIC_COMPLAINT: 'public_complaint', + PUBLIC_PRESS_REQUEST: 'public_press_request', + PUBLIC_RSVP: 'public_rsvp', + ONBOARDING_WIZARD: 'onboarding_wizard', + EVENT_SETUP_WIZARD: 'event_setup_wizard', + COMPANY_CUSTOM: 'company_custom', + ARTIST_CUSTOM: 'artist_custom', + CUSTOM: 'custom', +} as const +export type FormSchemaPurpose = typeof FormSchemaPurpose[keyof typeof FormSchemaPurpose] + +// Mirrors api/app/Enums/FormBuilder/FormSubmissionMode.php +export const FormSubmissionMode = { + SINGLE: 'single', + MULTIPLE: 'multiple', + DRAFT_SINGLE: 'draft_single', +} as const +export type FormSubmissionMode = typeof FormSubmissionMode[keyof typeof FormSubmissionMode] + +// Mirrors api/app/Enums/FormBuilder/FormSchemaSnapshotMode.php +export const FormSchemaSnapshotMode = { + NEVER: 'never', + ON_SUBMIT: 'on_submit', + ALWAYS: 'always', +} as const +export type FormSchemaSnapshotMode = typeof FormSchemaSnapshotMode[keyof typeof FormSchemaSnapshotMode] + +// Derived client-side status. The backend does not emit a dedicated +// status enum — `is_published: boolean` on the resource is the source +// of truth. Add 'archived' here if/when we surface soft-deleted schemas. +export type FormSchemaStatus = 'draft' | 'published' + +// Shape returned by GET /organisations/{organisation}/forms/schemas +// (FormSchemaSummaryResource — index endpoint, paginated 25/page). +export interface FormSchemaSummary { + id: string + organisation_id: string + name: string + slug: string + purpose: FormSchemaPurpose + submission_mode: FormSubmissionMode + is_published: boolean + version: number + updated_at: string | null + // Both counts are always present in the index response because the + // controller calls ->withCount(['fields', 'submissions']). + submissions_count: number + fields_count: number +} + +// Shape returned by GET /organisations/{organisation}/forms/schemas/{form_schema} +// (FormSchemaResource — show, store, update, duplicate, publish, unpublish, rotate). +export interface FormSchema { + id: string + organisation_id: string + owner_type: string | null + owner_id: string | null + name: string + slug: string + purpose: FormSchemaPurpose + custom_purpose_slug: string | null + description: string | null + is_published: boolean + submission_mode: FormSubmissionMode + locale: string | null + settings: Record | null + snapshot_mode: FormSchemaSnapshotMode + freeze_on_submit: boolean + retention_days: number | null + consent_version: string | null + section_level_submit: boolean + auto_save_enabled: boolean + max_submissions: number | null + version: number + public_token: string | null + public_token_previous: string | null + public_token_rotated_at: string | null + submission_deadline: string | null + created_by_user_id: string | null + last_updated_by_user_id: string | null + edit_lock_user_id: string | null + edit_lock_expires_at: string | null + is_locked: boolean + public_form_url: string | null + fields_count: number + submissions_count: number | null + has_submissions: boolean | null + // TODO(PR-b3): replace with FormField[] once organizer field types land + fields: unknown[] + // TODO(PR-b3): replace with FormSchemaSection[] once organizer section types land + sections: unknown[] + created_at: string | null + updated_at: string | null +} + +// Body of POST /organisations/{organisation}/forms/schemas +// Mirrors StoreFormSchemaRequest. +export interface CreateFormSchemaPayload { + name: string + slug?: string | null + purpose: FormSchemaPurpose + custom_purpose_slug?: string | null + description?: string | null + is_published?: boolean + submission_mode?: FormSubmissionMode | null + locale?: string | null + settings?: Record | null + snapshot_mode?: FormSchemaSnapshotMode | null + freeze_on_submit?: boolean + retention_days?: number | null + consent_version?: string | null + section_level_submit?: boolean + auto_save_enabled?: boolean + max_submissions?: number | null + owner_type?: string | null + owner_id?: string | null +} + +// Body of PUT /organisations/{organisation}/forms/schemas/{form_schema} +// Mirrors UpdateFormSchemaRequest — all fields are optional (`sometimes`). +export interface UpdateFormSchemaPayload { + name?: string + slug?: string + purpose?: FormSchemaPurpose + custom_purpose_slug?: string | null + description?: string | null + is_published?: boolean + submission_mode?: FormSubmissionMode + locale?: string + settings?: Record + snapshot_mode?: FormSchemaSnapshotMode + freeze_on_submit?: boolean + retention_days?: number | null + consent_version?: string | null + section_level_submit?: boolean + auto_save_enabled?: boolean + max_submissions?: number | null + submission_deadline?: string | null +} + +// Body of POST /organisations/{organisation}/forms/schemas/{form_schema}/duplicate. +// The backend's duplicate action accepts no body today; the type exists for +// forward compatibility so callers can evolve without signature churn. +export type DuplicateFormSchemaPayload = Record + +// Body of POST /organisations/{organisation}/forms/schemas/{form_schema}/rotate-public-token +// Mirrors RotatePublicTokenRequest. +export interface RotatePublicTokenPayload { + grace_days?: number +} + +// Response of the rotate endpoint. The backend returns the full +// FormSchemaResource with the new public_token, public_token_previous, +// public_token_rotated_at, and refreshed public_form_url. +export type RotatePublicTokenResponse = FormSchema