diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..7563370 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,21 @@ +{ + "name": "nuxt-auto-skills", + "owner": { + "name": "websideproject" + }, + "metadata": { + "description": "Claude Code skills for @websideproject/nuxt-auto-api and @websideproject/nuxt-auto-admin — schema-driven REST API and admin panel generation for Nuxt 4", + "version": "0.0.7" + }, + "plugins": [ + { + "name": "nuxt-auto-skills", + "description": "Skills for nuxt-auto-api and nuxt-auto-admin: resource registration, CRUD composables, authorization, hooks, M2M, aggregation, admin config, widgets, and module authoring", + "source": "./", + "skills": [ + "./.claude/skills/nuxt-auto-api", + "./.claude/skills/nuxt-auto-admin" + ] + } + ] +} diff --git a/.claude/skills/nuxt-auto-admin/SKILL.md b/.claude/skills/nuxt-auto-admin/SKILL.md new file mode 100644 index 0000000..dee691e --- /dev/null +++ b/.claude/skills/nuxt-auto-admin/SKILL.md @@ -0,0 +1,98 @@ +--- +name: nuxt-auto-admin +description: Use when working with nuxt-auto-admin (@websideproject/nuxt-auto-admin) - auto-generated admin panel from Drizzle ORM schemas, with resource tables, forms, M2M cards, permission integration, and customizable widgets. +license: MIT +--- + +# nuxt-auto-admin + +Auto-generates a full admin panel for Nuxt 4 from Drizzle ORM schemas registered with `@websideproject/nuxt-auto-api`. Introspects table schemas at build time to produce list views, create/edit forms, M2M management, and permission-aware navigation — all via `@nuxt/ui`. + +**Requires** `@websideproject/nuxt-auto-api` loaded first in the `modules` array. + +## When to Use + +- Configuring `autoAdmin` module options (branding, prefix, features, permissions) +- Customizing resource display (`displayName`, `icon`, `listFields`, `formFields`, `hiddenFields`) +- Adding/configuring form field widgets (`WidgetType`, `WidgetOptions`) +- Writing custom actions (`CustomAction`) for single items, bulk, or page-level +- Using admin composables (`useAdminConfig`, `useAdminRegistry`, `useAdminPermissions`, `useAdminActions`) +- Working with ``, ``, ``, ``, `` +- Setting up custom pages (`customPages`) +- Understanding permission integration with nuxt-auto-api + +## Quick Start + +```ts +// nuxt.config.ts — load API module first +export default defineNuxtConfig({ + modules: [ + '@websideproject/nuxt-auto-api', + '@websideproject/nuxt-auto-admin', + ], + autoApi: { + database: { client: 'd1' }, + }, + autoAdmin: { + prefix: '/admin', + access: (user) => user?.roles?.includes('admin'), + branding: { title: 'My App Admin', logo: '/logo.svg' }, + resources: { + posts: { + displayName: 'Blog Posts', + icon: 'i-heroicons-document-text', + listFields: ['title', 'status', 'author', 'createdAt'], + hiddenFields: ['slug', 'internalNotes'], + }, + users: { + displayName: 'Users', + icon: 'i-heroicons-users', + group: 'Team', + readonlyFields: ['email', 'createdAt'], + }, + }, + }, +}) +``` + +## Auto-Generated Routes + +All prefixed by `autoAdmin.prefix` (default: `/admin`): + +| Route | Page | +|-------|------| +| `/admin` | Dashboard | +| `/admin/:resource` | List with search, filters, bulk actions | +| `/admin/:resource/new` | Create form | +| `/admin/:resource/:id` | Detail view | +| `/admin/:resource/:id/edit` | Edit form | + +## Available Guidance + +| File | Topics | +|------|--------| +| **[references/config.md](references/config.md)** | ModuleOptions, ResourceConfig, DashboardConfig, ThemeConfig, CustomPageConfig | +| **[references/fields-widgets.md](references/fields-widgets.md)** | FieldConfig, all WidgetTypes with WidgetOptions, auto-widget selection rules | +| **[references/composables.md](references/composables.md)** | useAdminConfig, useAdminRegistry, useAdminResource, useAdminPermissions, useAdminActions, useResourceForm, useM2MDetection | +| **[references/components.md](references/components.md)** | ResourceTable, ResourceForm, AutoForm, AutoField, M2MRelationCard, modals | +| **[references/actions-permissions.md](references/actions-permissions.md)** | CustomAction, ActionContext, permission integration, middleware, unauthorized behavior | +| **[references/module-authoring.md](references/module-authoring.md)** | Injecting admin display config from a Nuxt module: spread pattern, formFields, junction tables | + +## Progressive Loading + +- Setting up the module or resources? → [references/config.md](references/config.md) +- Configuring form fields / widgets? → [references/fields-widgets.md](references/fields-widgets.md) +- Using composables in custom pages? → [references/composables.md](references/composables.md) +- Using admin UI components? → [references/components.md](references/components.md) +- Custom actions or permission control? → [references/actions-permissions.md](references/actions-permissions.md) +- Building a Nuxt module that ships admin config? → [references/module-authoring.md](references/module-authoring.md) + +**DO NOT read all files at once.** + +## Related Skills + +- **`nuxt-auto-api`** — required backend; provides the endpoints this admin consumes +- **`nuxt-ui`** — UI component library used by the admin panel +- **`nuxt`** — Nuxt 4 patterns for custom pages and middleware + +_Token efficiency: Main skill ~350 tokens. Each reference ~800–1200 tokens._ diff --git a/.claude/skills/nuxt-auto-admin/references/actions-permissions.md b/.claude/skills/nuxt-auto-admin/references/actions-permissions.md new file mode 100644 index 0000000..62feabf --- /dev/null +++ b/.claude/skills/nuxt-auto-admin/references/actions-permissions.md @@ -0,0 +1,165 @@ +# Custom Actions & Permissions + +## Custom Actions + +Add custom buttons to resource rows, toolbars, or detail pages. + +```ts +resources: { + posts: { + actions: { + publish: { + label: 'Publish', + icon: 'i-heroicons-paper-airplane', + type: 'single', // 'single' | 'bulk' | 'page-level' + location: 'row', // 'row' | 'toolbar' | 'detail' + permission: (ctx) => ctx.user?.roles?.includes('editor'), + confirm: 'Publish this post?', // confirmation dialog message + // or: confirm: (item) => `Publish "${item.title}"?` + handler: async (item, ctx) => { + await $fetch(`/api/posts/${item.id}`, { + method: 'PATCH', + body: { status: 'published', publishedAt: new Date() }, + }) + await ctx.refresh() + ctx.toast.add({ title: 'Post published', color: 'success' }) + }, + variant: 'ghost', + color: 'success', + }, + }, + }, +} +``` + +### `CustomAction` interface + +```ts +interface CustomAction { + label: string + icon?: string // Iconify icon + type: 'single' | 'bulk' | 'page-level' + location: 'row' | 'toolbar' | 'detail' + permission?: (ctx: ActionContext) => boolean | Promise + handler: (item: any | any[], ctx: ActionContext) => Promise | void + confirm?: string | ((item: any | any[]) => string) + variant?: 'primary' | 'secondary' | 'ghost' | 'link' + color?: string +} +``` + +### `ActionContext` + +```ts +interface ActionContext { + user: any // Current authenticated user + resource: string // Resource name + refresh: () => Promise // Re-fetch the current list/detail + toast: any // @nuxt/ui useToast() instance +} +``` + +### Action types + +| Type | Receives | Shown in | +|------|----------|----------| +| `single` | Single item | Row actions, detail page | +| `bulk` | Array of selected items | Toolbar (bulk actions) | +| `page-level` | N/A | Page toolbar only | + +### Action locations + +| Location | Where | +|----------|-------| +| `row` | Per-row action buttons in the table | +| `toolbar` | Above the table (for bulk and page-level) | +| `detail` | On the detail/edit page | + +--- + +## Permission Integration + +nuxt-auto-admin integrates directly with the `nuxt-auto-api` permission system. + +### How permissions flow + +1. `useAdminPermissions('posts')` calls `usePermissions('posts')` from nuxt-auto-api +2. The admin UI reads `canCreate`, `canRead`, `canUpdate`, `canDelete` +3. Buttons/sidebar items are **hidden or disabled** based on `autoAdmin.permissions` config + +### Configure unauthorized behavior + +```ts +autoAdmin: { + permissions: { + unauthorizedButtons: 'disable', // 'hide' | 'disable' — default: 'disable' + unauthorizedSidebarItems: 'hide', // 'hide' | 'disable' — default: 'hide' + }, +} +``` + +- `'disable'` — shows the button but makes it unclickable +- `'hide'` — removes the element entirely + +### Custom action permissions + +```ts +permission: async (ctx) => { + // ctx.user is the authenticated user from the session + return ctx.user?.roles?.includes('editor') && !ctx.user?.isSuspended +} +``` + +When `permission` returns `false`, the action button respects the `unauthorizedButtons` setting. + +--- + +## Admin Access Guard + +The `access` function in module config controls who can reach any admin route at all: + +```ts +autoAdmin: { + access: (user) => { + if (!user) return false + return user.roles?.includes('admin') || user.roles?.includes('moderator') + }, +} +``` + +The `admin-auth` middleware enforces this on every `/admin/*` route. + +--- + +## Middleware + +### `admin-auth` + +Use in custom pages to enforce the global `access` check: + +```ts +// pages/admin/custom-page.vue +definePageMeta({ middleware: 'admin-auth' }) +``` + +### `permissions.global` + +Automatically runs on all admin routes. Checks per-resource permissions and shows `` if the user lacks the required permission for the current route's resource. + +--- + +## Custom Page Access + +Each custom page can define its own access check independent of the global one: + +```ts +customPages: [ + { + name: 'reports', + label: 'Reports', + path: '/reports', + icon: 'i-heroicons-chart-bar', + canAccess: (user) => user?.permissions?.includes('view:reports'), + }, +] +``` diff --git a/.claude/skills/nuxt-auto-admin/references/components.md b/.claude/skills/nuxt-auto-admin/references/components.md new file mode 100644 index 0000000..cc4ec3f --- /dev/null +++ b/.claude/skills/nuxt-auto-admin/references/components.md @@ -0,0 +1,186 @@ +# Components Reference + +All components are auto-imported when `@websideproject/nuxt-auto-admin` is registered. + +--- + +## `` — Paginated list view + +Auto-generates a searchable, sortable, paginated table for a resource using its introspected schema. + +```vue + + view-mode="modal" +/> +``` + +Features: search bar, column sorting, row-level edit/delete/view buttons, bulk selection, bulk actions, export button (if `features.export: true`), permission-aware button visibility. + +--- + +## `` — Schema-driven create/edit form + +```vue + + + + + + + + +``` + +Renders all configured `formFields.create` (or `.edit`) fields, handles M2M fields automatically, shows validation errors, and handles submission. + +--- + +## `` — Generic form renderer + +Lower-level form component. Takes an explicit `fields: FieldConfig[]` array rather than inferring from a resource name. Use when building custom forms. + +```vue + + :show-cancel="true" + :show-reset="false" + submit-label="Save Post" + :disabled="isPending" + @submit="handleSubmit" + @cancel="handleCancel" +/> +``` + +**Props:** +- `fields: FieldConfig[]` — field definitions +- `initialData?: Record` — pre-filled values +- `mode?: 'create' | 'edit' | 'view'` — default: `'create'` +- `showCancel?: boolean` +- `showReset?: boolean` +- `submitLabel?: string` +- `disabled?: boolean` + +**Emits:** +- `submit(data: Record)` — form values on valid submission +- `cancel()` + +--- + +## `` — Single field widget renderer + +Renders one field widget based on its `FieldConfig.widget` type. + +```vue + +``` + +**Props:** +- `field: FieldConfig` +- `modelValue: any` +- `error?: string` +- `disabled?: boolean` + +**Emits:** +- `update:modelValue(value: any)` + +Use this to embed individual widgets in custom layouts. + +--- + +## `` — M2M relationship editor + +Dedicated card for managing a many-to-many relation. Renders a multi-select or searchable list of related items. + +```vue + + :disabled="!canUpdate" +/> +``` + +--- + +## `` — 403 page + +Shown automatically by the permissions middleware when a user lacks access to a resource. Can also be used manually: + +```vue + +``` + +--- + +## Modal Components + +Used internally by `` based on `editMode`/`viewMode` config. Can also be used directly. + +### `` + +```vue + +``` + +### `` + +```vue + +``` + +### `` + +```vue + +``` + +--- + +## Layout Components + +Rendered automatically by the admin layout but can be customized: + +- **``** — top bar with branding, user info, mobile sidebar toggle +- **``** — collapsible sidebar with resource groups, custom pages + +--- + +## Widget Components (individual) + +All widgets accept `modelValue`/`update:modelValue` + `disabled` + `error`. Available for direct use in custom admin pages: + +| Component | Widget type | +|-----------|-------------| +| `` | text | +| `` | number with min/max/step | +| `` | multi-line text | +| `` | boolean | +| `` | dropdown with static options | +| `` | date/time | +| `` | masked password | +| `` | FK select via API search | +| `` | M2M multi-select via API | diff --git a/.claude/skills/nuxt-auto-admin/references/composables.md b/.claude/skills/nuxt-auto-admin/references/composables.md new file mode 100644 index 0000000..1e8a327 --- /dev/null +++ b/.claude/skills/nuxt-auto-admin/references/composables.md @@ -0,0 +1,165 @@ +# Admin Composables + +All composables are auto-imported when `@websideproject/nuxt-auto-admin` is registered. + +--- + +## `useAdminConfig` — Runtime config access + +```ts +const config = useAdminConfig() +// Returns: +{ + prefix: string + branding: { title?: string; logo?: string; favicon?: string } + permissions: { + unauthorizedButtons: 'hide' | 'disable' + unauthorizedSidebarItems: 'hide' | 'disable' + } + features: { + bulkActions: boolean + search: boolean + filters: boolean + export: boolean + import: boolean + auditLog: boolean + } + ui: { editMode: 'modal' | 'page'; viewMode: 'modal' | 'page' } +} +``` + +--- + +## `useAdminRegistry` — All registered resources + +```ts +const { + registry, // Ref> + allResources, // Ref + getResource, // (name: string) => ResourceSchema | undefined + getResourcesByGroup, // Ref> + isLoading, // Ref +} = useAdminRegistry() +``` + +### `ResourceSchema` + +The introspected + configured schema for a resource: + +```ts +interface ResourceSchema { + name: string + displayName: string + icon: string + columns: ColumnMetadata[] // introspected from Drizzle table + relations?: RelationMetadata[] + primaryKey: string + listFields: string[] + formFields: { create: FieldConfig[]; edit: FieldConfig[] } + hiddenFields: string[] + readonlyFields: string[] + actions: Record + group?: string + order?: number + disabled?: boolean + type: 'resource' | 'junction' +} +``` + +--- + +## `useAdminResource` — Single resource schema + +```ts +const { + resource, // Ref + isLoading, +} = useAdminResource('posts') +``` + +--- + +## `useAdminPermissions` — CRUD permission flags + +Wraps `usePermissions` from `nuxt-auto-api` with admin-specific helpers. + +```ts +const { + permissions, // Ref + canCreate, // Ref + canRead, // Ref + canUpdate, // Ref + canDelete, // Ref + hasAnyPermission, // Ref + isLoading, + getPermissionDeniedMessage, // (action) => string +} = useAdminPermissions('posts') +``` + +Use with `autoAdmin.permissions.unauthorizedButtons` to conditionally disable/hide UI controls. + +--- + +## `useAdminActions` — Navigation helpers + +```ts +const { + goToList, // () => navigateTo('/admin/posts') + goToDetail, // (id) => navigateTo('/admin/posts/:id') + goToCreate, // () => navigateTo('/admin/posts/new') + goToEdit, // (id) => navigateTo('/admin/posts/:id/edit') + handleDelete, // async (id, { redirect? }) => void + isDeleting, // Ref +} = useAdminActions('posts') +``` + +`handleDelete` calls `useAutoApiDelete` internally, shows a confirmation toast, and optionally redirects to the list after deletion. + +--- + +## `useResourceForm` — Form field generation + +```ts +const { + fields, // Ref — resolved form fields for the mode + initialData, // Ref> — default values + isLoading, + resource, // Ref +} = useResourceForm('posts', 'create') // 'create' | 'edit' +``` + +Use this to render a custom form outside ``: + +```vue + + + +``` + +--- + +## `useM2MDetection` — M2M relationship helpers + +```ts +const { + detectM2MFields, // async (resourceName: string) => M2MFieldConfig[] + mergeM2MFields, // (auto: M2MFieldConfig[], manual?: FieldConfig[]) => FieldConfig[] + formatLabel, // (resourceName: string) => string +} = useM2MDetection() + +// Standalone helpers +const isJunction = await isJunctionTable('post_tags') // boolean +const junctions = await getJunctionTableNames() // string[] +``` + +`detectM2MFields` queries `GET /api/_m2m/detect/:resource` to find M2M relations and returns them as `FieldConfig[]` with `MultiRelationSelect` widgets pre-configured. diff --git a/.claude/skills/nuxt-auto-admin/references/config.md b/.claude/skills/nuxt-auto-admin/references/config.md new file mode 100644 index 0000000..0219352 --- /dev/null +++ b/.claude/skills/nuxt-auto-admin/references/config.md @@ -0,0 +1,199 @@ +# Configuration Reference + +## Full Module Options + +```ts +// nuxt.config.ts +autoAdmin: { + prefix?: string // default: '/admin' + + // Who can access the admin panel at all + access?: (user: any) => boolean | Promise + + branding?: { + logo?: string // URL to logo image + title?: string // default: 'Admin Panel' + favicon?: string + } + + // Per-resource overrides (see ResourceConfig below) + resources?: Record + + dashboard?: DashboardConfig + + theme?: ThemeConfig + + customPages?: CustomPageConfig[] + + features?: { + bulkActions?: boolean // default: true + search?: boolean // default: true + filters?: boolean // default: true + export?: boolean // default: true + import?: boolean // default: false + auditLog?: boolean // default: false + } + + permissions?: { + unauthorizedButtons?: 'hide' | 'disable' // default: 'disable' + unauthorizedSidebarItems?: 'hide' | 'disable' // default: 'hide' + } + + ui?: { + editMode?: 'modal' | 'page' // default: 'modal' + viewMode?: 'modal' | 'page' // default: 'modal' + } +} +``` + +--- + +## `ResourceConfig` + +Overrides the auto-introspected schema for a specific resource. + +```ts +interface ResourceConfig { + displayName?: string // Human-readable name shown in sidebar and headers + icon?: string // Iconify icon (e.g. 'i-heroicons-document-text') + + listFields?: string[] // Columns shown in the list/table view + hiddenFields?: string[] // Fields excluded from list + form views + readonlyFields?: string[] // Shown in forms but not editable + + formFields?: { + create?: FieldConfig[] // Override fields for create form + edit?: FieldConfig[] // Override fields for edit form + } + + actions?: Record // Custom per-item, bulk, or page-level actions + + disabled?: boolean // Hide this resource from admin entirely + group?: string // Sidebar group label (e.g. 'Content', 'Users') + order?: number // Sort position in sidebar + + type?: 'resource' | 'junction' // default: 'resource'; 'junction' hides from sidebar +} +``` + +### Example + +```ts +resources: { + posts: { + displayName: 'Blog Posts', + icon: 'i-heroicons-document-text', + group: 'Content', + order: 1, + listFields: ['title', 'status', 'authorId', 'publishedAt', 'createdAt'], + hiddenFields: ['deletedAt', 'internalScore'], + readonlyFields: ['createdAt', 'updatedAt'], + formFields: { + create: [ + { name: 'title', label: 'Title', widget: 'TextInput', required: true }, + { name: 'content', label: 'Content', widget: 'MarkdownEditor' }, + { name: 'authorId', label: 'Author', widget: 'RelationSelect', + options: { resource: 'users', displayField: 'name' } }, + { name: 'status', label: 'Status', widget: 'SelectInput', + options: { options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + ]}}, + ], + }, + }, + post_tags: { type: 'junction' }, // hide junction table from sidebar +} +``` + +--- + +## `DashboardConfig` + +```ts +interface DashboardConfig { + // Define stat cards, recent activity, or custom widgets + widgets?: DashboardWidget[] +} +``` + +--- + +## `ThemeConfig` + +```ts +interface ThemeConfig { + // Color overrides for the admin panel (uses @nuxt/ui theming) +} +``` + +--- + +## `CustomPageConfig` + +Add custom pages to the admin sidebar and routing. + +```ts +interface CustomPageConfig { + name: string // Route name used internally + label: string // Sidebar label + path: string // URL path (relative to admin prefix) + icon: string // Iconify icon + group?: string // Sidebar group + order?: number + + // Access control + permissions?: string | string[] + canAccess?: (user: any) => boolean | Promise +} +``` + +```ts +customPages: [ + { + name: 'analytics', + label: 'Analytics', + path: '/analytics', + icon: 'i-heroicons-chart-bar', + group: 'Insights', + order: 10, + canAccess: (user) => user?.roles?.includes('analyst'), + }, +] +``` + +The corresponding page component should be created at `pages/admin/analytics.vue`. + +--- + +## Resource Groups + +Resources with the same `group` string are grouped together in the sidebar: + +```ts +resources: { + posts: { group: 'Content', order: 1 }, + pages: { group: 'Content', order: 2 }, + media: { group: 'Content', order: 3 }, + users: { group: 'Team', order: 1 }, + roles: { group: 'Team', order: 2 }, + settings: { group: 'System', order: 1 }, +} +``` + +--- + +## Virtual Module (`#nuxt-auto-admin-registry`) + +Generated at build time. Import directly for SSR-friendly registry access: + +```ts +import { + registry, + getResource, + getAllResources, + getResourcesByGroup, + resourceNames, + adminConfig, +} from '#nuxt-auto-admin-registry' +``` diff --git a/.claude/skills/nuxt-auto-admin/references/fields-widgets.md b/.claude/skills/nuxt-auto-admin/references/fields-widgets.md new file mode 100644 index 0000000..e8c2295 --- /dev/null +++ b/.claude/skills/nuxt-auto-admin/references/fields-widgets.md @@ -0,0 +1,210 @@ +# Fields & Widgets + +## `FieldConfig` + +Defines a single form field in `formFields.create` or `formFields.edit`: + +```ts +interface FieldConfig { + name: string // Drizzle column name + label?: string // Display label (auto-generated if omitted) + widget?: WidgetType // Auto-detected from column type if omitted + required?: boolean + readonly?: boolean + help?: string // Help text below the field + placeholder?: string + options?: WidgetOptions // Widget-specific configuration + validation?: any // Additional validation rules + condition?: (formData: any) => boolean // Show/hide based on other field values +} +``` + +--- + +## Widget Types + +```ts +type WidgetType = + | 'TextInput' + | 'NumberInput' + | 'TextareaInput' + | 'CheckboxInput' + | 'SelectInput' + | 'DateTimePicker' + | 'PasswordInput' + | 'RelationSelect' + | 'MultiRelationSelect' + | 'FileUpload' + | 'ImageUpload' + | 'RichTextEditor' + | 'MarkdownEditor' + | 'CodeEditor' + | 'ColorPicker' + | 'JsonEditor' + | 'TagsInput' + | 'SlugInput' +``` + +--- + +## Widget Options (`WidgetOptions`) + +### `SelectInput` + +```ts +options: { + options?: Array<{ label: string; value: any }> + enumValues?: string[] // Use enum values from Drizzle schema +} +``` + +```ts +{ name: 'status', widget: 'SelectInput', options: { + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + { label: 'Archived', value: 'archived' }, + ] +}} +``` + +### `RelationSelect` — FK/belongsTo + +```ts +options: { + resource: string // nuxt-auto-api resource name + displayField?: string // field to show in dropdown (default: 'name' or 'title') + searchFields?: string[] // fields to search when typing +} +``` + +```ts +{ name: 'authorId', widget: 'RelationSelect', options: { + resource: 'users', + displayField: 'name', + searchFields: ['name', 'email'], +}} +``` + +### `MultiRelationSelect` — M2M + +```ts +options: { + resource: string + displayField?: string + searchFields?: string[] + junctionTable?: string // if M2M via junction + junctionLeftKey?: string + junctionRightKey?: string +} +``` + +```ts +{ name: 'tags', widget: 'MultiRelationSelect', options: { + resource: 'tags', + displayField: 'name', + junctionTable: 'post_tags', + junctionLeftKey: 'postId', + junctionRightKey: 'tagId', +}} +``` + +### `NumberInput` + +```ts +options: { min?: number; max?: number; step?: number } +``` + +### `TextInput` / `TextareaInput` + +```ts +options: { maxLength?: number; minLength?: number; rows?: number } +``` + +### `DateTimePicker` + +```ts +options: { format?: string; showTime?: boolean } +``` + +### `FileUpload` / `ImageUpload` + +```ts +options: { accept?: string; maxSize?: number; multiple?: boolean } +// e.g. accept: 'image/*', maxSize: 5_000_000 +``` + +### `SlugInput` + +Auto-generates a slug from another field. + +```ts +options: { generateFrom: 'title' } // watches 'title' field and generates slug +``` + +### `RichTextEditor` + +```ts +options: { toolbar?: string[] } +``` + +### `CodeEditor` + +```ts +options: { language?: 'typescript' | 'json' | 'sql' | string; theme?: string } +``` + +--- + +## Auto-Widget Selection + +When `widget` is not specified, nuxt-auto-admin maps Drizzle column types automatically: + +| Column type / pattern | Widget | +|-----------------------|--------| +| FK column (`*Id`) | `RelationSelect` | +| Enum column | `SelectInput` (with enum values) | +| `password`, `*hash*`, `*secret*` | `PasswordInput` | +| Boolean | `CheckboxInput` | +| Integer/numeric | `NumberInput` | +| Date/timestamp | `DateTimePicker` | +| Large text (TEXT, `*content*`, `*body*`, `*description*`) | `TextareaInput` | +| JSON/jsonb | `JsonEditor` | +| Default | `TextInput` | + +--- + +## Conditional Fields + +Show or hide a field based on other form values: + +```ts +formFields: { + create: [ + { name: 'type', widget: 'SelectInput', + options: { options: [ + { label: 'Article', value: 'article' }, + { label: 'Video', value: 'video' }, + ]}}, + { name: 'videoUrl', widget: 'TextInput', + condition: (data) => data.type === 'video' }, + { name: 'readingTime', widget: 'NumberInput', + condition: (data) => data.type === 'article' }, + ] +} +``` + +--- + +## Hidden & Readonly Fields + +```ts +resources: { + posts: { + hiddenFields: ['deletedAt', 'internalScore'], // excluded from list + form + readonlyFields: ['createdAt', 'updatedAt', 'slug'], // shown but not editable + } +} +``` + +Common auto-hidden fields: primary key (unless it's the only identifier), standard audit timestamps when `readonlyFields` applies. diff --git a/.claude/skills/nuxt-auto-admin/references/module-authoring.md b/.claude/skills/nuxt-auto-admin/references/module-authoring.md new file mode 100644 index 0000000..3b5b30f --- /dev/null +++ b/.claude/skills/nuxt-auto-admin/references/module-authoring.md @@ -0,0 +1,223 @@ +# Module Authoring with nuxt-auto-admin + +How to configure nuxt-auto-admin from inside a reusable Nuxt module that ships its own resources. + +**Prerequisite:** The module must also use `nuxt-auto-api` (see its `module-authoring.md`). nuxt-auto-admin consumes the same registry populated by `autoApi:registerSchema`. + +--- + +## How It Works + +nuxt-auto-admin **captures the registry reference** during the same `autoApi:registerSchema` hook that nuxt-auto-api uses. After `modules:done`, it introspects every registered Drizzle table and builds an admin schema (columns, relations, form fields, list columns) automatically. + +Modules can inject **display-layer config** (labels, icons, groups, form field overrides) by mutating `nuxt.options.autoAdmin.resources` during module setup. + +--- + +## The Injection Pattern + +Always guard the injection since the host app may not have nuxt-auto-admin installed: + +```ts +// module.ts +export default defineNuxtModule({ + setup(_options, nuxt) { + const resolver = createResolver(import.meta.url) + + // 1. Register schema with nuxt-auto-api (always required) + nuxt.hook('autoApi:registerSchema' as any, (registry: any) => { + registry.register('posts', { + schema: createModuleImport(resolver.resolve('./schema'), 'posts'), + authorization: createModuleImport(resolver.resolve('./auth'), 'postsAuth'), + }) + registry.register('postTags', { + schema: createModuleImport(resolver.resolve('./schema'), 'postTags'), + }) + }) + + // 2. Inject admin display config if nuxt-auto-admin is present + if ((nuxt.options as any).autoAdmin) { + (nuxt.options as any).autoAdmin = { + ...(nuxt.options as any).autoAdmin, + resources: { + // Module's default config — host app spreads AFTER to override + posts: { + displayName: 'Blog Posts', + icon: 'i-heroicons-document-text', + group: 'Content', + order: 1, + listFields: ['title', 'status', 'authorId', 'publishedAt', 'createdAt'], + hiddenFields: ['deletedAt', 'internalScore'], + readonlyFields: ['createdAt', 'updatedAt', 'slug'], + formFields: { + create: [ + { name: 'title', label: 'Title', widget: 'TextInput', required: true }, + { name: 'slug', label: 'Slug', widget: 'SlugInput', + options: { generateFrom: 'title' } }, + { name: 'content', label: 'Content', widget: 'MarkdownEditor' }, + { name: 'status', label: 'Status', widget: 'SelectInput', + options: { enumValues: ['draft', 'published', 'archived'] } }, + { name: 'authorId', label: 'Author', widget: 'RelationSelect', + options: { resource: 'users', displayField: 'name', searchFields: ['name', 'email'] } }, + { name: 'tags', label: 'Tags', widget: 'MultiRelationSelect', + options: { resource: 'tags', displayField: 'name', + junctionTable: 'postTags', junctionLeftKey: 'postId', junctionRightKey: 'tagId' } }, + ], + edit: [ + { name: 'title', label: 'Title', widget: 'TextInput', required: true }, + { name: 'content', label: 'Content', widget: 'MarkdownEditor' }, + { name: 'status', label: 'Status', widget: 'SelectInput', + options: { enumValues: ['draft', 'published', 'archived'] } }, + { name: 'tags', label: 'Tags', widget: 'MultiRelationSelect', + options: { resource: 'tags', displayField: 'name', + junctionTable: 'postTags', junctionLeftKey: 'postId', junctionRightKey: 'tagId' } }, + ], + }, + }, + + // Junction table: hide from admin sidebar + postTags: { type: 'junction' }, + + // Spread host app config LAST — lets it override anything above + ...((nuxt.options as any).autoAdmin.resources ?? {}), + }, + } + } + }, +}) +``` + +--- + +## Spread Order Matters + +```ts +resources: { + // 1. Module defaults (your safe fallbacks) + posts: { displayName: 'Blog Posts', icon: '...' }, + + // 2. Host app overrides (spread last — wins over module defaults) + ...((nuxt.options as any).autoAdmin.resources ?? {}), +} +``` + +This lets the host app customize anything your module sets by putting config in their `nuxt.config.ts`: + +```ts +// host app nuxt.config.ts +autoAdmin: { + resources: { + posts: { + displayName: 'Articles', // overrides module's 'Blog Posts' + group: 'Editorial', + }, + }, +} +``` + +--- + +## ResourceConfig Reference (in module context) + +```ts +interface ResourceConfig { + displayName?: string // Label shown in sidebar and page headers + icon?: string // Iconify icon + group?: string // Sidebar group label + order?: number // Sort order within the group + type?: 'resource' | 'junction' // 'junction' = hidden from sidebar + + listFields?: string[] // Which columns appear in the table view + hiddenFields?: string[] // Excluded from list AND form + readonlyFields?: string[] // Shown in forms but not editable + + formFields?: { + create?: FieldConfig[] + edit?: FieldConfig[] + } + + actions?: Record + disabled?: boolean // Hide resource entirely +} +``` + +--- + +## Auto-Introspection + +Even without `formFields` config, nuxt-auto-admin introspects every registered Drizzle table at build time using `Symbol.for('drizzle:Columns')` to read column metadata. It auto-selects widgets based on: + +| Column pattern | Auto widget | +|----------------|-------------| +| `*Id` (FK) | `RelationSelect` (targets guessed from resource name) | +| Enum column | `SelectInput` with enum values | +| `password`, `*hash*`, `*secret*` | `PasswordInput` | +| Boolean | `CheckboxInput` | +| Integer/numeric | `NumberInput` | +| Date/timestamp | `DateTimePicker` | +| Large text, `*content*`, `*body*` | `TextareaInput` | +| JSON | `JsonEditor` | +| Default | `TextInput` | + +The `formFields` config in your module is used to **override** this auto-detection — useful for: +- Setting M2M `MultiRelationSelect` fields (not auto-detected for FK-less junction) +- Using `MarkdownEditor` instead of `TextareaInput` +- Specifying `RelationSelect.displayField` and `searchFields` +- Adding `SlugInput` with `generateFrom` +- Ordering fields explicitly + +--- + +## Custom Actions from a Module + +```ts +if ((nuxt.options as any).autoAdmin) { + (nuxt.options as any).autoAdmin = { + ...(nuxt.options as any).autoAdmin, + resources: { + posts: { + ..., + actions: { + publish: { + label: 'Publish', + icon: 'i-heroicons-paper-airplane', + type: 'single', + location: 'row', + permission: (ctx) => ctx.user?.roles?.includes('editor'), + confirm: (item) => `Publish "${item.title}"?`, + handler: async (item, ctx) => { + await $fetch(`/api/posts/${item.id}`, { + method: 'PATCH', + body: { status: 'published', publishedAt: new Date() }, + }) + await ctx.refresh() + }, + }, + }, + }, + ...((nuxt.options as any).autoAdmin.resources ?? {}), + }, + } +} +``` + +--- + +## Detecting Admin Presence + +```ts +// Robust check — works even if autoAdmin key was set but not initialized +const hasAdmin = !!(nuxt.options as any).autoAdmin + || nuxt.options.modules?.some(m => String(m).includes('nuxt-auto-admin')) +``` + +--- + +## Full Module Checklist + +1. `autoApi:registerSchema` hook → register all tables (including junction tables) +2. Guard `if (nuxt.options.autoAdmin)` before injecting display config +3. Mark junction tables as `type: 'junction'` +4. Define `formFields.create` and `formFields.edit` for non-trivial resources +5. Spread `...((nuxt.options as any).autoAdmin.resources ?? {})` LAST +6. Export `ResourceConfig` types if the module has a public API diff --git a/.claude/skills/nuxt-auto-api/SKILL.md b/.claude/skills/nuxt-auto-api/SKILL.md new file mode 100644 index 0000000..e16b153 --- /dev/null +++ b/.claude/skills/nuxt-auto-api/SKILL.md @@ -0,0 +1,109 @@ +--- +name: nuxt-auto-api +description: Use when working with nuxt-auto-api (@websideproject/nuxt-auto-api) - schema-driven REST API generation from Drizzle ORM schemas with authorization, hooks, bulk ops, M2M, aggregation, and a plugin system. +license: MIT +--- + +# nuxt-auto-api + +Auto-generates type-safe REST APIs from Drizzle ORM table schemas for Nuxt 4. Register a Drizzle table and instantly get CRUD endpoints, filtering, sorting, pagination, relations, permissions, soft-delete, bulk ops, aggregation, and M2M — with lifecycle hooks and a plugin system. + +## When to Use + +- Registering Drizzle tables as auto-generated API resources +- Using `useAutoApiList`, `useAutoApiGet`, `useAutoApiCreate`, `useAutoApiUpdate`, `useAutoApiDelete` +- Configuring authorization (`permissions`, `objectLevel`, `listFilter`) +- Writing lifecycle hooks (`beforeCreate`, `afterCreate`, etc.) +- Bulk operations (`useAutoApiBulkCreate/Update/Delete`) +- Aggregations (`useAutoApiAggregate`) +- M2M relationships (`useM2MRelation`, `useM2MSync`, `useM2MAdd`, `useM2MRemove`) +- Checking permissions (`usePermissions`, `useAllPermissions`) +- Building plugins with `defineAutoApiPlugin` + +## Quick Start + +```ts +// nuxt.config.ts +export default defineNuxtConfig({ + modules: ['@websideproject/nuxt-auto-api'], + autoApi: { + prefix: '/api', + database: { client: 'd1' }, + }, +}) + +// server/plugins/register-api.ts — register resources via hook +export default defineNitroPlugin((nitroApp) => { + // Resources are registered at build time via nuxt hook: + // nuxt.hook('autoApi:registerSchema', (registry) => { registry.register(...) }) +}) +``` + +```vue + + +``` + +## Auto-Generated Endpoints + +For each resource `{name}`: +``` +GET /api/{name} List (filter, sort, paginate, include relations) +GET /api/{name}/:id Get by ID +POST /api/{name} Create +PATCH /api/{name}/:id Update +DELETE /api/{name}/:id Delete (soft-delete if deletedAt column exists) +POST /api/{name}/:id/restore Restore soft-deleted +GET /api/{name}/permissions Per-resource permissions +POST /api/{name}/bulk Bulk create +PATCH /api/{name}/bulk Bulk update +DELETE /api/{name}/bulk Bulk delete +GET /api/{name}/aggregate Aggregate (count/sum/avg/min/max + groupBy) +GET /api/{name}/:id/relations/:rel List M2M +POST /api/{name}/:id/relations/:rel Sync M2M +POST /api/{name}/:id/relations/:rel/add Add M2M +DELETE /api/{name}/:id/relations/:rel/remove Remove M2M +GET /api/permissions All resource permissions +``` + +## Available Guidance + +| File | Topics | +|------|--------| +| **[references/setup.md](references/setup.md)** | Module options, database adapters, resource registration, built-in plugins list | +| **[references/composables-query.md](references/composables-query.md)** | useAutoApiList, useAutoApiGet, useAutoApiInfinite — all query params | +| **[references/composables-mutations.md](references/composables-mutations.md)** | useAutoApiCreate/Update/Delete/Mutation, bulk ops, optimistic updates | +| **[references/authorization.md](references/authorization.md)** | ResourceAuthConfig, HandlerContext, permissions, listFilter, objectLevel, fields | +| **[references/hooks-plugins.md](references/hooks-plugins.md)** | ResourceHooks (all lifecycle events), defineAutoApiPlugin, middleware, context extenders | +| **[references/m2m.md](references/m2m.md)** | M2M config, useM2MRelation, useM2MSync, useM2MAdd, useM2MRemove, useM2MBatchSync | +| **[references/advanced.md](references/advanced.md)** | useAutoApiAggregate, usePermissions, useAllPermissions, multi-tenancy, custom endpoints | +| **[references/module-authoring.md](references/module-authoring.md)** | Building a Nuxt module that registers resources: hook, createModuleImport, schema/auth/hooks co-location | + +## Progressive Loading + +- Setting up the module or registering resources? → [references/setup.md](references/setup.md) +- Reading/listing data? → [references/composables-query.md](references/composables-query.md) +- Creating/updating/deleting? → [references/composables-mutations.md](references/composables-mutations.md) +- Controlling who can do what? → [references/authorization.md](references/authorization.md) +- Reacting to CRUD events or writing plugins? → [references/hooks-plugins.md](references/hooks-plugins.md) +- Many-to-many relationships? → [references/m2m.md](references/m2m.md) +- Aggregations, permissions, multi-tenancy? → [references/advanced.md](references/advanced.md) +- Building a Nuxt module that ships resources? → [references/module-authoring.md](references/module-authoring.md) + +**DO NOT read all files at once.** + +## Related Skills + +- **`nuxt-auto-admin`** — admin UI that consumes these API endpoints +- **`nuxt`** — Nuxt 4 server routes and module patterns +- **`nuxthub`** — Cloudflare D1/KV/Blob storage + +_Token efficiency: Main skill ~400 tokens. Each reference ~800–1400 tokens._ diff --git a/.claude/skills/nuxt-auto-api/references/advanced.md b/.claude/skills/nuxt-auto-api/references/advanced.md new file mode 100644 index 0000000..00cacb8 --- /dev/null +++ b/.claude/skills/nuxt-auto-api/references/advanced.md @@ -0,0 +1,201 @@ +# Advanced: Aggregations, Permissions, Multi-Tenancy + +## `useAutoApiAggregate` — SQL Aggregations + +```ts +const { data, isLoading } = useAutoApiAggregate( + 'orders', + aggregateOptions, // MaybeRef + queryOptions? +) +``` + +### `AggregateOptions` + +```ts +interface AggregateOptions { + aggregate?: 'count' | 'sum' | 'avg' | 'min' | 'max' | string[] + field?: string // field to aggregate (required for sum/avg/min/max) + groupBy?: string | string[] // group by field(s) + having?: Record // filter on aggregated values + filter?: Record // WHERE filter before aggregation +} +``` + +### Examples + +```ts +// Count all posts +useAutoApiAggregate('posts', { aggregate: 'count' }) +// → { _count: 142 } + +// Sum revenue by status +useAutoApiAggregate('orders', { + aggregate: 'sum', + field: 'amount', + groupBy: 'status', +}) +// → [{ status: 'paid', _sum: 48200 }, { status: 'pending', _sum: 3100 }] + +// Average order value, only completed orders +useAutoApiAggregate('orders', { + aggregate: 'avg', + field: 'amount', + filter: { status: 'completed' }, +}) + +// Multiple aggregations +useAutoApiAggregate('orders', { + aggregate: ['count', 'sum', 'avg'], + field: 'amount', + groupBy: ['month', 'region'], + having: { _count: { gt: 5 } }, // only groups with more than 5 orders +}) +``` + +### Cache key + +```ts +['autoapi', resourceName, 'aggregate', aggregateOptions] +``` + +--- + +## `usePermissions` — Per-resource permissions + +```ts +const { + permissions, // Ref + canCreate, // Ref + canRead, // Ref + canUpdate, // Ref + canDelete, // Ref + isLoading, + error, +} = usePermissions( + 'posts', // MaybeRef + options?: { + individual?: boolean // default: false — use global /api/permissions endpoint + } +) +``` + +By default uses `GET /api/permissions` (fetches all resources at once, cached as `['permissions', 'all']`). Set `individual: true` to use `GET /api/posts/permissions`. + +### `PermissionCheckResult` + +```ts +interface PermissionCheckResult { + canCreate: boolean + canRead: boolean + canUpdate: boolean + canDelete: boolean + fields?: { + [fieldName: string]: { + canRead: boolean + canWrite: boolean + } + } +} +``` + +--- + +## `useAllPermissions` — All resource permissions + +Fetches all resource permissions in one request. More efficient than calling `usePermissions` per resource. + +```ts +const { data, isLoading } = useAllPermissions(options?) + +// data.value = { +// user: { id: 1, ... }, +// permissions: { +// posts: { canCreate: true, canRead: true, canUpdate: true, canDelete: false }, +// comments: { canCreate: true, canRead: true, canUpdate: false, canDelete: false }, +// } +// } +``` + +Cache key: `['permissions', 'all']` + +--- + +## Multi-Tenancy + +Configure in `autoApi.multiTenancy`: + +```ts +multiTenancy: { + enabled: true, + tenantIdField: 'organizationId', // default: 'organizationId' + + // How to resolve the current tenant from the request + getTenantId: async (event) => { + const session = await getAuthSession(event) + return session?.user?.organizationId ?? null + }, + + // Which resources enforce tenant isolation + scopedResources: '*', // all resources + // scopedResources: ['posts', 'tasks'], + excludedResources: ['plans', 'features'], + + // Who can bypass tenant scoping + allowCrossTenantAccess: (user) => user.roles?.includes('superadmin'), + + // Reject requests with no resolvable tenant + requireTenant: false, +} +``` + +When tenant isolation is active: +- **List**: WHERE `tenantId = ` is automatically appended +- **Get**: Returns 404 if `tenantId` doesn't match +- **Create**: Automatically sets `tenantId` on new records +- **Update/Delete**: Only affects records belonging to the current tenant + +--- + +## Custom Endpoints + +Use `createEndpoint()` (from `@websideproject/nuxt-auto-api/utils`) to build custom server routes with shared auth/validation: + +```ts +// server/api/posts/export.get.ts +import { createEndpoint } from '@websideproject/nuxt-auto-api/utils' +import { z } from 'zod' + +export default createEndpoint({ + resource: 'posts', + operation: 'list', // re-uses list authorization + query: z.object({ format: z.enum(['csv', 'json']).default('json') }), + skipAuthorization: false, + handler: async (ctx, event) => { + const posts = await ctx.db.query.posts.findMany({ ... }) + if (ctx.validated.query.format === 'csv') { + return toCsv(posts) + } + return { data: posts } + }, + responseFormat: 'raw', // bypass the { data: } wrapper +}) +``` + +### `EndpointOptions` + +```ts +interface EndpointOptions { + resource?: string + operation?: HandlerContext['operation'] + body?: ZodSchema // Zod schema for request body validation + query?: ZodSchema // Zod schema for query string validation + skipAuthorization?: boolean + skipValidation?: boolean + handler: (ctx: EndpointContext, event: H3Event) => Promise|TResponse + transform?: (data: TResponse, ctx: EndpointContext) => any + responseFormat?: 'auto' | 'raw' // 'auto' = wrap in { data: ... }, 'raw' = pass-through +} +``` + +`EndpointContext` extends `HandlerContext` with `validated.body` and `validated.query` typed by the Zod schemas. diff --git a/.claude/skills/nuxt-auto-api/references/authorization.md b/.claude/skills/nuxt-auto-api/references/authorization.md new file mode 100644 index 0000000..f83bfac --- /dev/null +++ b/.claude/skills/nuxt-auto-api/references/authorization.md @@ -0,0 +1,205 @@ +# Authorization + +Authorization is configured per-resource via `autoApi.authorization` in `nuxt.config.ts` or via the `authorization` field in `registry.register()`. + +--- + +## `ResourceAuthConfig` + +```ts +interface ResourceAuthConfig { + // Operation-level guards + permissions?: { + read?: PermissionRule // GET list + get + create?: PermissionRule // POST + update?: PermissionRule // PATCH + delete?: PermissionRule // DELETE + m2m?: M2MPermissionConfig + } + + // Post-fetch item filter (applied per item after list query) + objectLevel?: (object: any, ctx: HandlerContext) => boolean | Promise + + // SQL WHERE clause filter (applied before query — more efficient than objectLevel) + listFilter?: (table: any, ctx: HandlerContext) => SQL | undefined + + // Field-level guards + fields?: { + [fieldName: string]: { + read?: PermissionRule + write?: PermissionRule + } + } +} + +type PermissionRule = + | string // single role/permission string + | string[] // any of these roles/permissions + | ((ctx: HandlerContext) => boolean | Promise) +``` + +--- + +## `HandlerContext` + +Available in all authorization functions, hooks, and plugin middleware. + +```ts +interface HandlerContext { + db: any // Drizzle database instance + schema: any // Drizzle schema (table exports) + fullSchema?: any // Full schema including relations + user: AuthUser | null // Current authenticated user (null if unauthenticated) + permissions: string[] // User's permission strings + params: Record // Route params (e.g. { id: '123' }) + query: Record // Query string params + validated: { body?: any; query?: any } // Zod-validated request data + event: H3Event // Raw H3/Nitro event + resource: string // Resource name (e.g. 'posts') + operation: 'list' | 'get' | 'create' | 'update' | 'delete' | 'bulk' | 'aggregate' | 'm2m' + tenant?: { id: string|number; field: string; canAccessAllTenants: boolean } + requestMeta?: { + ip?: string; country?: string; userAgent?: string; [key: string]: any + } + additionalFilters?: SQL[] // Push extra SQL conditions here (in hooks/plugins) +} + +interface AuthUser { + id: string | number + email?: string + roles?: string[] + permissions?: string[] + [key: string]: any +} +``` + +--- + +## Examples + +### Role-based (string match) + +```ts +authorization: { + posts: { + permissions: { + read: 'user', // any user with role 'user' + create: ['editor', 'admin'], // editor or admin + update: ['editor', 'admin'], + delete: 'admin', // only admin + }, + }, +} +``` + +### Function-based (custom logic) + +```ts +authorization: { + posts: { + permissions: { + read: (ctx) => ctx.user !== null, // must be authenticated + create: (ctx) => ctx.user?.roles?.includes('editor'), + update: async (ctx) => { + // Check subscription tier from DB + const user = await getUserWithPlan(ctx.db, ctx.user!.id) + return user.plan === 'pro' + }, + }, + }, +} +``` + +### Object-level authorization (post-fetch) + +Applied per item after the list query runs. Use `listFilter` for performance when possible. + +```ts +authorization: { + documents: { + objectLevel: (doc, ctx) => { + // Only own documents or public ones + return doc.ownerId === ctx.user?.id || doc.visibility === 'public' + }, + }, +} +``` + +### SQL-level filter (`listFilter`) — preferred + +Appended to the WHERE clause — more efficient than objectLevel for large datasets. + +```ts +import { eq, or } from 'drizzle-orm' + +authorization: { + documents: { + listFilter: (table, ctx) => { + if (!ctx.user) return eq(table.visibility, 'public') + return or( + eq(table.ownerId, ctx.user.id), + eq(table.visibility, 'public') + ) + }, + }, +} +``` + +### Field-level authorization + +```ts +authorization: { + users: { + fields: { + email: { + read: (ctx) => ctx.user?.id === ctx.params.id || ctx.user?.roles?.includes('admin'), + write: (ctx) => ctx.user?.id === ctx.params.id, + }, + salary: { + read: 'hr', // only hr role + write: 'hr', + }, + }, + }, +} +``` + +--- + +## M2M Permission Config + +```ts +permissions: { + m2m?: { + read?: PermissionRule + sync?: PermissionRule // POST /:id/relations/:rel + add?: PermissionRule // POST /:id/relations/:rel/add + remove?: PermissionRule // DELETE /:id/relations/:rel/remove + } +} +``` + +--- + +## Authorization from Module Registration + +Per-resource auth can be defined alongside the schema in `registry.register()`: + +```ts +registry.register('posts', { + schema: createModuleImport(resolver.resolve('./schema'), 'posts'), + authorization: createModuleImport(resolver.resolve('./auth'), 'postsAuth'), +}) + +// auth.ts +export const postsAuth: ResourceAuthConfig = { + permissions: { + read: (ctx) => !!ctx.user, + create: ['editor', 'admin'], + update: (ctx) => ctx.user?.roles?.includes('admin'), + delete: 'admin', + }, + listFilter: (table, ctx) => + ctx.user ? undefined : eq(table.status, 'published'), +} +``` diff --git a/.claude/skills/nuxt-auto-api/references/composables-mutations.md b/.claude/skills/nuxt-auto-api/references/composables-mutations.md new file mode 100644 index 0000000..d562e92 --- /dev/null +++ b/.claude/skills/nuxt-auto-api/references/composables-mutations.md @@ -0,0 +1,160 @@ +# Mutation Composables + +All mutation composables auto-invalidate the relevant cache keys on success. + +--- + +## `useAutoApiMutation` — Unified API (recommended) + +Dispatches to create/update/delete based on the `action` parameter. + +```ts +const mutation = useAutoApiMutation( + 'posts', + 'create', // 'create' | 'update' | 'delete' + options? +) + +mutation.mutate(body) +await mutation.mutateAsync(body) +``` + +--- + +## `useAutoApiCreate` — Create + +```ts +const { mutate, mutateAsync, isPending, error } = useAutoApiCreate( + 'posts', + options? // UseMutationOptions & { toast?: AutoApiToastOptions } +) + +// Usage +mutate({ title: 'Hello', status: 'draft' }) + +// With toast feedback +useAutoApiCreate('posts', { + toast: { enabled: true, showSuccess: true, showErrors: true }, + onSuccess: (result) => navigateTo(`/posts/${result.data.id}`), +}) +``` + +Auto-invalidates: `['autoapi', 'posts', 'list', *]` + +--- + +## `useAutoApiUpdate` — Update + +```ts +const { mutate, isPending } = useAutoApiUpdate('posts', options?) + +// Must include `id` in the mutation variables +mutate({ id: 1, title: 'Updated', status: 'published' }) +``` + +Auto-invalidates: `['autoapi', 'posts', 'get', id, *]` and `['autoapi', 'posts', 'list', *]` + +--- + +## `useAutoApiDelete` — Delete + +```ts +const { mutate, isPending } = useAutoApiDelete('posts', options?) + +mutate(postId) // string | number +``` + +Auto-removes: `['autoapi', 'posts', 'get', id, *]` and invalidates list. + +--- + +## Toast Options + +```ts +interface AutoApiToastOptions { + enabled?: boolean + showSuccess?: boolean + showErrors?: boolean +} +``` + +Requires `@nuxt/ui` `useToast()` available in the app. + +--- + +## Bulk Operations + +### `useAutoApiBulkCreate` + +```ts +const { mutate } = useAutoApiBulkCreate('posts', options?) + +mutate([ + { title: 'Post 1', status: 'draft' }, + { title: 'Post 2', status: 'draft' }, +]) +// Response: BulkOperationResponse +// { data: Post[], meta: { total, successful, failed, errors? } } +``` + +### `useAutoApiBulkUpdate` + +```ts +const { mutate } = useAutoApiBulkUpdate('posts', options?) + +mutate([ + { id: 1, status: 'published' }, + { id: 2, status: 'published' }, +]) +``` + +### `useAutoApiBulkDelete` + +```ts +const { mutate } = useAutoApiBulkDelete('posts', options?) + +mutate([1, 2, 3]) +// Response: { success: boolean, deleted: number } +``` + +--- + +## Optimistic Updates + +```ts +import { useAutoApiOptimisticUpdate } from '@websideproject/nuxt-auto-api' + +// Inside onMutate callback +const { queryKey, previousData } = useAutoApiOptimisticUpdate( + 'posts', + postId, + { title: 'Optimistically updated' } +) + +// Rollback on error +onError: () => { + queryClient.setQueryData(queryKey, previousData) +} +``` + +--- + +## Response Types + +```ts +// Single resource (create/update/get) +interface SingleResponse { + data: T +} + +// Bulk operations +interface BulkOperationResponse { + data: T[] + meta: { + total: number + successful: number + failed: number + errors?: Array<{ index: number; id?: string | number; error: string }> + } +} +``` diff --git a/.claude/skills/nuxt-auto-api/references/composables-query.md b/.claude/skills/nuxt-auto-api/references/composables-query.md new file mode 100644 index 0000000..7a89a85 --- /dev/null +++ b/.claude/skills/nuxt-auto-api/references/composables-query.md @@ -0,0 +1,146 @@ +# Query Composables + +All query composables use **TanStack Query (Vue Query)** under the hood and are auto-imported. They cache responses and revalidate automatically. + +--- + +## `useAutoApiList` — Fetch a list + +```ts +const { data, isLoading, error, refetch } = useAutoApiList( + 'posts', // resource name (MaybeRef) + params?, // MaybeRef + options? // Vue Query UseQueryOptions +) +``` + +### `ListQueryParams` + +```ts +interface ListQueryParams { + filter?: Record // field equality or operator filters + sort?: string | string[] // field name, prefix '-' for descending + page?: number // offset pagination page number + limit?: number // items per page (default: 20, max: 100) + cursor?: string // cursor pagination (instead of page) + include?: string | string[] // relation names to load + fields?: string | string[] // select only these fields +} +``` + +### Filtering examples + +```ts +// Equality +filter: { status: 'published', authorId: 5 } + +// Operators (server-side support varies) +filter: { price: { gt: 100, lte: 500 } } +filter: { title: { like: '%nuxt%' } } +filter: { tags: { in: ['vue', 'nuxt'] } } + +// Null checks +filter: { deletedAt: null } +filter: { publishedAt: { not: null } } +``` + +### Sorting examples + +```ts +sort: 'createdAt' // ascending +sort: '-createdAt' // descending +sort: ['-publishedAt', 'title'] // multi-sort +``` + +### Including relations + +```ts +include: ['author', 'tags', 'category.parent'] +``` + +### Return value (`ListResponse`) + +```ts +data.value = { + data: T[], + meta: { + total?: number, + page?: number, + limit?: number, + cursor?: string, + nextCursor?: string, + hasMore?: boolean, + } +} +``` + +### Reactive params + +```ts +const status = ref('published') +const { data } = useAutoApiList('posts', computed(() => ({ + filter: { status: status.value }, + sort: '-createdAt', +}))) +// Re-fetches automatically when status.value changes +``` + +--- + +## `useAutoApiGet` — Fetch single resource + +```ts +const { data, isLoading, error } = useAutoApiGet( + 'posts', + id, // MaybeRef + params?, // MaybeRef<{ include?: string|string[], fields?: string|string[] }> + options? +) +``` + +```ts +// With relations +const { data } = useAutoApiGet('posts', postId, { include: ['author', 'comments'] }) +// data.value = { data: Post & { author: User, comments: Comment[] } } +``` + +--- + +## `useAutoApiInfinite` — Infinite scroll + +Uses cursor-based pagination for infinite lists. + +```ts +const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, +} = useAutoApiInfinite( + 'posts', + params?, // Omit — cursor is managed internally + options? +) +``` + +```ts +// data.value = { pages: [ListResponse, ...] } +const allItems = computed(() => + data.value?.pages.flatMap(p => p.data) ?? [] +) +``` + +--- + +## Cache Keys + +TanStack Query cache keys (for manual invalidation): + +```ts +['autoapi', resourceName, 'list', params] // useAutoApiList +['autoapi', resourceName, 'get', id, params] // useAutoApiGet +['autoapi', resourceName, 'infinite', params] // useAutoApiInfinite +``` + +Mutation composables automatically invalidate the relevant keys on success. diff --git a/.claude/skills/nuxt-auto-api/references/hooks-plugins.md b/.claude/skills/nuxt-auto-api/references/hooks-plugins.md new file mode 100644 index 0000000..aad22b9 --- /dev/null +++ b/.claude/skills/nuxt-auto-api/references/hooks-plugins.md @@ -0,0 +1,199 @@ +# Hooks & Plugins + +## Resource Hooks + +Lifecycle hooks fire around every CRUD operation. Register via `autoApi.hooks` config or inside a plugin. + +```ts +interface ResourceHooks { + // Create + beforeCreate?: (data: any, ctx: HandlerContext) => Promise | any + afterCreate?: (result: any, ctx: HandlerContext) => Promise | any + + // Update + beforeUpdate?: (id: string|number, data: any, ctx: HandlerContext) => Promise | any + afterUpdate?: (result: any, ctx: HandlerContext) => Promise | any + + // Delete + beforeDelete?: (id: string|number, ctx: HandlerContext) => Promise | void + afterDelete?: (id: string|number, ctx: HandlerContext) => Promise | void + + // List + beforeList?: (ctx: HandlerContext) => Promise | void + afterList?: (results: any[], ctx: HandlerContext) => Promise | any[]|void + + // Get + beforeGet?: (id: string|number, ctx: HandlerContext) => Promise | void + afterGet?: (result: any, ctx: HandlerContext) => Promise | any + + // M2M + beforeM2MSync?: (relation: string, ids: any[], ctx: HandlerContext) => Promise|any + afterM2MSync?: (relation: string, result: any, ctx: HandlerContext) => Promise|void + beforeM2MAdd?: (relation: string, ids: any[], ctx: HandlerContext) => Promise|any + afterM2MAdd?: (relation: string, result: any, ctx: HandlerContext) => Promise|void + beforeM2MRemove?: (relation: string, ids: any[], ctx: HandlerContext) => Promise|void + afterM2MRemove?: (relation: string, result: any, ctx: HandlerContext) => Promise|void +} +``` + +**Return values from `after*` hooks modify the response** sent to the client. +**Return values from `before*` hooks modify the data** passed to the operation (except void hooks). + +### Static hook registration (nuxt.config.ts) + +```ts +autoApi: { + hooks: { + posts: { + beforeCreate: async (data, ctx) => { + data.authorId = ctx.user!.id + data.slug = generateSlug(data.title) + return data + }, + afterCreate: async (result, ctx) => { + await sendNotification(result.id) + return result + }, + beforeDelete: async (id, ctx) => { + await cleanupPostAssets(id) + }, + }, + }, +} +``` + +### Via module registration + +```ts +registry.register('posts', { + schema: ..., + hooks: createModuleImport(resolver.resolve('./hooks'), 'postsHooks'), +}) +``` + +--- + +## Plugin System + +Plugins can add middleware, hooks, and context extensions at **runtime**. They can also register Nuxt server handlers and imports at **build time**. + +### `defineAutoApiPlugin` + +```ts +import { defineAutoApiPlugin } from '@websideproject/nuxt-auto-api' + +const MyPlugin = defineAutoApiPlugin({ + name: 'my-plugin', + version: '1.0.0', + + // Build-time: add server handlers, imports, templates + buildSetup?: async (ctx: PluginBuildContext) => { + ctx.addServerHandler({ route: '/api/custom', handler: ... }) + ctx.addImportsDir(resolver.resolve('./composables')) + ctx.logger.info('Plugin registered') + }, + + // Runtime: add middleware, hooks, context extensions + runtimeSetup?: async (ctx: PluginRuntimeContext) => { + // Add middleware for specific resources/operations + ctx.addMiddleware({ + name: 'my-middleware', + order?: 10, + stage: 'post-auth', // 'pre-auth' | 'post-auth' | 'pre-execute' | 'post-execute' + resources?: ['posts', 'comments'], // omit = all resources + operations?: ['create', 'update'], // omit = all operations + handler: async (handlerCtx) => { + // Mutate handlerCtx, add additionalFilters, short-circuit, etc. + handlerCtx.additionalFilters?.push(eq(table.active, true)) + }, + }) + + // Add hooks for a specific resource + ctx.addHook('posts', { + afterCreate: async (result, ctx) => { ... }, + }) + + // Add hooks for all resources + ctx.addGlobalHook({ + beforeCreate: async (data, ctx) => { + data.createdAt = new Date() + return data + }, + }) + + // Extend HandlerContext with custom data + ctx.extendContext(async (handlerCtx) => { + const featureFlags = await getFeatureFlags(handlerCtx.event) + handlerCtx.requestMeta = { ...handlerCtx.requestMeta, featureFlags } + }) + }, +}) +``` + +### Register in config + +```ts +autoApi: { + plugins: [MyPlugin], + // or plugin directory: + // plugins: './server/plugins/auto-api', +} +``` + +--- + +## Middleware Stages + +| Stage | When it runs | +|-------|-------------| +| `pre-auth` | Before authorization checks | +| `post-auth` | After authorization, before DB operation | +| `pre-execute` | Just before the DB query | +| `post-execute` | After DB query, before response | + +--- + +## Short-Circuiting + +From a middleware handler, set `ctx.shortCircuit` to skip the remaining pipeline and return a custom response: + +```ts +handler: async (ctx) => { + const cached = await cache.get(ctx.resource + ctx.params.id) + if (cached) { + ctx.shortCircuit = { data: cached, status: 200 } + } +} +``` + +--- + +## PluginBuildContext + +```ts +interface PluginBuildContext { + addServerHandler: (handler: any) => void + addServerImportsDir: (dir: string) => void + addImportsDir: (dir: string) => void + addServerPlugin: (plugin: string) => void + addPlugin: (plugin: any) => void + addTemplate: (template: any) => void + options: AutoApiOptions + nuxt: Nuxt + resolver: Resolver + logger: PluginLogger +} +``` + +## PluginRuntimeContext + +```ts +interface PluginRuntimeContext { + addMiddleware: (middleware: AutoApiMiddleware) => void + addHook: (resource: string, hooks: ResourceHooks) => void + addGlobalHook: (hooks: ResourceHooks) => void + extendContext: (fn: (ctx: HandlerContext) => void|Promise) => void + runtimeConfig: any + logger: PluginLogger +} +``` diff --git a/.claude/skills/nuxt-auto-api/references/m2m.md b/.claude/skills/nuxt-auto-api/references/m2m.md new file mode 100644 index 0000000..5920ea6 --- /dev/null +++ b/.claude/skills/nuxt-auto-api/references/m2m.md @@ -0,0 +1,201 @@ +# Many-to-Many (M2M) Relations + +M2M relationships are managed through junction tables. nuxt-auto-api auto-detects junction tables by convention, or they can be configured explicitly. + +--- + +## M2M Configuration + +```ts +autoApi: { + m2m?: { + autoDetect?: boolean // default: true — detect junction tables automatically + + relations?: { + // Manual config: posts.tags (junction: post_tags) + posts: { + tags: { + junctionTable: 'post_tags', // Drizzle schema export name + leftKey: 'postId', // FK → source resource + rightKey: 'tagId', // FK → related resource + label?: 'Tags', + help?: string, + displayField?: 'name', // field to show in UI + metadataColumns?: ['order', 'addedAt'], // extra junction columns + }, + }, + }, + }, +} +``` + +Junction table detection: a table with exactly two foreign keys and no other non-FK columns is treated as a junction table. + +--- + +## M2M Endpoints (auto-generated) + +``` +GET /api/{resource}/:id/relations/{relation} List related IDs (+ optional records) +POST /api/{resource}/:id/relations/{relation} Sync (replace all relations) +POST /api/{resource}/:id/relations/{relation}/add Add relations +DELETE /api/{resource}/:id/relations/{relation}/remove Remove relations +POST /api/{resource}/:id/relations/batch Batch sync multiple relations +``` + +--- + +## `useM2MRelation` — Query relations + +```ts +const { data, isLoading } = useM2MRelation( + 'posts', // source resource + postId, // source ID + 'tags', // relation name + params?, // M2MListQuery + options? +) +``` + +### `M2MListQuery` + +```ts +interface M2MListQuery { + includeRecords?: boolean | string // include full records (not just IDs) + includeMetadata?: boolean | string // include junction table metadata columns + fields?: string | string[] // select fields from related records + filter?: Record // filter related records + sort?: string | string[] + limit?: number + offset?: number +} +``` + +### `M2MListResponse` + +```ts +interface M2MListResponse { + ids: Array // always present + records?: T[] // present when includeRecords: true + metadata?: Array> // junction table extra columns + total: number + meta?: { limit?: number; offset?: number; hasMore?: boolean } +} +``` + +--- + +## `useM2MSync` — Replace all relations (sync) + +Replaces the entire set of relations in one atomic operation. + +```ts +const { mutate, isPending } = useM2MSync( + 'posts', + postId, + 'tags', + options? +) + +mutate({ + ids: [1, 2, 3], // new complete set of related IDs + metadata?: [ // optional junction metadata per relation + { order: 1, addedAt: new Date() }, + { order: 2, addedAt: new Date() }, + { order: 3, addedAt: new Date() }, + ], + replace?: true, // default: true +}) +// Returns: M2MOperationResponse +``` + +Supports **optimistic updates** with automatic rollback on error. + +--- + +## `useM2MAdd` — Append relations + +Adds new relations without touching existing ones. + +```ts +const { mutate } = useM2MAdd('posts', postId, 'tags', options?) + +mutate({ + ids: [4, 5], + metadata?: [{ order: 4 }, { order: 5 }], +}) +``` + +--- + +## `useM2MRemove` — Remove relations + +```ts +const { mutate } = useM2MRemove('posts', postId, 'tags', options?) + +mutate({ ids: [2, 3] }) +``` + +--- + +## `useM2MBatchSync` — Sync multiple relations at once + +Updates several M2M relations in a single request/transaction. + +```ts +const { mutate } = useM2MBatchSync('posts', postId, options?) + +mutate({ + relations: { + tags: { ids: [1, 2, 3] }, + categories: { ids: [5], metadata: [{ primary: true }] }, + authors: { ids: [10, 11] }, + }, +}) +// Returns: M2MBatchSyncResponse +// { success, results: { tags: { added, removed, total }, ... } } +``` + +--- + +## `M2MOperationResponse` + +```ts +interface M2MOperationResponse { + success: boolean + added?: number + removed?: number + total: number + error?: string +} +``` + +--- + +## M2M in Authorization + +```ts +authorization: { + posts: { + permissions: { + m2m: { + read: 'user', + sync: ['editor', 'admin'], + add: 'editor', + remove: 'editor', + }, + }, + }, +} +``` + +--- + +## M2M Detection Endpoints (for admin/tooling) + +``` +GET /api/_m2m/detect/:resource Detect M2M relations for a resource +GET /api/_m2m/is-junction/:table Check if a table is a junction table +GET /api/_m2m/junctions List all detected junction tables +GET /api/_m2m/debug-detection Debug M2M detection across all tables +``` diff --git a/.claude/skills/nuxt-auto-api/references/module-authoring.md b/.claude/skills/nuxt-auto-api/references/module-authoring.md new file mode 100644 index 0000000..bf0a4c5 --- /dev/null +++ b/.claude/skills/nuxt-auto-api/references/module-authoring.md @@ -0,0 +1,327 @@ +# Module Authoring with nuxt-auto-api + +Building a reusable Nuxt module that registers resources into `nuxt-auto-api`. + +--- + +## How It Works + +nuxt-auto-api exposes a build-time hook `autoApi:registerSchema` that fires after all modules have loaded. Your module hooks into it to register Drizzle table schemas. After all registrations, nuxt-auto-api generates a virtual registry module and wires up HTTP handlers automatically. + +**Registration timing:** The hook runs at `modules:done`. Never try to register resources in `setup()` directly — always use the hook. + +--- + +## Directory Structure + +``` +modules/blog/ +├── module.ts # defineNuxtModule — hooks into autoApi:registerSchema +├── schema.ts # Drizzle table definitions (exported by name) +├── auth.ts # ResourceAuthConfig exports (co-located) +├── hooks.ts # ResourceHooks exports (optional, co-located) +└── runtime/ + └── server/ + └── database/ + └── migrations/ # Drizzle migration files +``` + +--- + +## The Core Pattern + +```ts +// module.ts +import { defineNuxtModule, createResolver } from '@nuxt/kit' +import type { Nuxt } from '@nuxt/schema' +import { createModuleImport } from '@websideproject/nuxt-auto-api' + +export default defineNuxtModule({ + meta: { + name: '@myorg/module-blog', + configKey: 'moduleBlog', + }, + + setup(_options, nuxt: Nuxt) { + const resolver = createResolver(import.meta.url) + + // Register resources into nuxt-auto-api + nuxt.hook('autoApi:registerSchema' as any, (registry: any) => { + registry.register('posts', { + schema: createModuleImport(resolver.resolve('./schema'), 'posts'), + authorization: createModuleImport(resolver.resolve('./auth'), 'postsAuth'), + hooks: createModuleImport(resolver.resolve('./hooks'), 'postsHooks'), + }) + + // Junction table — register but mark as hidden in admin + registry.register('postTags', { + schema: createModuleImport(resolver.resolve('./schema'), 'postTags'), + }) + }) + + // Optionally configure nuxt-auto-admin if it's present + if ((nuxt.options as any).autoAdmin) { + (nuxt.options as any).autoAdmin = { + ...(nuxt.options as any).autoAdmin, + resources: { + posts: { + displayName: 'Blog Posts', + icon: 'i-heroicons-document-text', + group: 'Content', + order: 1, + listFields: ['title', 'status', 'authorId', 'createdAt'], + hiddenFields: ['deletedAt'], + }, + postTags: { type: 'junction' }, // hide from sidebar + // Spread host app's resources AFTER ours so they can override + ...((nuxt.options as any).autoAdmin.resources ?? {}), + }, + } + } + }, +}) +``` + +--- + +## `createModuleImport` + +Creates a **deferred import reference** that nuxt-auto-api resolves during virtual module generation. This avoids circular dependencies at module setup time. + +```ts +import { createModuleImport } from '@websideproject/nuxt-auto-api' + +createModuleImport( + resolver.resolve('./schema'), // absolute path to the file + 'posts' // named export to import from that file +) +// → { __modulePath: '/abs/path/schema', __exportName: 'posts', __isModuleImport: true } +``` + +**Important:** Always use `resolver.resolve()` (from `createResolver(import.meta.url)`) to get an absolute path. Never pass relative paths. + +--- + +## Schema File (`schema.ts`) + +Standard Drizzle table definitions. Export each table by name — this name must match the `__exportName` in `createModuleImport`. + +```ts +// schema.ts +import { sqliteTable, text, integer, primaryKey } from 'drizzle-orm/sqlite-core' +import { relations } from 'drizzle-orm' + +export const posts = sqliteTable('posts', { + id: integer('id').primaryKey({ autoIncrement: true }), + title: text('title').notNull(), + slug: text('slug').unique(), + content: text('content'), + status: text('status', { enum: ['draft', 'published', 'archived'] }).default('draft'), + authorId: integer('author_id'), + publishedAt: integer('published_at', { mode: 'timestamp' }), + createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()), + updatedAt: integer('updated_at', { mode: 'timestamp' }), + deletedAt: integer('deleted_at', { mode: 'timestamp' }), // enables soft-delete +}) + +export const tags = sqliteTable('tags', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull().unique(), +}) + +// Junction table — composite PK enables M2M auto-detection +export const postTags = sqliteTable('post_tags', { + postId: integer('post_id').notNull().references(() => posts.id, { onDelete: 'cascade' }), + tagId: integer('tag_id').notNull().references(() => tags.id, { onDelete: 'cascade' }), + order: integer('order').default(0), // optional metadata column +}, (t) => ({ + pk: primaryKey({ columns: [t.postId, t.tagId] }), +})) + +// Drizzle relations (used for include: [] queries) +export const postsRelations = relations(posts, ({ one, many }) => ({ + author: one(users, { fields: [posts.authorId], references: [users.id] }), + postTags: many(postTags), +})) + +export const postTagsRelations = relations(postTags, ({ one }) => ({ + post: one(posts, { fields: [postTags.postId], references: [posts.id] }), + tag: one(tags, { fields: [postTags.tagId], references: [tags.id] }), +})) +``` + +**Junction table rules for M2M auto-detection:** +- Exactly two foreign key columns +- Composite `primaryKey` on both FK columns +- Any additional columns become `metadataColumns` + +--- + +## Authorization File (`auth.ts`) + +```ts +// auth.ts +import { eq } from 'drizzle-orm' +import type { ResourceAuthConfig } from '@websideproject/nuxt-auto-api' + +export const postsAuth: ResourceAuthConfig = { + // Operation-level guards + permissions: { + read: () => true, // public read + create: ['editor', 'admin'], // roles + update: (ctx) => ctx.user?.roles?.includes('editor') || ctx.user?.roles?.includes('admin'), + delete: 'admin', + }, + + // SQL WHERE filter — applied before query (correct pagination, efficient) + listFilter: (table, ctx) => { + const isEditor = ctx.user?.roles?.some(r => ['editor', 'admin'].includes(r)) + if (isEditor) return undefined // no filter for editors + return eq(table.status, 'published') + }, + + // Object-level — applied per item after fetch (use for ownership checks) + objectLevel: async (post, ctx) => { + if (ctx.user?.roles?.includes('admin')) return true + if (ctx.operation === 'get') return post.status === 'published' + // For update/delete: only the author + return post.authorId === ctx.user?.id + }, + + // Field-level guards + fields: { + internalNotes: { + read: (ctx) => ctx.user?.roles?.includes('admin'), + write: (ctx) => ctx.user?.roles?.includes('admin'), + }, + }, +} +``` + +--- + +## Hooks File (`hooks.ts`) + +```ts +// hooks.ts +import type { ResourceHooks } from '@websideproject/nuxt-auto-api' +import { generateSlug } from '../utils/slug' + +export const postsHooks: ResourceHooks = { + beforeCreate: async (data, ctx) => { + data.authorId = ctx.user!.id + data.slug = data.slug || generateSlug(data.title) + data.createdAt = new Date() + return data + }, + + beforeUpdate: async (id, data, ctx) => { + data.updatedAt = new Date() + return data + }, + + afterCreate: async (result, ctx) => { + // Side effects: notifications, search indexing, etc. + await notifySubscribers(result.id) + return result + }, + + beforeDelete: async (id, ctx) => { + // Cleanup before deletion + await cleanupPostAssets(id) + }, +} +``` + +--- + +## Plugin Registration Hook + +For runtime extensions (middleware, global hooks), use `autoApi:registerPlugins`: + +```ts +nuxt.hook('autoApi:registerPlugins' as any, (ctx: any) => { + ctx.addGlobalHook({ + beforeCreate: (data, handlerCtx) => { + // Runs for ALL resources in this module's app + data.tenantId = handlerCtx.tenant?.id + return data + }, + }) +}) +``` + +--- + +## TypeScript: Augmenting the Hook Type + +Avoid the `as any` casts by augmenting `@nuxt/schema`: + +```ts +// types/nuxt.d.ts (in your module) +import type { BuildTimeRegistry } from '@websideproject/nuxt-auto-api' + +declare module '@nuxt/schema' { + interface NuxtHooks { + 'autoApi:registerSchema': (registry: BuildTimeRegistry) => void | Promise + } +} +``` + +Then use without cast: +```ts +nuxt.hook('autoApi:registerSchema', (registry) => { + registry.register('posts', { ... }) +}) +``` + +--- + +## `BuildTimeRegistry` Interface + +```ts +interface BuildTimeRegistry { + resources: Map + register(name: string, config: Omit): void + getAll(): ResourceRegistration[] +} + +interface ResourceRegistration { + name: string + schema: any // createModuleImport reference at build time + authorization?: ResourceAuthConfig | any + validation?: any + hooks?: ResourceHooks | any + metadata?: Record + hiddenFields?: string[] +} +``` + +--- + +## Configuring nuxt-auto-admin from a Module + +If the host app has `@websideproject/nuxt-auto-admin` installed, the module can inject resource display config: + +```ts +setup(_options, nuxt) { + // Guard: only configure admin if it's present + if ((nuxt.options as any).autoAdmin) { + (nuxt.options as any).autoAdmin = { + ...(nuxt.options as any).autoAdmin, + resources: { + // Your resource defaults + posts: { + displayName: 'Blog Posts', + icon: 'i-heroicons-document-text', + group: 'Content', + listFields: ['title', 'status', 'createdAt'], + readonlyFields: ['createdAt', 'updatedAt', 'slug'], + }, + // Spread host config LAST so it can override your defaults + ...((nuxt.options as any).autoAdmin.resources ?? {}), + }, + } + } +} +``` diff --git a/.claude/skills/nuxt-auto-api/references/setup.md b/.claude/skills/nuxt-auto-api/references/setup.md new file mode 100644 index 0000000..9814859 --- /dev/null +++ b/.claude/skills/nuxt-auto-api/references/setup.md @@ -0,0 +1,207 @@ +# Setup & Configuration + +## Module Installation + +```ts +// nuxt.config.ts +export default defineNuxtConfig({ + modules: ['@websideproject/nuxt-auto-api'], + autoApi: { + prefix: '/api', // default + database: { + client: 'd1', // see Database Adapters below + url?: string, // for non-serverless clients + }, + }, +}) +``` + +--- + +## Full Module Options (`AutoApiOptions`) + +```ts +interface AutoApiOptions { + prefix?: string // URL prefix, default: '/api' + + database: { + client: DatabaseEngine // required + url?: string + } + + pagination?: { + default?: 'offset' | 'cursor' // default: 'offset' + defaultLimit?: number // default: 20 + maxLimit?: number // default: 100 + } + + authorization?: Record // per-resource auth + + multiTenancy?: MultiTenancyConfig + + plugins?: string | AutoApiPlugin[] // plugin paths or instances + + exclude?: string[] // resource names to skip + include?: string[] // whitelist (exclusive with exclude) + + relations?: { + maxDepth?: number // default: 3 + allowFieldSelection?: boolean // default: true + allowFiltering?: boolean // default: true + allowPagination?: boolean // default: true + } + + bulk?: { + enabled?: boolean // default: true + maxBatchSize?: number // default: 100 + transactional?: boolean // default: true + } + + aggregations?: { + enabled?: boolean // default: true + allowGroupBy?: boolean // default: true + maxGroupByFields?: number // default: 5 + } + + hooks?: Record // static hook registration + + hookConfig?: { + errorHandling?: 'throw' | 'log' // default: 'log' + timeout?: number // ms, default: 5000 + parallel?: boolean // default: false + } + + m2m?: M2MConfig + + hiddenFields?: { + global?: string[] // fields hidden from ALL resources + resources?: Record + } +} +``` + +--- + +## Database Adapters + +```ts +type DatabaseEngine = 'better-sqlite3' | 'postgres' | 'mysql' | 'd1' | 'turso' | 'planetscale' +``` + +| Engine | Notes | +|--------|-------| +| `d1` | Cloudflare D1 (serverless SQLite) — no `url` needed | +| `better-sqlite3` | Local SQLite file — set `url` to file path | +| `postgres` | PostgreSQL via pg — set `url` to connection string | +| `mysql` | MySQL/MariaDB — set `url` to connection string | +| `turso` | Turso (libSQL) — set `url` to Turso DB URL | +| `planetscale` | PlanetScale serverless MySQL | + +--- + +## Resource Registration + +Resources are registered at **build time** via the `autoApi:registerSchema` Nuxt hook. Typically done inside a Nuxt module or local module. + +```ts +// modules/my-api/index.ts +import { defineNuxtModule, createResolver } from '@nuxt/kit' + +export default defineNuxtModule({ + setup(_options, nuxt) { + const resolver = createResolver(import.meta.url) + + nuxt.hook('autoApi:registerSchema', (registry) => { + // Register a resource from a Drizzle schema export + registry.register('posts', { + schema: createModuleImport( + resolver.resolve('../../server/database/schema'), + 'posts' // export name of the Drizzle table + ), + // optional: per-resource authorization, validation, hooks + authorization: createModuleImport( + resolver.resolve('./authorization'), + 'postsAuth' + ), + hooks: createModuleImport( + resolver.resolve('./hooks'), + 'postsHooks' + ), + }) + + registry.register('users', { + schema: createModuleImport( + resolver.resolve('../../server/database/schema'), + 'users' + ), + }) + }) + }, +}) +``` + +The `registry.register(name, config)` call ties a resource name (used in API URLs) to a Drizzle table schema. + +--- + +## Multi-Tenancy + +```ts +multiTenancy: { + enabled: true, + tenantIdField?: 'organizationId', // column name in tenant-scoped tables + getTenantId?: async (event) => { // how to extract tenant ID from request + const user = await getUser(event) + return user?.organizationId ?? null + }, + scopedResources?: ['posts', 'tasks'] | '*', // which resources are tenant-scoped + excludedResources?: ['users'], + allowCrossTenantAccess?: (user) => user.roles?.includes('superadmin'), + requireTenant?: true, // 403 if no tenant ID resolved +} +``` + +--- + +## Hidden Fields (Global) + +```ts +hiddenFields: { + global: ['password', 'passwordHash', 'secret'], // never returned from any resource + resources: { + users: ['internalNotes', 'stripeSecretKey'], + }, +} +``` + +--- + +## Built-In Plugins + +Import from `@websideproject/nuxt-auto-api/plugins`: + +| Plugin | Purpose | +|--------|---------| +| `RateLimitPlugin` | Sliding-window rate limiting by IP/user | +| `RequestMetadataPlugin` | Extract IP, geo, user-agent into context + DB columns | +| `BetterAuthPlugin` | Session auth via better-auth | +| `AuditLogPlugin` | Record all mutations with before/after snapshots | +| `WebhookPlugin` | POST notifications on mutations | +| `ActivityFeedPlugin` | User-facing activity log | +| `SlugGenerationPlugin` | Auto-generate URL slugs from a source field | +| `SchemaValidationPlugin` | Runtime Zod/Valibot schema validation | +| `DataExportPlugin` | CSV/JSON export endpoints | +| `FileUploadPlugin` | File uploads tied to records | +| `RevisionHistoryPlugin` | Full version history with rollback | +| `CachePlugin` | In-memory list/get caching with auto-invalidation | +| `SearchPlugin` | Full-text-like SQL LIKE/ILIKE search | +| `FieldEncryptionPlugin` | AES-256-GCM encryption of sensitive fields | +| `ApiTokenPlugin` | API token management with Bearer auth and scopes | + +```ts +import { BetterAuthPlugin, AuditLogPlugin } from '@websideproject/nuxt-auto-api/plugins' + +autoApi: { + plugins: [BetterAuthPlugin(), AuditLogPlugin({ ... })], +} +``` diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 04abc62..f299db6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,12 +63,55 @@ jobs: fi done + - name: Replace workspace:* with actual versions for publish + run: | + VERSION=${{ steps.version.outputs.version }} + node -e ' + const fs = require("fs"); + const version = process.argv[1]; + const dirs = ["packages/nuxt-auto-api", "packages/nuxt-auto-admin"]; + for (const dir of dirs) { + const p = dir + "/package.json"; + const pkg = JSON.parse(fs.readFileSync(p, "utf8")); + for (const section of ["dependencies", "peerDependencies", "devDependencies"]) { + if (!pkg[section]) continue; + for (const [key, val] of Object.entries(pkg[section])) { + if (val === "workspace:*") pkg[section][key] = "^" + version; + } + } + fs.writeFileSync(p, JSON.stringify(pkg, null, 2) + "\n"); + } + ' "$VERSION" + - name: Publish nuxt-auto-api to npm run: bunx npm publish --workspace=@websideproject/nuxt-auto-api --provenance --access public - name: Publish nuxt-auto-admin to npm run: bunx npm publish --workspace=@websideproject/nuxt-auto-admin --provenance --access public + - name: Restore workspace:* after publish + run: | + VERSION=${{ steps.version.outputs.version }} + node -e ' + const fs = require("fs"); + const version = process.argv[1]; + const dirs = ["packages/nuxt-auto-api", "packages/nuxt-auto-admin"]; + for (const dir of dirs) { + const p = dir + "/package.json"; + const pkg = JSON.parse(fs.readFileSync(p, "utf8")); + for (const section of ["dependencies", "peerDependencies", "devDependencies"]) { + if (!pkg[section]) continue; + const workspacePkgs = ["@websideproject/nuxt-auto-api", "@websideproject/nuxt-auto-admin"]; + for (const [key, val] of Object.entries(pkg[section])) { + if (workspacePkgs.includes(key) && val === "^" + version) { + pkg[section][key] = "workspace:*"; + } + } + } + fs.writeFileSync(p, JSON.stringify(pkg, null, 2) + "\n"); + } + ' "$VERSION" + - name: Commit version updates run: | VERSION=${{ steps.version.outputs.version }} diff --git a/README.md b/README.md index f846ae0..8e928c6 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,19 @@ bun add -D @websideproject/nuxt-auto-api @websideproject/nuxt-auto-admin 📖 **[Full Documentation →](https://github.com/websideproject/nuxt-auto)** +## 🤖 Claude Code Skills + +If you use [Claude Code](https://claude.ai/code), install the skill plugin to give Claude accurate knowledge of both `nuxt-auto-api` and `nuxt-auto-admin` APIs. + +```bash +/plugin marketplace add websideproject/nuxt-auto +/plugin install nuxt-auto-skills +``` + +The plugin provides two skills: +- **`nuxt-auto-api`** — resource registration, CRUD composables, authorization, hooks, bulk ops, M2M, aggregation, plugins, multi-tenancy, and module authoring patterns +- **`nuxt-auto-admin`** — admin config, resource display, form field widgets, custom actions, permissions, composables, and module authoring patterns + ## 🔍 PR Previews diff --git a/apps/docs/content/4.claude-code-skills/.navigation.yml b/apps/docs/content/4.claude-code-skills/.navigation.yml new file mode 100644 index 0000000..7c4af91 --- /dev/null +++ b/apps/docs/content/4.claude-code-skills/.navigation.yml @@ -0,0 +1,2 @@ +title: Claude Code Skills +icon: i-lucide-bot diff --git a/apps/docs/content/4.claude-code-skills/1.index.md b/apps/docs/content/4.claude-code-skills/1.index.md new file mode 100644 index 0000000..7f47456 --- /dev/null +++ b/apps/docs/content/4.claude-code-skills/1.index.md @@ -0,0 +1,84 @@ +--- +title: Claude Code Skills +description: Install the nuxt-auto Claude Code skill plugin to give Claude accurate API knowledge for nuxt-auto-api and nuxt-auto-admin. +navigation: + title: Overview +--- + +# Claude Code Skills + +`nuxt-auto` ships a [Claude Code](https://claude.ai/code) skill plugin so Claude has accurate, up-to-date knowledge of both `nuxt-auto-api` and `nuxt-auto-admin` APIs — without hallucinating composable signatures, authorization config, or admin widget options. + +## Installation + +Run these two commands in Claude Code: + +```bash +/plugin marketplace add websideproject/nuxt-auto +/plugin install nuxt-auto-skills +``` + +That's it. Claude will now automatically load the relevant skill context whenever you work with either module. + +## What the skills cover + +### nuxt-auto-api + +Progressive-loading reference docs across 8 topic areas: + +| Topic | Coverage | +|-------|----------| +| **Setup** | Module options, database adapters, resource registration via `autoApi:registerSchema` hook | +| **Query composables** | `useAutoApiList`, `useAutoApiGet`, `useAutoApiInfinite` — all filter operators, sort, pagination, include | +| **Mutation composables** | `useAutoApiCreate/Update/Delete`, bulk ops, optimistic updates | +| **Authorization** | `ResourceAuthConfig`, `HandlerContext`, role-based/object-level/list-filter/field-level auth | +| **Hooks & plugins** | `ResourceHooks` all lifecycle events, `defineAutoApiPlugin`, middleware stages | +| **M2M** | M2M config, `useM2MRelation`, `useM2MSync`, `useM2MAdd/Remove`, `useM2MBatchSync` | +| **Advanced** | `useAutoApiAggregate`, `usePermissions`, multi-tenancy, custom endpoints | +| **Module authoring** | `autoApi:registerSchema` hook timing, `createModuleImport`, co-located schema/auth/hooks pattern | + +### nuxt-auto-admin + +Progressive-loading reference docs across 6 topic areas: + +| Topic | Coverage | +|-------|----------| +| **Config** | `ModuleOptions`, `ResourceConfig`, resource groups, `CustomPageConfig` | +| **Fields & widgets** | All 18 `WidgetType` values with options, auto-widget selection rules, conditional fields | +| **Composables** | `useAdminConfig`, `useAdminRegistry`, `useAdminResource`, `useAdminPermissions`, `useAdminActions` | +| **Components** | ``, ``, ``, ``, `` | +| **Actions & permissions** | `CustomAction`, `ActionContext`, permission integration, `access` guard, middleware | +| **Module authoring** | Injecting admin display config from a Nuxt module, spread order, junction tables, custom actions | + +## How it works + +Skills are token-efficient — only the relevant reference file is loaded based on what you're asking about. Configuring authorization? Only `references/authorization.md` is loaded. Setting up admin widgets? Only `references/fields-widgets.md`. + +## Source + +The skills live next to the source code in this repository: + +``` +.claude-plugin/marketplace.json # plugin manifest +.claude/skills/ + nuxt-auto-api/ + SKILL.md # main entry + quick start + references/ + setup.md + composables-query.md + composables-mutations.md + authorization.md + hooks-plugins.md + m2m.md + advanced.md + module-authoring.md + nuxt-auto-admin/ + SKILL.md + references/ + config.md + fields-widgets.md + composables.md + components.md + actions-permissions.md + module-authoring.md +``` diff --git a/bun.lock b/bun.lock index 3ef8bf6..0428a57 100644 --- a/bun.lock +++ b/bun.lock @@ -77,7 +77,7 @@ }, "packages/nuxt-auto-admin": { "name": "@websideproject/nuxt-auto-admin", - "version": "0.0.1", + "version": "0.0.7", "dependencies": { "@nuxt/kit": "^4.3.0", "@tailwindcss/vite": "^4.1.18", @@ -106,7 +106,7 @@ }, "packages/nuxt-auto-api": { "name": "@websideproject/nuxt-auto-api", - "version": "0.0.1", + "version": "0.0.7", "dependencies": { "@nuxt/kit": "^4.3.0", "@tanstack/vue-query": "^5.0.0", @@ -3367,11 +3367,11 @@ "@websideproject/nuxt-auto-admin/@nuxt/kit": ["@nuxt/kit@4.3.1", "", { "dependencies": { "c12": "^3.3.3", "consola": "^3.4.2", "defu": "^6.1.4", "destr": "^2.0.5", "errx": "^0.1.0", "exsolve": "^1.0.8", "ignore": "^7.0.5", "jiti": "^2.6.1", "klona": "^2.0.6", "mlly": "^1.8.0", "ohash": "^2.0.11", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "rc9": "^3.0.0", "scule": "^1.3.0", "semver": "^7.7.4", "tinyglobby": "^0.2.15", "ufo": "^1.6.3", "unctx": "^2.5.0", "untyped": "^2.0.0" } }, "sha512-UjBFt72dnpc+83BV3OIbCT0YHLevJtgJCHpxMX0YRKWLDhhbcDdUse87GtsQBrjvOzK7WUNUYLDS/hQLYev5rA=="], - "@websideproject/nuxt-auto-admin/@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="], + "@websideproject/nuxt-auto-admin/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], "@websideproject/nuxt-auto-api/@nuxt/kit": ["@nuxt/kit@4.3.1", "", { "dependencies": { "c12": "^3.3.3", "consola": "^3.4.2", "defu": "^6.1.4", "destr": "^2.0.5", "errx": "^0.1.0", "exsolve": "^1.0.8", "ignore": "^7.0.5", "jiti": "^2.6.1", "klona": "^2.0.6", "mlly": "^1.8.0", "ohash": "^2.0.11", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "rc9": "^3.0.0", "scule": "^1.3.0", "semver": "^7.7.4", "tinyglobby": "^0.2.15", "ufo": "^1.6.3", "unctx": "^2.5.0", "untyped": "^2.0.0" } }, "sha512-UjBFt72dnpc+83BV3OIbCT0YHLevJtgJCHpxMX0YRKWLDhhbcDdUse87GtsQBrjvOzK7WUNUYLDS/hQLYev5rA=="], - "@websideproject/nuxt-auto-api/@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="], + "@websideproject/nuxt-auto-api/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], "ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.32", "", { "dependencies": { "@ai-sdk/provider": "3.0.7", "@ai-sdk/provider-utils": "4.0.13", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7clZRr07P9rpur39t1RrbIe7x8jmwnwUWI8tZs+BvAfX3NFgdSVGGIaT7bTz2pb08jmLXzTSDbrOTqAQ7uBkBQ=="],