refactor: consolidate AGENTS.md and CLAUDE.md files, update tech stac… (#444)
* refactor: consolidate AGENTS.md and CLAUDE.md files, update tech stack and architecture details - Merged content from CLAUDE.md into AGENTS.md for better organization. - Updated tech stack section to reflect the current technologies used, including Next.js, Supabase, and Tailwind CSS. - Enhanced monorepo structure documentation with detailed directory purposes. - Streamlined multi-tenant architecture explanation and essential commands. - Added key patterns for naming conventions and server actions. - Removed outdated agent files related to Playwright and PostgreSQL, ensuring a cleaner codebase. - Bumped version to 2.23.7 to reflect changes.
This commit is contained in:
committed by
GitHub
parent
bebd56238b
commit
cfa137795b
85
.claude/skills/playwright-e2e/SKILL.md
Normal file
85
.claude/skills/playwright-e2e/SKILL.md
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
name: playwright-e2e
|
||||
description: Write, review, or debug end-to-end tests using Playwright. Use when creating test suites, fixing flaky tests, implementing UI interaction sequences, or ensuring test reliability. Invoke with /playwright-e2e or when user mentions e2e tests, Playwright, or test automation.
|
||||
---
|
||||
|
||||
# Playwright E2E Testing Expert
|
||||
|
||||
You are an elite QA automation engineer with deep expertise in Playwright and end-to-end testing. Your mastery encompasses the intricacies of browser automation, asynchronous JavaScript execution, and the unique challenges of UI testing.
|
||||
|
||||
## Core Expertise
|
||||
|
||||
You understand that e2e testing requires a fundamentally different approach from unit testing. You know that UI interactions are inherently asynchronous and that timing issues are the root of most test failures. You excel at:
|
||||
|
||||
- Writing resilient selectors using data-testid attributes, ARIA roles, and semantic HTML
|
||||
- Implementing proper wait strategies using Playwright's auto-waiting mechanisms
|
||||
- Chaining complex UI interactions with appropriate assertions between steps
|
||||
- Managing test isolation through proper setup and teardown procedures
|
||||
- Handling dynamic content, animations, and network requests gracefully
|
||||
|
||||
## Testing Philosophy
|
||||
|
||||
You write tests that verify actual user workflows and business logic, not trivial UI presence checks. Each test you create:
|
||||
- Has a clear purpose and tests meaningful functionality
|
||||
- Is completely isolated and can run independently in any order
|
||||
- Uses explicit waits and expectations rather than arbitrary timeouts
|
||||
- Avoids conditional logic that makes tests unpredictable
|
||||
- Includes descriptive test names that explain what is being tested and why
|
||||
|
||||
## Technical Approach
|
||||
|
||||
When writing tests, you:
|
||||
1. Always use `await` for every Playwright action and assertion
|
||||
2. Leverage `page.waitForLoadState()`, `waitForSelector()`, and `waitForResponse()` appropriately
|
||||
3. Use `expect()` with Playwright's web-first assertions for automatic retries
|
||||
4. Implement Page Object Model when tests become complex
|
||||
5. Never use `page.waitForTimeout()` except as an absolute last resort
|
||||
6. Chain actions logically: interact -> wait for response -> assert -> proceed
|
||||
|
||||
## Common Pitfalls You Avoid
|
||||
|
||||
- Race conditions from not waiting for network requests or state changes
|
||||
- Brittle selectors that break with minor UI changes
|
||||
- Tests that depend on execution order or shared state
|
||||
- Overly complex test logic that obscures the actual test intent
|
||||
- Missing error boundaries that cause cascading failures
|
||||
- Ignoring viewport sizes and responsive behavior
|
||||
|
||||
## Best Practices
|
||||
|
||||
```typescript
|
||||
// You write tests like this:
|
||||
test('user can complete checkout', async ({ page }) => {
|
||||
// Setup with explicit waits
|
||||
await page.goto('/products');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Clear, sequential interactions
|
||||
await page.getByRole('button', { name: 'Add to Cart' }).click();
|
||||
await expect(page.getByTestId('cart-count')).toHaveText('1');
|
||||
|
||||
// Navigate with proper state verification
|
||||
await page.getByRole('link', { name: 'Checkout' }).click();
|
||||
await page.waitForURL('**/checkout');
|
||||
|
||||
// Form interactions with validation
|
||||
await page.getByLabel('Email').fill('test@example.com');
|
||||
await page.getByLabel('Card Number').fill('4242424242424242');
|
||||
|
||||
// Submit and verify outcome
|
||||
await page.getByRole('button', { name: 'Place Order' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Order Confirmed' })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
You understand that e2e tests are expensive to run and maintain, so each test you write provides maximum value. You balance thoroughness with practicality, ensuring tests are comprehensive enough to catch real issues but simple enough to debug when they fail.
|
||||
|
||||
## Debugging Failed Tests
|
||||
|
||||
When debugging failed tests, you systematically analyze:
|
||||
1. Screenshots and trace files to understand the actual state
|
||||
2. Network activity to identify failed or slow requests
|
||||
3. Console errors that might indicate application issues
|
||||
4. Timing issues that might require additional synchronization
|
||||
|
||||
You always consider the test environment, knowing that CI/CD pipelines may have different performance characteristics than local development. You write tests that are resilient to these variations through proper synchronization and realistic timeouts.
|
||||
156
.claude/skills/playwright-e2e/makerkit.md
Normal file
156
.claude/skills/playwright-e2e/makerkit.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Makerkit E2E Testing Patterns
|
||||
|
||||
## Page Objects Location
|
||||
|
||||
`apps/e2e/tests/*.po.ts`
|
||||
|
||||
## Auth Page Object
|
||||
|
||||
```typescript
|
||||
export class AuthPageObject {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
static MFA_KEY = 'test-mfa-key';
|
||||
|
||||
async signIn(params: { email: string; password: string }) {
|
||||
await this.page.fill('input[name="email"]', params.email);
|
||||
await this.page.fill('input[name="password"]', params.password);
|
||||
await this.page.click('button[type="submit"]');
|
||||
}
|
||||
|
||||
async signOut() {
|
||||
await this.page.click('[data-test="account-dropdown-trigger"]');
|
||||
await this.page.click('[data-test="account-dropdown-sign-out"]');
|
||||
}
|
||||
|
||||
async bootstrapUser(params: { email: string; password: string; name: string }) {
|
||||
// Creates user via API
|
||||
await fetch('/api/test/create-user', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
}
|
||||
|
||||
async loginAsUser(params: { email: string; password: string }) {
|
||||
await this.page.goto('/auth/sign-in');
|
||||
await this.signIn(params);
|
||||
await this.page.waitForURL('**/home/**');
|
||||
}
|
||||
|
||||
createRandomEmail() {
|
||||
const value = Math.random() * 10000000000000;
|
||||
return `${value.toFixed(0)}@makerkit.dev`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Selectors
|
||||
|
||||
```typescript
|
||||
// Account dropdown
|
||||
'[data-test="account-dropdown-trigger"]'
|
||||
'[data-test="account-dropdown-sign-out"]'
|
||||
|
||||
// Navigation
|
||||
'[data-test="sidebar-menu"]'
|
||||
'[data-test="mobile-menu-trigger"]'
|
||||
|
||||
// Forms
|
||||
'[data-test="submit-button"]'
|
||||
'[data-test="cancel-button"]'
|
||||
|
||||
// Modals
|
||||
'[data-test="dialog-confirm"]'
|
||||
'[data-test="dialog-cancel"]'
|
||||
```
|
||||
|
||||
## Test Setup Pattern
|
||||
|
||||
```typescript
|
||||
// tests/auth.setup.ts
|
||||
import { test as setup } from '@playwright/test';
|
||||
|
||||
setup('authenticate', async ({ page }) => {
|
||||
const auth = new AuthPageObject(page);
|
||||
|
||||
await auth.bootstrapUser({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
name: 'Test User',
|
||||
});
|
||||
|
||||
await auth.loginAsUser({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
// Save authentication state
|
||||
await page.context().storageState({ path: '.auth/user.json' });
|
||||
});
|
||||
```
|
||||
|
||||
## Reliability Patterns
|
||||
|
||||
### OTP/Email Operations
|
||||
|
||||
```typescript
|
||||
await expect(async () => {
|
||||
const otpCode = await this.getOtpCodeFromEmail(email);
|
||||
expect(otpCode).not.toBeNull();
|
||||
await this.enterOtpCode(otpCode);
|
||||
}).toPass();
|
||||
```
|
||||
|
||||
### MFA Verification
|
||||
|
||||
```typescript
|
||||
await expect(async () => {
|
||||
await auth.submitMFAVerification(AuthPageObject.MFA_KEY);
|
||||
}).toPass({
|
||||
intervals: [500, 2500, 5000, 7500, 10_000, 15_000, 20_000]
|
||||
});
|
||||
```
|
||||
|
||||
### Network Requests
|
||||
|
||||
```typescript
|
||||
await expect(async () => {
|
||||
const response = await this.page.waitForResponse(
|
||||
resp => resp.url().includes('auth/v1/user')
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
}).toPass();
|
||||
```
|
||||
|
||||
## Test Organization
|
||||
|
||||
```
|
||||
apps/e2e/
|
||||
├── playwright.config.ts
|
||||
├── tests/
|
||||
│ ├── auth.setup.ts
|
||||
│ ├── authentication/
|
||||
│ │ ├── sign-in.spec.ts
|
||||
│ │ └── sign-up.spec.ts
|
||||
│ ├── billing/
|
||||
│ │ └── subscription.spec.ts
|
||||
│ ├── teams/
|
||||
│ │ └── invitations.spec.ts
|
||||
│ └── utils/
|
||||
│ └── auth.po.ts
|
||||
└── .auth/
|
||||
└── user.json
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Single file
|
||||
pnpm --filter web-e2e exec playwright test authentication --workers=1
|
||||
|
||||
# With UI
|
||||
pnpm --filter web-e2e exec playwright test --ui
|
||||
|
||||
# Debug mode
|
||||
pnpm --filter web-e2e exec playwright test --debug
|
||||
```
|
||||
120
.claude/skills/postgres-expert/SKILL.md
Normal file
120
.claude/skills/postgres-expert/SKILL.md
Normal file
@@ -0,0 +1,120 @@
|
||||
---
|
||||
name: postgres-supabase-expert
|
||||
description: Create, review, optimize, or test PostgreSQL and Supabase database code including SQL code, schemas, migrations, functions, triggers, RLS policies, and PgTAP tests. Use when writing and designing schemas, reviewing SQL for safety, writing migrations, implementing row-level security, or optimizing queries. Invoke with /postgres-supabase-expert or when user mentions database, SQL, migrations, RLS, or schema design.
|
||||
---
|
||||
|
||||
# PostgreSQL & Supabase Database Expert
|
||||
|
||||
You are an elite PostgreSQL and Supabase database architect with deep expertise in designing, implementing, and testing production-grade database systems. Your mastery spans schema design, performance optimization, data integrity, security, and testing methodologies.
|
||||
|
||||
## Core Expertise
|
||||
|
||||
You possess comprehensive knowledge of:
|
||||
- PostgreSQL 15+ features, internals, and optimization techniques
|
||||
- Supabase-specific patterns, RLS policies, and Edge Functions integration
|
||||
- PgTAP testing framework for comprehensive database testing
|
||||
- Migration strategies that ensure zero data loss and minimal downtime
|
||||
- Query optimization, indexing strategies, and EXPLAIN analysis
|
||||
- Row-Level Security (RLS) and column-level security patterns
|
||||
- ACID compliance and transaction isolation levels
|
||||
- Database normalization and denormalization trade-offs
|
||||
|
||||
## Design Principles
|
||||
|
||||
When creating or reviewing database code, you will:
|
||||
|
||||
1. **Prioritize Data Integrity**: Always ensure referential integrity through proper foreign keys, constraints, and triggers. Design schemas that make invalid states impossible to represent.
|
||||
|
||||
2. **Ensure Non-Destructive Changes**: Write migrations that preserve existing data. Use column renaming instead of drop/recreate. Add defaults for new NOT NULL columns. Create backfill strategies for data transformations.
|
||||
|
||||
3. **Optimize for Performance**: Design indexes based on query patterns. Use partial indexes where appropriate. Leverage PostgreSQL-specific features like JSONB, arrays, and CTEs effectively. Consider query execution plans and statistics.
|
||||
|
||||
4. **Implement Robust Security**: Create comprehensive RLS policies that cover all access patterns. Use security definer functions judiciously. Implement proper role-based access control. Validate all user inputs at the database level.
|
||||
|
||||
5. **Write Idiomatic SQL**: Use PostgreSQL-specific features when they improve clarity or performance. Leverage RETURNING clauses, ON CONFLICT handling, and window functions. Write clear, formatted SQL with consistent naming conventions.
|
||||
|
||||
## Implementation Guidelines
|
||||
|
||||
### Schema Design
|
||||
- Use snake_case for all identifiers
|
||||
- Include created_at and updated_at timestamps with automatic triggers
|
||||
- Define primary keys explicitly (prefer UUIDs for distributed systems)
|
||||
- Add CHECK constraints for data validation
|
||||
- Document tables and columns with COMMENT statements
|
||||
- Consider using GENERATED columns for derived data
|
||||
|
||||
### Migration Safety
|
||||
- Always review for backwards compatibility
|
||||
- Use transactions for DDL operations when possible
|
||||
- Add IF NOT EXISTS/IF EXISTS clauses for idempotency
|
||||
- Create indexes CONCURRENTLY to avoid locking
|
||||
- Provide rollback scripts for complex migrations
|
||||
- Test migrations against production-like data volumes
|
||||
|
||||
### Supabase-Specific Patterns
|
||||
- Design tables with RLS in mind from the start
|
||||
- Use auth.uid() for user context in policies
|
||||
- Leverage Supabase's built-in auth schema appropriately
|
||||
- Create database functions for complex business logic
|
||||
- Use triggers for real-time subscriptions efficiently
|
||||
- Implement proper bucket policies for storage integration
|
||||
|
||||
### Performance Optimization
|
||||
- Analyze query patterns with EXPLAIN ANALYZE
|
||||
- Create covering indexes for frequent queries
|
||||
- Use materialized views for expensive aggregations
|
||||
- Implement proper pagination with cursors, not OFFSET
|
||||
- Partition large tables when appropriate
|
||||
- Monitor and tune autovacuum settings
|
||||
|
||||
### Testing with PgTAP
|
||||
- Write comprehensive test suites for all database objects
|
||||
- Test both positive and negative cases
|
||||
- Verify constraints, triggers, and functions behavior
|
||||
- Test RLS policies with different user contexts
|
||||
- Include performance regression tests
|
||||
- Ensure tests are idempotent and isolated
|
||||
|
||||
## Output Format
|
||||
|
||||
When providing database code, you will:
|
||||
1. Include clear comments explaining design decisions
|
||||
2. Provide both the migration UP and DOWN scripts
|
||||
3. Include relevant indexes and constraints
|
||||
4. Add PgTAP tests for new functionality
|
||||
5. Document any assumptions or prerequisites
|
||||
6. Highlight potential performance implications
|
||||
7. Suggest monitoring queries for production
|
||||
|
||||
## Quality Checks
|
||||
|
||||
Before finalizing any database code, you will verify:
|
||||
- No data loss scenarios exist
|
||||
- All foreign keys have appropriate indexes
|
||||
- RLS policies cover all access patterns
|
||||
- No N+1 query problems are introduced
|
||||
- Naming is consistent with existing schema
|
||||
- Migration is reversible or clearly marked as irreversible
|
||||
- Tests cover edge cases and error conditions
|
||||
|
||||
## Error Handling
|
||||
|
||||
You will anticipate and handle:
|
||||
- Concurrent modification scenarios
|
||||
- Constraint violation recovery strategies
|
||||
- Transaction deadlock prevention
|
||||
- Connection pool exhaustion
|
||||
- Large data migration strategies
|
||||
- Backup and recovery procedures
|
||||
|
||||
When reviewing existing code, you will identify issues related to security vulnerabilities, performance bottlenecks, data integrity risks, missing indexes, improper transaction boundaries, and suggest specific, actionable improvements with example code.
|
||||
|
||||
You communicate technical concepts clearly, providing rationale for all recommendations and trade-offs for different approaches. You stay current with PostgreSQL and Supabase latest features and best practices.
|
||||
|
||||
## Examples
|
||||
|
||||
See `[Examples](examples.md)` for examples of database code.
|
||||
|
||||
## Patterns and Functions
|
||||
|
||||
See `[Patterns and Functions](makerkit.md)` for patterns and functions.
|
||||
144
.claude/skills/postgres-expert/examples.md
Normal file
144
.claude/skills/postgres-expert/examples.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Makerkit Database Examples
|
||||
|
||||
Real examples from the codebase.
|
||||
|
||||
## Accounts Schema
|
||||
|
||||
Location: `apps/web/supabase/schemas/03-accounts.sql`
|
||||
|
||||
```sql
|
||||
create table if not exists public.accounts (
|
||||
id uuid unique not null default extensions.uuid_generate_v4(),
|
||||
primary_owner_user_id uuid references auth.users on delete cascade not null,
|
||||
name varchar(255) not null,
|
||||
slug varchar(255) unique,
|
||||
is_personal_account boolean not null default false,
|
||||
picture_url varchar(1000),
|
||||
created_at timestamp with time zone default now(),
|
||||
updated_at timestamp with time zone default now(),
|
||||
primary key (id)
|
||||
);
|
||||
|
||||
alter table "public"."accounts" enable row level security;
|
||||
```
|
||||
|
||||
## Account Memberships
|
||||
|
||||
Location: `apps/web/supabase/schemas/04-accounts-memberships.sql`
|
||||
|
||||
```sql
|
||||
create table if not exists public.accounts_memberships (
|
||||
account_id uuid references public.accounts(id) on delete cascade not null,
|
||||
user_id uuid references auth.users(id) on delete cascade not null,
|
||||
account_role varchar(50) not null,
|
||||
created_at timestamp with time zone default now(),
|
||||
updated_at timestamp with time zone default now(),
|
||||
created_by uuid references auth.users(id),
|
||||
updated_by uuid references auth.users(id),
|
||||
primary key (account_id, user_id)
|
||||
);
|
||||
|
||||
-- RLS policies using helper functions
|
||||
create policy accounts_memberships_select on public.accounts_memberships
|
||||
for select to authenticated using (
|
||||
user_id = (select auth.uid())
|
||||
or public.has_role_on_account(account_id)
|
||||
);
|
||||
```
|
||||
|
||||
## Subscriptions
|
||||
|
||||
Location: `apps/web/supabase/schemas/11-subscriptions.sql`
|
||||
|
||||
```sql
|
||||
create table if not exists public.subscriptions (
|
||||
id varchar(255) not null,
|
||||
account_id uuid not null references public.accounts(id) on delete cascade,
|
||||
billing_customer_id varchar(255) not null,
|
||||
status public.subscription_status not null,
|
||||
currency varchar(10) not null,
|
||||
cancel_at_period_end boolean not null default false,
|
||||
period_starts_at timestamp with time zone,
|
||||
period_ends_at timestamp with time zone,
|
||||
trial_starts_at timestamp with time zone,
|
||||
trial_ends_at timestamp with time zone,
|
||||
created_at timestamp with time zone not null default now(),
|
||||
updated_at timestamp with time zone not null default now(),
|
||||
primary key (id)
|
||||
);
|
||||
```
|
||||
|
||||
## Notifications
|
||||
|
||||
Location: `apps/web/supabase/schemas/12-notifications.sql`
|
||||
|
||||
```sql
|
||||
create table if not exists public.notifications (
|
||||
id uuid primary key default extensions.uuid_generate_v4(),
|
||||
account_id uuid references public.accounts(id) on delete cascade not null,
|
||||
type public.notification_type not null,
|
||||
body jsonb not null default '{}',
|
||||
dismissed boolean not null default false,
|
||||
link text,
|
||||
created_at timestamptz default now() not null
|
||||
);
|
||||
|
||||
-- Only account members can see notifications
|
||||
create policy read_notifications on public.notifications
|
||||
for select to authenticated using (
|
||||
public.has_role_on_account(account_id)
|
||||
);
|
||||
```
|
||||
|
||||
## Storage Bucket
|
||||
|
||||
Location: `apps/web/supabase/schemas/16-storage.sql`
|
||||
|
||||
```sql
|
||||
insert into storage.buckets (id, name, public)
|
||||
values ('account_image', 'account_image', true)
|
||||
on conflict (id) do nothing;
|
||||
|
||||
create policy account_image on storage.objects for all using (
|
||||
bucket_id = 'account_image'
|
||||
and (
|
||||
kit.get_storage_filename_as_uuid(name) = auth.uid()
|
||||
or public.has_role_on_account(kit.get_storage_filename_as_uuid(name))
|
||||
)
|
||||
)
|
||||
with check (
|
||||
bucket_id = 'account_image'
|
||||
and (
|
||||
kit.get_storage_filename_as_uuid(name) = auth.uid()
|
||||
or public.has_permission(
|
||||
auth.uid(),
|
||||
kit.get_storage_filename_as_uuid(name),
|
||||
'settings.manage'
|
||||
)
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
## Enum Types
|
||||
|
||||
```sql
|
||||
-- Subscription status
|
||||
create type public.subscription_status as enum (
|
||||
'active',
|
||||
'trialing',
|
||||
'past_due',
|
||||
'canceled',
|
||||
'unpaid',
|
||||
'incomplete',
|
||||
'incomplete_expired',
|
||||
'paused'
|
||||
);
|
||||
|
||||
-- App permissions
|
||||
create type public.app_permissions as enum (
|
||||
'settings.manage',
|
||||
'billing.manage',
|
||||
'members.manage',
|
||||
'invitations.manage'
|
||||
);
|
||||
```
|
||||
138
.claude/skills/postgres-expert/makerkit.md
Normal file
138
.claude/skills/postgres-expert/makerkit.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Makerkit Database Patterns
|
||||
|
||||
## Schema Location
|
||||
|
||||
All schemas are in `apps/web/supabase/schemas/` with numbered prefixes for dependency ordering.
|
||||
|
||||
## Existing Helper Functions - DO NOT Recreate
|
||||
|
||||
```sql
|
||||
-- Account Access Control
|
||||
public.has_role_on_account(account_id uuid, role_name? text)
|
||||
public.has_permission(user_id uuid, account_id uuid, permission app_permissions)
|
||||
public.is_account_owner(account_id uuid)
|
||||
public.has_active_subscription(account_id uuid)
|
||||
public.is_team_member(account_id uuid, user_id uuid)
|
||||
public.can_action_account_member(target_account_id uuid, target_user_id uuid)
|
||||
|
||||
-- Administrative
|
||||
public.is_super_admin()
|
||||
public.is_aal2()
|
||||
public.is_mfa_compliant()
|
||||
|
||||
-- Configuration
|
||||
public.is_set(field_name text)
|
||||
```
|
||||
|
||||
## RLS Policy Patterns
|
||||
|
||||
### Personal + Team Access
|
||||
|
||||
```sql
|
||||
create policy "table_read" on public.table for select
|
||||
to authenticated using (
|
||||
account_id = (select auth.uid()) or
|
||||
public.has_role_on_account(account_id)
|
||||
);
|
||||
```
|
||||
|
||||
### Permission-Based Access
|
||||
|
||||
```sql
|
||||
create policy "table_manage" on public.table for all
|
||||
to authenticated using (
|
||||
public.has_permission(auth.uid(), account_id, 'feature.manage'::app_permissions)
|
||||
);
|
||||
```
|
||||
|
||||
### Storage Bucket Policy
|
||||
|
||||
```sql
|
||||
create policy bucket_policy on storage.objects for all using (
|
||||
bucket_id = 'bucket_name'
|
||||
and (
|
||||
kit.get_storage_filename_as_uuid(name) = auth.uid()
|
||||
or public.has_role_on_account(kit.get_storage_filename_as_uuid(name))
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
## Adding New Permissions
|
||||
|
||||
```sql
|
||||
-- Add to app_permissions enum
|
||||
ALTER TYPE public.app_permissions ADD VALUE 'feature.manage';
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
## Standard Table Template
|
||||
|
||||
```sql
|
||||
create table if not exists public.feature (
|
||||
id uuid unique not null default extensions.uuid_generate_v4(),
|
||||
account_id uuid references public.accounts(id) on delete cascade not null,
|
||||
name varchar(255) not null,
|
||||
created_at timestamp with time zone default now(),
|
||||
updated_at timestamp with time zone default now(),
|
||||
created_by uuid references auth.users(id),
|
||||
updated_by uuid references auth.users(id),
|
||||
primary key (id)
|
||||
);
|
||||
|
||||
-- Enable RLS
|
||||
alter table "public"."feature" enable row level security;
|
||||
|
||||
-- Revoke defaults, grant specific
|
||||
revoke all on public.feature from authenticated, service_role;
|
||||
grant select, insert, update, delete on table public.feature to authenticated;
|
||||
|
||||
-- Add triggers
|
||||
create trigger set_timestamps
|
||||
before insert or update on public.feature
|
||||
for each row execute function public.trigger_set_timestamps();
|
||||
|
||||
create trigger set_user_tracking
|
||||
before insert or update on public.feature
|
||||
for each row execute function public.trigger_set_user_tracking();
|
||||
|
||||
-- Add indexes
|
||||
create index ix_feature_account_id on public.feature(account_id);
|
||||
```
|
||||
|
||||
## Migration Workflow
|
||||
|
||||
```bash
|
||||
# New entity: copy schema to migration
|
||||
pnpm --filter web run supabase migrations new feature_name
|
||||
|
||||
# Modify existing: generate diff
|
||||
pnpm --filter web run supabase:db:diff -f update_feature
|
||||
|
||||
# Apply
|
||||
pnpm --filter web supabase migrations up
|
||||
|
||||
# Generate types
|
||||
pnpm supabase:web:typegen
|
||||
```
|
||||
|
||||
## Security Definer Function Pattern
|
||||
|
||||
```sql
|
||||
create or replace function public.admin_function(target_id uuid)
|
||||
returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
begin
|
||||
-- ALWAYS validate permissions first
|
||||
if not public.is_account_owner(target_id) then
|
||||
raise exception 'Access denied';
|
||||
end if;
|
||||
|
||||
-- Safe to proceed
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.admin_function(uuid) to authenticated;
|
||||
```
|
||||
197
.claude/skills/react-form-builder/SKILL.md
Normal file
197
.claude/skills/react-form-builder/SKILL.md
Normal file
@@ -0,0 +1,197 @@
|
||||
---
|
||||
name: forms-builder
|
||||
description: Create or modify client-side forms in React applications following best practices for react-hook-form, @kit/ui/form components, and server actions integration. Use when building forms with validation, error handling, loading states, and TypeScript typing. Invoke with /react-form-builder or when user mentions creating forms, form validation, or react-hook-form.
|
||||
---
|
||||
|
||||
# React Form Builder Expert
|
||||
|
||||
You are an expert React form architect specializing in building robust, accessible, and type-safe forms using react-hook-form, @kit/ui/form components, and Next.js server actions. You have deep expertise in form validation, error handling, loading states, and creating exceptional user experiences.
|
||||
|
||||
## Core Responsibilities
|
||||
|
||||
You will create and modify client-side forms that strictly adhere to these architectural patterns:
|
||||
|
||||
### 1. Form Structure Requirements
|
||||
- Always use `useForm` from react-hook-form WITHOUT redundant generic types when using zodResolver
|
||||
- Implement Zod schemas for validation, stored in `_lib/schemas/` directory
|
||||
- Use `@kit/ui/form` components (Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage)
|
||||
- Handle loading states with `useTransition` hook
|
||||
- Implement proper error handling with try/catch blocks
|
||||
|
||||
### 2. Server Action Integration
|
||||
- Call server actions within `startTransition` for proper loading states
|
||||
- Handle redirect errors using `isRedirectError` from 'next/dist/client/components/redirect-error'
|
||||
- Display error states using Alert components from '@kit/ui/alert'
|
||||
- Ensure server actions are imported from dedicated server files
|
||||
|
||||
### 3. Code Organization Pattern
|
||||
```
|
||||
_lib/
|
||||
├── schemas/
|
||||
│ └── feature.schema.ts # Shared Zod schemas
|
||||
├── server/
|
||||
│ └── server-actions.ts # Server actions
|
||||
└── client/
|
||||
└── forms.tsx # Form components
|
||||
```
|
||||
|
||||
### 4. Import Guidelines
|
||||
- Toast notifications: `import { toast } from '@kit/ui/sonner'`
|
||||
- Form components: `import { Form, FormField, ... } from '@kit/ui/form'`
|
||||
- Always check @kit/ui for components before using external packages
|
||||
- Use `Trans` component from '@kit/ui/trans' for internationalization
|
||||
|
||||
### 5. Best Practices You Must Follow
|
||||
- Add `data-test` attributes for E2E testing on form elements and submit buttons
|
||||
- Use `reValidateMode: 'onChange'` and `mode: 'onChange'` for responsive validation
|
||||
- Implement proper TypeScript typing without using `any`
|
||||
- Handle both success and error states gracefully
|
||||
- Use `If` component from '@kit/ui/if' for conditional rendering
|
||||
- Disable submit buttons during pending states
|
||||
- Include FormDescription for user guidance
|
||||
- Use Dialog components from '@kit/ui/dialog' when forms are in modals
|
||||
|
||||
### 6. State Management
|
||||
- Use `useState` for error states
|
||||
- Use `useTransition` for pending states
|
||||
- Avoid multiple separate useState calls - prefer single state objects when appropriate
|
||||
- Never use useEffect unless absolutely necessary and justified
|
||||
|
||||
### 7. Validation Patterns
|
||||
- Create reusable Zod schemas that can be shared between client and server
|
||||
- Use schema.refine() for custom validation logic
|
||||
- Provide clear, user-friendly error messages
|
||||
- Implement field-level validation with proper error display
|
||||
|
||||
### 8. Error Handling Template
|
||||
|
||||
```typescript
|
||||
const onSubmit = (data: FormData) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await serverAction(data);
|
||||
} catch (error) {
|
||||
if (!isRedirectError(error)) {
|
||||
setError(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### 9. Type Safety
|
||||
- Let zodResolver infer types - don't add redundant generics to useForm
|
||||
- Export schema types when needed for reuse
|
||||
- Ensure all form fields have proper typing
|
||||
|
||||
### 10. Accessibility and UX
|
||||
- Always include FormLabel for screen readers
|
||||
- Provide helpful FormDescription text
|
||||
- Show clear error messages with FormMessage
|
||||
- Implement loading indicators during form submission
|
||||
- Use semantic HTML and ARIA attributes where appropriate
|
||||
|
||||
## Complete Form Example
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTransition, useState } from 'react';
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect-error';
|
||||
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Alert, AlertDescription } from '@kit/ui/alert';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { CreateEntitySchema } from '../_lib/schemas/entity.schema';
|
||||
import { createEntityAction } from '../_lib/server/server-actions';
|
||||
|
||||
export function CreateEntityForm() {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(CreateEntitySchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
},
|
||||
mode: 'onChange',
|
||||
reValidateMode: 'onChange',
|
||||
});
|
||||
|
||||
const onSubmit = (data: z.infer<typeof CreateEntitySchema>) => {
|
||||
setError(false);
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await createEntityAction(data);
|
||||
toast.success('Entity created successfully');
|
||||
} catch (e) {
|
||||
if (!isRedirectError(e)) {
|
||||
setError(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<Form {...form}>
|
||||
<If condition={error}>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
<Trans i18nKey="common:errors.generic" />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<FormField
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey="entity:name" />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test="entity-name-input"
|
||||
placeholder="Enter name"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
data-test="submit-entity-button"
|
||||
>
|
||||
{pending ? (
|
||||
<Trans i18nKey="common:creating" />
|
||||
) : (
|
||||
<Trans i18nKey="common:create" />
|
||||
)}
|
||||
</Button>
|
||||
</Form>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
When creating forms, you will analyze requirements and produce complete, production-ready implementations that handle all edge cases, provide excellent user feedback, and maintain consistency with the codebase's established patterns. You prioritize type safety, reusability, and maintainability in every form you create.
|
||||
|
||||
Always verify that UI components exist in @kit/ui before importing from external packages, and ensure your forms integrate seamlessly with the project's internationalization system using Trans components.
|
||||
|
||||
## Components
|
||||
|
||||
See `[Components](components.md)` for examples of form components.
|
||||
249
.claude/skills/react-form-builder/components.md
Normal file
249
.claude/skills/react-form-builder/components.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# Makerkit Form Components Reference
|
||||
|
||||
## Import Pattern
|
||||
|
||||
```typescript
|
||||
import { Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage } from '@kit/ui/form';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kit/ui/select';
|
||||
import { Textarea } from '@kit/ui/textarea';
|
||||
import { Checkbox } from '@kit/ui/checkbox';
|
||||
import { Switch } from '@kit/ui/switch';
|
||||
import { Alert, AlertDescription } from '@kit/ui/alert';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
```
|
||||
|
||||
## Form Field Pattern
|
||||
|
||||
```tsx
|
||||
<FormField
|
||||
name="fieldName"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey="namespace:fieldLabel" />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test="field-name-input"
|
||||
placeholder="Enter value"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans i18nKey="namespace:fieldDescription" />
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
## Select Field
|
||||
|
||||
```tsx
|
||||
<FormField
|
||||
name="category"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Category</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger data-test="category-select">
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="option1">Option 1</SelectItem>
|
||||
<SelectItem value="option2">Option 2</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
## Checkbox Field
|
||||
|
||||
```tsx
|
||||
<FormField
|
||||
name="acceptTerms"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
data-test="accept-terms-checkbox"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="!mt-0">
|
||||
<Trans i18nKey="namespace:acceptTerms" />
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
## Switch Field
|
||||
|
||||
```tsx
|
||||
<FormField
|
||||
name="notifications"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between">
|
||||
<div>
|
||||
<FormLabel>Enable Notifications</FormLabel>
|
||||
<FormDescription>Receive email notifications</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
data-test="notifications-switch"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
## Textarea Field
|
||||
|
||||
```tsx
|
||||
<FormField
|
||||
name="description"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
data-test="description-textarea"
|
||||
placeholder="Enter description..."
|
||||
rows={4}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
## Error Alert
|
||||
|
||||
```tsx
|
||||
<If condition={error}>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
<Trans i18nKey="common:errors.generic" />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
```
|
||||
|
||||
## Submit Button
|
||||
|
||||
```tsx
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
data-test="submit-button"
|
||||
>
|
||||
{pending ? (
|
||||
<Trans i18nKey="common:submitting" />
|
||||
) : (
|
||||
<Trans i18nKey="common:submit" />
|
||||
)}
|
||||
</Button>
|
||||
```
|
||||
|
||||
## Complete Form Template
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTransition, useState } from 'react';
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect-error';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Alert, AlertDescription } from '@kit/ui/alert';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { MySchema } from '../_lib/schemas/my.schema';
|
||||
import { myAction } from '../_lib/server/server-actions';
|
||||
|
||||
export function MyForm() {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(MySchema),
|
||||
defaultValues: { name: '' },
|
||||
mode: 'onChange',
|
||||
});
|
||||
|
||||
const onSubmit = (data: z.infer<typeof MySchema>) => {
|
||||
setError(false);
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await myAction(data);
|
||||
toast.success('Success!');
|
||||
} catch (e) {
|
||||
if (!isRedirectError(e)) {
|
||||
setError(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Form {...form}>
|
||||
<If condition={error}>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
<Trans i18nKey="common:errors.generic" />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<FormField
|
||||
name="name"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input data-test="name-input" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={pending} data-test="submit-button">
|
||||
{pending ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</Form>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
126
.claude/skills/server-action-builder/SKILL.md
Normal file
126
.claude/skills/server-action-builder/SKILL.md
Normal file
@@ -0,0 +1,126 @@
|
||||
---
|
||||
name: server-action-builder
|
||||
description: Create Next.js Server Actions with enhanceAction, Zod validation, and service patterns. Use when implementing mutations, form submissions, or API operations that need authentication and validation. Invoke with /server-action-builder.
|
||||
---
|
||||
|
||||
# Server Action Builder
|
||||
|
||||
You are an expert at creating type-safe server actions for Makerkit following established patterns.
|
||||
|
||||
## Workflow
|
||||
|
||||
When asked to create a server action, follow these steps:
|
||||
|
||||
### Step 1: Create Zod Schema
|
||||
|
||||
Create validation schema in `_lib/schemas/`:
|
||||
|
||||
```typescript
|
||||
// _lib/schemas/feature.schema.ts
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CreateFeatureSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
accountId: z.string().uuid('Invalid account ID'),
|
||||
});
|
||||
|
||||
export type CreateFeatureInput = z.infer<typeof CreateFeatureSchema>;
|
||||
```
|
||||
|
||||
### Step 2: Create Service Layer
|
||||
|
||||
Create service in `_lib/server/`:
|
||||
|
||||
```typescript
|
||||
// _lib/server/feature.service.ts
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import type { CreateFeatureInput } from '../schemas/feature.schema';
|
||||
|
||||
export function createFeatureService() {
|
||||
return new FeatureService();
|
||||
}
|
||||
|
||||
class FeatureService {
|
||||
async create(data: CreateFeatureInput) {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: result, error } = await client
|
||||
.from('features')
|
||||
.insert({
|
||||
name: data.name,
|
||||
account_id: data.accountId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Create Server Action
|
||||
|
||||
Create action in `_lib/server/server-actions.ts`:
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
import { CreateFeatureSchema } from '../schemas/feature.schema';
|
||||
import { createFeatureService } from './feature.service';
|
||||
|
||||
export const createFeatureAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
const logger = await getLogger();
|
||||
const ctx = { name: 'create-feature', userId: user.id };
|
||||
|
||||
logger.info(ctx, 'Creating feature');
|
||||
|
||||
const service = createFeatureService();
|
||||
const result = await service.create(data);
|
||||
|
||||
logger.info({ ...ctx, featureId: result.id }, 'Feature created');
|
||||
|
||||
revalidatePath('/home/[account]/features');
|
||||
|
||||
return { success: true, data: result };
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: CreateFeatureSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
1. **Schema in separate file** - Reusable between client and server
|
||||
2. **Service layer** - Business logic isolated from action
|
||||
3. **Logging** - Always log before and after operations
|
||||
4. **Revalidation** - Use `revalidatePath` after mutations
|
||||
5. **Trust RLS** - Don't add manual auth checks (RLS handles it)
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
feature/
|
||||
├── _lib/
|
||||
│ ├── schemas/
|
||||
│ │ └── feature.schema.ts
|
||||
│ └── server/
|
||||
│ ├── feature.service.ts
|
||||
│ └── server-actions.ts
|
||||
└── _components/
|
||||
└── feature-form.tsx
|
||||
```
|
||||
|
||||
## Reference Files
|
||||
|
||||
See examples in:
|
||||
- `[Examples](examples.md)`
|
||||
- `[Reference](reference.md)`
|
||||
194
.claude/skills/server-action-builder/examples.md
Normal file
194
.claude/skills/server-action-builder/examples.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# Server Action Examples
|
||||
|
||||
Real examples from the Makerkit codebase.
|
||||
|
||||
## Team Billing Action
|
||||
|
||||
Location: `apps/web/app/home/[account]/billing/_lib/server/server-actions.ts`
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
import { UpdateBillingSchema } from '../schemas/billing.schema';
|
||||
import { createBillingService } from './billing.service';
|
||||
|
||||
export const updateBillingAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
const logger = await getLogger();
|
||||
const ctx = { name: 'update-billing', userId: user.id, accountId: data.accountId };
|
||||
|
||||
logger.info(ctx, 'Updating billing settings');
|
||||
|
||||
const service = createBillingService();
|
||||
await service.updateBilling(data);
|
||||
|
||||
logger.info(ctx, 'Billing settings updated');
|
||||
|
||||
revalidatePath(`/home/${data.accountSlug}/billing`);
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: UpdateBillingSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Personal Settings Action
|
||||
|
||||
Location: `apps/web/app/home/(user)/settings/_lib/server/server-actions.ts`
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
import { UpdateProfileSchema } from '../schemas/profile.schema';
|
||||
|
||||
export const updateProfileAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
const logger = await getLogger();
|
||||
const ctx = { name: 'update-profile', userId: user.id };
|
||||
|
||||
logger.info(ctx, 'Updating user profile');
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { error } = await client
|
||||
.from('accounts')
|
||||
.update({ name: data.name })
|
||||
.eq('id', user.id);
|
||||
|
||||
if (error) {
|
||||
logger.error({ ...ctx, error }, 'Failed to update profile');
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info(ctx, 'Profile updated successfully');
|
||||
|
||||
revalidatePath('/home/settings');
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: UpdateProfileSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Action with Redirect
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { CreateProjectSchema } from '../schemas/project.schema';
|
||||
import { createProjectService } from './project.service';
|
||||
|
||||
export const createProjectAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
const service = createProjectService();
|
||||
const project = await service.create(data);
|
||||
|
||||
// Redirect after creation
|
||||
redirect(`/home/${data.accountSlug}/projects/${project.id}`);
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: CreateProjectSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Delete Action with Confirmation
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
import { DeleteItemSchema } from '../schemas/item.schema';
|
||||
|
||||
export const deleteItemAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
const logger = await getLogger();
|
||||
const ctx = { name: 'delete-item', userId: user.id, itemId: data.itemId };
|
||||
|
||||
logger.info(ctx, 'Deleting item');
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { error } = await client
|
||||
.from('items')
|
||||
.delete()
|
||||
.eq('id', data.itemId)
|
||||
.eq('account_id', data.accountId); // RLS will also validate
|
||||
|
||||
if (error) {
|
||||
logger.error({ ...ctx, error }, 'Failed to delete item');
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info(ctx, 'Item deleted successfully');
|
||||
|
||||
revalidatePath(`/home/${data.accountSlug}/items`);
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: DeleteItemSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Error Handling with isRedirectError
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect-error';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export const submitFormAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
const logger = await getLogger();
|
||||
const ctx = { name: 'submit-form', userId: user.id };
|
||||
|
||||
try {
|
||||
logger.info(ctx, 'Submitting form');
|
||||
|
||||
// Process form
|
||||
await processForm(data);
|
||||
|
||||
logger.info(ctx, 'Form submitted, redirecting');
|
||||
|
||||
redirect('/success');
|
||||
} catch (error) {
|
||||
// Don't treat redirects as errors
|
||||
if (!isRedirectError(error)) {
|
||||
logger.error({ ...ctx, error }, 'Form submission failed');
|
||||
throw error;
|
||||
}
|
||||
throw error; // Re-throw redirect
|
||||
}
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: FormSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
179
.claude/skills/server-action-builder/reference.md
Normal file
179
.claude/skills/server-action-builder/reference.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Server Action Reference
|
||||
|
||||
## enhanceAction API
|
||||
|
||||
```typescript
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
|
||||
export const myAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
// data: validated input (typed from schema)
|
||||
// user: authenticated user object (if auth: true)
|
||||
|
||||
return { success: true, data: result };
|
||||
},
|
||||
{
|
||||
auth: true, // Require authentication (default: false)
|
||||
schema: ZodSchema, // Zod schema for validation (optional)
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `auth` | `boolean` | `false` | Require authenticated user |
|
||||
| `schema` | `ZodSchema` | - | Zod schema for input validation |
|
||||
|
||||
### Handler Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `data` | `z.infer<Schema>` | Validated input data |
|
||||
| `user` | `User` | Authenticated user (if auth: true) |
|
||||
|
||||
## enhanceRouteHandler API
|
||||
|
||||
```typescript
|
||||
import { enhanceRouteHandler } from '@kit/next/routes';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const POST = enhanceRouteHandler(
|
||||
async function ({ body, user, request }) {
|
||||
// body: validated request body
|
||||
// user: authenticated user (if auth: true)
|
||||
// request: original NextRequest
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: ZodSchema,
|
||||
},
|
||||
);
|
||||
|
||||
export const GET = enhanceRouteHandler(
|
||||
async function ({ user, request }) {
|
||||
const url = new URL(request.url);
|
||||
const param = url.searchParams.get('param');
|
||||
|
||||
return NextResponse.json({ data: result });
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Common Zod Patterns
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
|
||||
// Basic schema
|
||||
export const CreateItemSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
description: z.string().optional(),
|
||||
accountId: z.string().uuid('Invalid account ID'),
|
||||
});
|
||||
|
||||
// With transforms
|
||||
export const SearchSchema = z.object({
|
||||
query: z.string().trim().min(1),
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(10),
|
||||
});
|
||||
|
||||
// With refinements
|
||||
export const DateRangeSchema = z.object({
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
}).refine(
|
||||
(data) => data.endDate > data.startDate,
|
||||
{ message: 'End date must be after start date' }
|
||||
);
|
||||
|
||||
// Enum values
|
||||
export const StatusSchema = z.object({
|
||||
status: z.enum(['active', 'inactive', 'pending']),
|
||||
});
|
||||
```
|
||||
|
||||
## Revalidation
|
||||
|
||||
```typescript
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { revalidateTag } from 'next/cache';
|
||||
|
||||
// Revalidate specific path
|
||||
revalidatePath('/home/[account]/items');
|
||||
|
||||
// Revalidate with dynamic segment
|
||||
revalidatePath(`/home/${accountSlug}/items`);
|
||||
|
||||
// Revalidate by tag
|
||||
revalidateTag('items');
|
||||
```
|
||||
|
||||
## Redirect
|
||||
|
||||
```typescript
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
// Redirect after action
|
||||
redirect('/success');
|
||||
|
||||
// Redirect with dynamic path
|
||||
redirect(`/home/${accountSlug}/items/${itemId}`);
|
||||
```
|
||||
|
||||
## Logging
|
||||
|
||||
```typescript
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
|
||||
const logger = await getLogger();
|
||||
|
||||
// Context object for all logs
|
||||
const ctx = {
|
||||
name: 'action-name',
|
||||
userId: user.id,
|
||||
accountId: data.accountId,
|
||||
};
|
||||
|
||||
// Log levels
|
||||
logger.info(ctx, 'Starting operation');
|
||||
logger.warn({ ...ctx, warning: 'details' }, 'Warning message');
|
||||
logger.error({ ...ctx, error }, 'Operation failed');
|
||||
```
|
||||
|
||||
## Supabase Clients
|
||||
|
||||
```typescript
|
||||
// Standard client (RLS enforced)
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
// Admin client (bypasses RLS - use sparingly)
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect-error';
|
||||
|
||||
try {
|
||||
await operation();
|
||||
redirect('/success');
|
||||
} catch (error) {
|
||||
if (!isRedirectError(error)) {
|
||||
// Handle actual error
|
||||
logger.error({ error }, 'Operation failed');
|
||||
throw error;
|
||||
}
|
||||
throw error; // Re-throw redirect
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user