Optimized agents rules subfolders, dependencies updates (#355)
* Update AGENTS.md and CLAUDE.md for improved clarity and structure * Added MCP Server * Added missing triggers to tables that should have used them * Updated all dependencies * Fixed rare bug in React present in the Admin layout which prevents navigating to pages (sometimes...)
This commit is contained in:
committed by
GitHub
parent
9fae142f2d
commit
533dfba5b9
@@ -3,6 +3,7 @@ description: Writing Forms with Shadcn UI, Server Actions, Zod
|
||||
globs: apps/**/*.tsx,packages/**/*.tsx
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Forms
|
||||
|
||||
- Use React Hook Form for form validation and submission.
|
||||
@@ -10,6 +11,7 @@ alwaysApply: false
|
||||
- Use the `zodResolver` function to resolve the Zod schema to the form.
|
||||
- Use Server Actions [server-actions.mdc](mdc:.cursor/rules/server-actions.mdc) for server-side code handling
|
||||
- Use Sonner for writing toasters for UI feedback
|
||||
- Never add generics to `useForm`, use Zod resolver to infer types instead
|
||||
|
||||
Follow the example below to create all forms:
|
||||
|
||||
@@ -35,7 +37,9 @@ Server Actions [server-actions.mdc](mdc:.cursor/rules/server-actions.mdc) can he
|
||||
'use server';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
|
||||
import { CreateNoteSchema } from '../schema/create-note.schema';
|
||||
|
||||
export const createNoteAction = enhanceAction(
|
||||
@@ -144,4 +148,4 @@ export function CreateNoteForm() {
|
||||
}
|
||||
```
|
||||
|
||||
Always use `@kit/ui` for writing the UI of the form.
|
||||
Always use `@kit/ui` for writing the UI of the form.
|
||||
|
||||
1
.gemini/settings.json
Normal file
1
.gemini/settings.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "contextFileName": "AGENTS.md" }
|
||||
@@ -15,7 +15,7 @@ class UserService {
|
||||
}
|
||||
|
||||
export function createUserService() {
|
||||
return new UserService();
|
||||
return new UserService();
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
610
AGENTS.md
610
AGENTS.md
@@ -1,25 +1,23 @@
|
||||
# AGENTS.md
|
||||
This file provides guidance to Claude Code when working with code in this repository.
|
||||
|
||||
This AGENTS.md file provides comprehensive guidance for OpenAI Codex and other AI agents working with this codebase.
|
||||
## Core Technologies
|
||||
|
||||
### Core Technologies
|
||||
|
||||
- **Next.js 15** with App Router and Turbopack
|
||||
- **Next.js 15** with App Router
|
||||
- **Supabase** for database, auth, and storage
|
||||
- **React 19** with React Compiler
|
||||
- **TypeScript** with strict configuration
|
||||
- **Tailwind CSS 4** for styling
|
||||
- **Turborepo** for monorepo management
|
||||
- **React 19**
|
||||
- **TypeScript**
|
||||
- **Tailwind CSS 4** and Shadcn UI
|
||||
- **Turborepo**
|
||||
|
||||
### Monorepo Structure
|
||||
## Monorepo Structure
|
||||
|
||||
- @apps/web - Main Next.js SaaS application
|
||||
- @apps/dev-tool - Development utilities (port 3010)
|
||||
- @apps/e2e - Playwright end-to-end tests
|
||||
- @packages/ - Shared packages and utilities
|
||||
- @tooling/ - Build tools and development scripts
|
||||
- `apps/web` - Main Next.js SaaS application
|
||||
- `apps/e2e` - Playwright end-to-end tests
|
||||
- `packages/features/*` - Feature packages
|
||||
- `packages/` - Shared packages and utilities
|
||||
- `tooling/` - Build tools and development scripts
|
||||
|
||||
### Multi-Tenant Architecture
|
||||
## Multi-Tenant Architecture
|
||||
|
||||
**Personal Accounts**: Individual user accounts (auth.users.id = accounts.id)
|
||||
**Team Accounts**: Shared workspaces with members, roles, and permissions
|
||||
@@ -33,8 +31,6 @@ Data associates with accounts via foreign keys for proper access control.
|
||||
```bash
|
||||
pnpm dev # Start all apps
|
||||
pnpm --filter web dev # Main app (port 3000)
|
||||
pnpm --filter dev-tool dev # Dev tools (port 3010)
|
||||
pnpm build # Build all apps
|
||||
```
|
||||
|
||||
### Database Operations
|
||||
@@ -49,570 +45,28 @@ pnpm --filter web supabase:db:diff # Create migration
|
||||
### Code Quality
|
||||
|
||||
```bash
|
||||
pnpm lint && pnpm format # Lint and format
|
||||
pnpm typecheck # Type checking
|
||||
pnpm test # Run tests
|
||||
pnpm format:fix
|
||||
pnpm lint:fix
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
## Application Structure
|
||||
- Run the typecheck command regularly to ensure your code is type-safe.
|
||||
- Run the linter and the formatter when your task is complete.
|
||||
|
||||
### Route Organization
|
||||
## Typescript
|
||||
|
||||
```
|
||||
app/
|
||||
├── (marketing)/ # Public pages (landing, blog, docs)
|
||||
├── (auth)/ # Authentication pages
|
||||
├── home/
|
||||
│ ├── (user)/ # Personal account context
|
||||
│ └── [account]/ # Team account context ([account] = team slug)
|
||||
├── admin/ # Super admin section
|
||||
└── api/ # API routes
|
||||
```
|
||||
- Write clean, clear, well-designed, explicit TypeScript
|
||||
- Avoid obvious comments
|
||||
- Avoid unnecessary complexity or overly abstract code
|
||||
- Always use implicit type inference, unless impossible
|
||||
- You must avoid using `any`
|
||||
- Handle errors gracefully using try/catch and appropriate error types
|
||||
|
||||
Key Examples:
|
||||
## React
|
||||
|
||||
- Marketing layout: @apps/web/app/(marketing)/layout.tsx
|
||||
- Personal dashboard: @apps/web/app/home/(user)/page.tsx
|
||||
- Team workspace: @apps/web/app/home/[account]/page.tsx
|
||||
- Admin section: @apps/web/app/admin/page.tsx
|
||||
|
||||
### Component Organization
|
||||
|
||||
- **Route-specific**: Use \_components/ directories
|
||||
- **Route utilities**: Use \_lib/ for client, \_lib/server/ for server-side
|
||||
- **Global components**: Root-level directories
|
||||
|
||||
Example:
|
||||
|
||||
- Team components: @apps/web/app/home/[account]/\_components/
|
||||
- Team server utils: @apps/web/app/home/[account]/\_lib/server/
|
||||
- Marketing components: @apps/web/app/(marketing)/\_components/
|
||||
|
||||
## Database Guidelines
|
||||
|
||||
### Security & RLS Implementation
|
||||
|
||||
**Critical Security Guidelines - Read Carefully! ⚠️**
|
||||
|
||||
#### Database Security Fundamentals
|
||||
|
||||
- **Always enable RLS** on new tables unless explicitly instructed otherwise
|
||||
- **NEVER use SECURITY DEFINER functions** without explicit access controls - they bypass RLS entirely
|
||||
- **Always use security_invoker=true for views** to maintain proper access control
|
||||
- **Storage buckets MUST validate access** using account_id in the path structure. See @apps/web/supabase/schemas/16-storage.sql for proper implementation.
|
||||
- **Use locks if required**: Database locks prevent race conditions and timing attacks in concurrent operations. Make sure to take these into account for all database operations.
|
||||
|
||||
#### Security Definer Function - Dangerous Pattern ❌
|
||||
|
||||
```sql
|
||||
-- NEVER DO THIS - Allows any authenticated user to call function
|
||||
CREATE OR REPLACE FUNCTION public.dangerous_function()
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER AS $
|
||||
BEGIN
|
||||
-- This bypasses all RLS policies!
|
||||
DELETE FROM sensitive_table; -- Anyone can call this!
|
||||
END;
|
||||
$;
|
||||
GRANT EXECUTE ON FUNCTION public.dangerous_function() TO authenticated;
|
||||
```
|
||||
|
||||
#### Security Definer Function - Safe Pattern ✅
|
||||
|
||||
```sql
|
||||
-- ONLY use SECURITY DEFINER with explicit access validation
|
||||
CREATE OR REPLACE FUNCTION public.safe_admin_function(target_account_id uuid)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = '' AS $
|
||||
BEGIN
|
||||
-- MUST validate caller has permission FIRST
|
||||
IF NOT public.is_account_owner(target_account_id) THEN
|
||||
RAISE EXCEPTION 'Access denied: insufficient permissions';
|
||||
END IF;
|
||||
|
||||
-- Now safe to proceed with elevated privileges
|
||||
-- Your admin operation here
|
||||
END;
|
||||
$;
|
||||
```
|
||||
|
||||
#### Existing Helper Functions - Use These! 📚
|
||||
|
||||
**DO NOT recreate these functions - they already exist:**
|
||||
|
||||
```sql
|
||||
-- Account Access Control
|
||||
public.has_role_on_account(account_id, role?) -- Check team membership
|
||||
public.has_permission(user_id, account_id, permission) -- Check permissions
|
||||
public.is_account_owner(account_id) -- Verify ownership
|
||||
public.has_active_subscription(account_id) -- Subscription status
|
||||
public.is_team_member(account_id, user_id) -- Direct membership check
|
||||
public.can_action_account_member(target_account_id, target_user_id) -- Member action rights
|
||||
|
||||
-- Administrative Functions
|
||||
public.is_super_admin() -- Super admin check
|
||||
public.is_aal2() -- MFA verification
|
||||
public.is_mfa_compliant() -- MFA compliance
|
||||
|
||||
-- Configuration
|
||||
public.is_set(field_name) -- Feature flag checks
|
||||
```
|
||||
|
||||
Always check @apps/web/supabase/schemas/ before creating new functions!
|
||||
|
||||
#### RLS Policy Best Practices ✅
|
||||
|
||||
```sql
|
||||
-- Proper RLS using existing helper functions
|
||||
CREATE POLICY "notes_read" ON public.notes FOR SELECT
|
||||
TO authenticated USING (
|
||||
account_id = (select auth.uid()) OR
|
||||
public.has_role_on_account(account_id)
|
||||
);
|
||||
|
||||
-- For operations requiring specific permissions
|
||||
CREATE POLICY "notes_manage" ON public.notes FOR ALL
|
||||
TO authenticated USING (
|
||||
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
|
||||
);
|
||||
```
|
||||
|
||||
### Schema Management Workflow
|
||||
|
||||
1. Create schemas in @apps/web/supabase/schemas/ as `<number>-<name>.sql`
|
||||
2. After changes: `pnpm supabase:web:stop`
|
||||
3. Run: `pnpm --filter web run supabase:db:diff -f <filename>`
|
||||
4. Restart: `pnpm supabase:web:start` and `pnpm supabase:web:reset`
|
||||
5. Generate types: `pnpm supabase:web:typegen`
|
||||
|
||||
Key schema files:
|
||||
|
||||
- Accounts: @apps/web/supabase/schemas/03-accounts.sql
|
||||
- Memberships: @apps/web/supabase/schemas/05-memberships.sql
|
||||
- Permissions: @apps/web/supabase/schemas/06-roles-permissions.sql
|
||||
|
||||
### Type Generation
|
||||
|
||||
```typescript
|
||||
import { Tables } from '@kit/supabase/database';
|
||||
|
||||
type Account = Tables<'accounts'>;
|
||||
```
|
||||
|
||||
Always prefer inferring types from generated Database types.
|
||||
|
||||
## Development Patterns
|
||||
|
||||
### Data Fetching Strategy
|
||||
|
||||
**Quick Decision Framework:**
|
||||
|
||||
- **Server Components**: Default choice for initial data loading
|
||||
- **Client Components**: For interactive features requiring hooks or real-time updates
|
||||
- **Admin Client**: Only for bypassing RLS (rare cases - requires manual auth/authorization)
|
||||
|
||||
#### Server Components (Preferred) ✅
|
||||
|
||||
```typescript
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
async function NotesPage() {
|
||||
const client = getSupabaseServerClient();
|
||||
const { data, error } = await client.from('notes').select('*');
|
||||
|
||||
if (error) return <ErrorMessage error={error} />;
|
||||
return <NotesList notes={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
**Key Insight**: Server Components automatically inherit RLS protection - no additional authorization checks needed!
|
||||
|
||||
#### Client Components (Interactive) 🖱️
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
function InteractiveNotes() {
|
||||
const supabase = useSupabase();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['notes'],
|
||||
queryFn: () => supabase.from('notes').select('*')
|
||||
});
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
return <NotesList notes={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
#### Performance Optimization - Parallel Data Fetching 🚀
|
||||
|
||||
**Sequential (Slow) Pattern ❌**
|
||||
|
||||
```typescript
|
||||
async function SlowDashboard() {
|
||||
const userData = await loadUserData();
|
||||
const notifications = await loadNotifications();
|
||||
const metrics = await loadMetrics();
|
||||
// Total time: sum of all requests
|
||||
}
|
||||
```
|
||||
|
||||
**Parallel (Optimized) Pattern ✅**
|
||||
|
||||
```typescript
|
||||
async function FastDashboard() {
|
||||
// Execute all requests simultaneously
|
||||
const [userData, notifications, metrics] = await Promise.all([
|
||||
loadUserData(),
|
||||
loadNotifications(),
|
||||
loadMetrics()
|
||||
]);
|
||||
// Total time: longest single request
|
||||
|
||||
return <Dashboard user={userData} notifications={notifications} metrics={metrics} />;
|
||||
}
|
||||
```
|
||||
|
||||
**Performance Impact**: Parallel fetching can reduce page load time by 60-80% for multi-data pages!
|
||||
|
||||
### Authorization Patterns - Critical Understanding 🔐
|
||||
|
||||
#### RLS-Protected Data Fetching (Standard) ✅
|
||||
|
||||
```typescript
|
||||
async function getUserNotes(userId: string) {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
// RLS automatically ensures user can only access their own notes
|
||||
// NO additional authorization checks needed!
|
||||
const { data } = await client.from('notes').select('*').eq('user_id', userId); // RLS validates this automatically
|
||||
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
#### Admin Client Usage (Dangerous - Rare Cases Only) ⚠️
|
||||
|
||||
```typescript
|
||||
async function adminGetUserNotes(userId: string) {
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
// CRITICAL: Manual authorization required - bypasses RLS!
|
||||
const currentUser = await getCurrentUser();
|
||||
if (!(await isSuperAdmin(currentUser))) {
|
||||
throw new Error('Unauthorized: Admin access required');
|
||||
}
|
||||
|
||||
// Additional validation: ensure current admin isn't targeting themselves
|
||||
if (currentUser.id === userId) {
|
||||
throw new Error('Cannot perform admin action on own account');
|
||||
}
|
||||
|
||||
// Now safe to proceed with admin privileges
|
||||
const { data } = await adminClient
|
||||
.from('notes')
|
||||
.select('*')
|
||||
.eq('user_id', userId);
|
||||
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
**Rule of thumb**: If using standard Supabase client, trust RLS. If using admin client, validate everything manually.
|
||||
|
||||
### Server Actions Implementation
|
||||
|
||||
Always use `enhanceAction` from @packages/next/src/actions/index.ts:
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
|
||||
export const createNoteAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
// data is validated, user is authenticated
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: CreateNoteSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
Example server actions:
|
||||
|
||||
- Team billing: @apps/web/app/home/[account]/billing/\_lib/server/server-actions.ts
|
||||
- Personal settings: @apps/web/app/home/(user)/settings/\_lib/server/server-actions.ts
|
||||
|
||||
### Forms with React Hook Form & Zod
|
||||
|
||||
```typescript
|
||||
// 1. Schema in separate file
|
||||
export const CreateNoteSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
});
|
||||
|
||||
// 2. Client component with form
|
||||
'use client';
|
||||
const form = useForm({
|
||||
resolver: zodResolver(CreateNoteSchema),
|
||||
});
|
||||
|
||||
const onSubmit = (data) => {
|
||||
startTransition(async () => {
|
||||
await toast.promise(createNoteAction(data), {
|
||||
loading: 'Creating...',
|
||||
success: 'Created!',
|
||||
error: 'Failed!',
|
||||
});
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
Form examples:
|
||||
|
||||
- Contact form: @apps/web/app/(marketing)/contact/\_components/contact-form.tsx
|
||||
- Verify OTP form: @packages/otp/src/components/verify-otp-form.tsx
|
||||
|
||||
### Route Handlers (API Routes)
|
||||
|
||||
Use `enhanceRouteHandler` from @packages/next/src/routes/index.ts:
|
||||
|
||||
```typescript
|
||||
import { enhanceRouteHandler } from '@kit/next/routes';
|
||||
|
||||
export const POST = enhanceRouteHandler(
|
||||
async function ({ body, user, request }) {
|
||||
// body is validated, user available if auth: true
|
||||
return NextResponse.json({ success: true });
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: ZodSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## React & TypeScript Best Practices
|
||||
|
||||
### TS
|
||||
|
||||
- Write clean, clear, well-designed, explicit Typescript
|
||||
- Use implicit type inference, unless impossible
|
||||
- `any` and `unknown` are a code smell and must justified if used
|
||||
- Handle errors gracefully using try/catch and appropriate error types.
|
||||
|
||||
### Components
|
||||
|
||||
- Use functional components with TypeScript
|
||||
- Use functional components
|
||||
- Always use 'use client' directive for client components
|
||||
- Destructure props with proper TypeScript interfaces
|
||||
- Name files to match component name (e.g., user-profile.tsx)
|
||||
|
||||
### Conditional Rendering
|
||||
|
||||
Use the `If` component from @packages/ui/src/makerkit/if.tsx:
|
||||
|
||||
```tsx
|
||||
import { If } from '@kit/ui/if';
|
||||
|
||||
<If condition={isLoading} fallback={<Content />}>
|
||||
<Spinner />
|
||||
</If>
|
||||
|
||||
// With type inference
|
||||
<If condition={error}>
|
||||
{(err) => <ErrorMessage error={err} />}
|
||||
</If>
|
||||
```
|
||||
|
||||
### Testing Attributes
|
||||
|
||||
```tsx
|
||||
<button data-test="submit-button">Submit</button>
|
||||
<div data-test="user-profile" data-user-id={user.id}>Profile</div>
|
||||
```
|
||||
|
||||
### Internationalization
|
||||
|
||||
Always use `Trans` component from @packages/ui/src/makerkit/trans.tsx:
|
||||
|
||||
```tsx
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
<Trans
|
||||
i18nKey="user:welcomeMessage"
|
||||
values={{ name: user.name }}
|
||||
/>
|
||||
|
||||
// With HTML elements
|
||||
<Trans
|
||||
i18nKey="terms:agreement"
|
||||
components={{
|
||||
TermsLink: <a href="/terms" className="underline" />,
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
Adding new languages:
|
||||
|
||||
1. Add language code to @apps/web/lib/i18n/i18n.settings.ts
|
||||
2. Create translation files in @apps/web/public/locales/[new-language]/
|
||||
3. Copy structure from English files
|
||||
|
||||
Translation files: @apps/web/public/locales/<locale>/<namespace>.json
|
||||
|
||||
## Security Guidelines 🛡️
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- Authentication enforced by middleware
|
||||
- Authorization handled by RLS at database level
|
||||
- Avoid defensive code - use RLS instead
|
||||
- For admin client usage, enforce both authentication and authorization
|
||||
|
||||
### Data Passing
|
||||
|
||||
- **Never pass sensitive data** to Client Components
|
||||
- **Never expose server environment variables** to client (unless prefixed with NEXT_PUBLIC)
|
||||
- Always validate user input
|
||||
|
||||
### OTP for Sensitive Operations
|
||||
|
||||
Use one-time tokens from @packages/otp/src/api/index.ts:
|
||||
|
||||
```tsx
|
||||
import { VerifyOtpForm } from '@kit/otp/components';
|
||||
|
||||
<VerifyOtpForm
|
||||
purpose="account-deletion"
|
||||
email={user.email}
|
||||
onSuccess={(otp) => {
|
||||
// Proceed with verified operation
|
||||
}}
|
||||
/>;
|
||||
```
|
||||
|
||||
### Super Admin Protection
|
||||
|
||||
For admin routes, use `AdminGuard` from @packages/features/admin/src/components/admin-guard.tsx:
|
||||
|
||||
```tsx
|
||||
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
||||
|
||||
export default AdminGuard(AdminPageComponent);
|
||||
```
|
||||
|
||||
## UI Components 🎨
|
||||
|
||||
### Core UI Library
|
||||
|
||||
Import from @packages/ui/src/:
|
||||
|
||||
```tsx
|
||||
// Shadcn components
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card } from '@kit/ui/card';
|
||||
// Makerkit components
|
||||
import { If } from '@kit/ui/if';
|
||||
import { ProfileAvatar } from '@kit/ui/profile-avatar';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
```
|
||||
|
||||
### Styling
|
||||
|
||||
- Use Tailwind CSS v4 with semantic classes
|
||||
- Prefer Shadcn-ui classes like `bg-background`, `text-muted-foreground`
|
||||
- Use `cn()` utility from @kit/ui/cn for class merging
|
||||
|
||||
## Workspace Contexts 🏢
|
||||
|
||||
### Personal Account Context (@apps/web/app/home/(user))
|
||||
|
||||
```tsx
|
||||
import { useUserWorkspace } from '@kit/accounts/hooks/use-user-workspace';
|
||||
|
||||
function PersonalComponent() {
|
||||
const { user, account } = useUserWorkspace();
|
||||
// Personal account data
|
||||
}
|
||||
```
|
||||
|
||||
Context provider: @packages/features/accounts/src/components/user-workspace-context-provider.tsx
|
||||
|
||||
### Team Account Context (@apps/web/app/home/[account])
|
||||
|
||||
```tsx
|
||||
import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';
|
||||
|
||||
function TeamComponent() {
|
||||
const { account, user, accounts } = useTeamAccountWorkspace();
|
||||
// Team account data with permissions
|
||||
}
|
||||
```
|
||||
|
||||
Context provider: @packages/features/team-accounts/src/components/team-account-workspace-context-provider.tsx
|
||||
|
||||
## Error Handling & Logging 📊
|
||||
|
||||
### Structured Logging
|
||||
|
||||
Use logger from @packages/shared/src/logger/logger.ts:
|
||||
|
||||
```typescript
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
|
||||
async function myServerAction() {
|
||||
const logger = await getLogger();
|
||||
const ctx = { name: 'myOperation', userId: user.id };
|
||||
|
||||
try {
|
||||
logger.info(ctx, 'Operation started');
|
||||
// ...
|
||||
} catch (error) {
|
||||
logger.error({ ...ctx, error }, 'Operation failed');
|
||||
// handle error
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Services
|
||||
|
||||
### Account Services
|
||||
|
||||
- Personal accounts API: @packages/features/accounts/src/server/api.ts
|
||||
- Team accounts API: @packages/features/team-accounts/src/server/api.ts
|
||||
- Admin service: @packages/features/admin/src/lib/server/services/admin.service.ts
|
||||
|
||||
### Billing Services
|
||||
|
||||
- Personal billing: @apps/web/app/home/(user)/billing/\_lib/server/user-billing.service.ts
|
||||
- Team billing: @apps/web/app/home/[account]/billing/\_lib/server/team-billing.service.ts
|
||||
- Per-seat billing: @packages/features/team-accounts/src/server/services/account-per-seat-billing.service.ts
|
||||
|
||||
## Key Configuration Files
|
||||
|
||||
- **Feature flags**: @apps/web/config/feature-flags.config.ts
|
||||
- **i18n settings**: @apps/web/lib/i18n/i18n.settings.ts
|
||||
- **Supabase config**: @apps/web/supabase/config.toml
|
||||
- **Middleware**: @apps/web/middleware.ts
|
||||
|
||||
## Quick Reference Checklist ✅
|
||||
|
||||
### Development Workflow
|
||||
|
||||
- [ ] Enable RLS on new tables
|
||||
- [ ] Generate TypeScript types after schema changes and infer types from these
|
||||
- [ ] Implement proper error handling with logging
|
||||
- [ ] Use Zod schemas for parsing all user input (including cookies, query params, etc.)
|
||||
- [ ] Add testing attributes to interactive elements
|
||||
- [ ] Validate permissions before sensitive operations
|
||||
- Add `data-test` for E2E tests where appropriate
|
||||
- `useEffect` is a code smell and must be justified - avoid if possible
|
||||
- Do not write many separate `useState`, prefer single state object (unless required)
|
||||
- Prefer server-side data fetching using RSC
|
||||
608
CLAUDE.md
608
CLAUDE.md
@@ -1,25 +1,23 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code when working with code in this repository.
|
||||
|
||||
### Core Technologies
|
||||
## Core Technologies
|
||||
|
||||
- **Next.js 15** with App Router and Turbopack
|
||||
- **Next.js 15** with App Router
|
||||
- **Supabase** for database, auth, and storage
|
||||
- **React 19** with React Compiler
|
||||
- **TypeScript** with strict configuration
|
||||
- **Tailwind CSS 4** for styling
|
||||
- **Turborepo** for monorepo management
|
||||
- **React 19**
|
||||
- **TypeScript**
|
||||
- **Tailwind CSS 4** and Shadcn UI
|
||||
- **Turborepo**
|
||||
|
||||
### Monorepo Structure
|
||||
## Monorepo Structure
|
||||
|
||||
- @apps/web - Main Next.js SaaS application
|
||||
- @apps/dev-tool - Development utilities (port 3010)
|
||||
- @apps/e2e - Playwright end-to-end tests
|
||||
- @packages/ - Shared packages and utilities
|
||||
- @tooling/ - Build tools and development scripts
|
||||
- `apps/web` - Main Next.js SaaS application
|
||||
- `apps/e2e` - Playwright end-to-end tests
|
||||
- `packages/features/*` - Feature packages
|
||||
- `packages/` - Shared packages and utilities
|
||||
- `tooling/` - Build tools and development scripts
|
||||
|
||||
### Multi-Tenant Architecture
|
||||
## Multi-Tenant Architecture
|
||||
|
||||
**Personal Accounts**: Individual user accounts (auth.users.id = accounts.id)
|
||||
**Team Accounts**: Shared workspaces with members, roles, and permissions
|
||||
@@ -33,8 +31,6 @@ Data associates with accounts via foreign keys for proper access control.
|
||||
```bash
|
||||
pnpm dev # Start all apps
|
||||
pnpm --filter web dev # Main app (port 3000)
|
||||
pnpm --filter dev-tool dev # Dev tools (port 3010)
|
||||
pnpm build # Build all apps
|
||||
```
|
||||
|
||||
### Database Operations
|
||||
@@ -49,570 +45,28 @@ pnpm --filter web supabase:db:diff # Create migration
|
||||
### Code Quality
|
||||
|
||||
```bash
|
||||
pnpm lint && pnpm format # Lint and format
|
||||
pnpm typecheck # Type checking
|
||||
pnpm test # Run tests
|
||||
pnpm format:fix
|
||||
pnpm lint:fix
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
## Application Structure
|
||||
- Run the typecheck command regularly to ensure your code is type-safe.
|
||||
- Run the linter and the formatter when your task is complete.
|
||||
|
||||
### Route Organization
|
||||
## Typescript
|
||||
|
||||
```
|
||||
app/
|
||||
├── (marketing)/ # Public pages (landing, blog, docs)
|
||||
├── (auth)/ # Authentication pages
|
||||
├── home/
|
||||
│ ├── (user)/ # Personal account context
|
||||
│ └── [account]/ # Team account context ([account] = team slug)
|
||||
├── admin/ # Super admin section
|
||||
└── api/ # API routes
|
||||
```
|
||||
- Write clean, clear, well-designed, explicit TypeScript
|
||||
- Avoid obvious comments
|
||||
- Avoid unnecessary complexity or overly abstract code
|
||||
- Always use implicit type inference, unless impossible
|
||||
- You must avoid using `any`
|
||||
- Handle errors gracefully using try/catch and appropriate error types
|
||||
|
||||
Key Examples:
|
||||
## React
|
||||
|
||||
- Marketing layout: @apps/web/app/(marketing)/layout.tsx
|
||||
- Personal dashboard: @apps/web/app/home/(user)/page.tsx
|
||||
- Team workspace: @apps/web/app/home/[account]/page.tsx
|
||||
- Admin section: @apps/web/app/admin/page.tsx
|
||||
|
||||
### Component Organization
|
||||
|
||||
- **Route-specific**: Use \_components/ directories
|
||||
- **Route utilities**: Use \_lib/ for client, \_lib/server/ for server-side
|
||||
- **Global components**: Root-level directories
|
||||
|
||||
Example:
|
||||
|
||||
- Team components: @apps/web/app/home/[account]/\_components/
|
||||
- Team server utils: @apps/web/app/home/[account]/\_lib/server/
|
||||
- Marketing components: @apps/web/app/(marketing)/\_components/
|
||||
|
||||
## Database Guidelines
|
||||
|
||||
### Security & RLS Implementation
|
||||
|
||||
**Critical Security Guidelines - Read Carefully! ⚠️**
|
||||
|
||||
#### Database Security Fundamentals
|
||||
|
||||
- **Always enable RLS** on new tables unless explicitly instructed otherwise
|
||||
- **NEVER use SECURITY DEFINER functions** without explicit access controls - they bypass RLS entirely
|
||||
- **Always use security_invoker=true for views** to maintain proper access control
|
||||
- **Storage buckets MUST validate access** using account_id in the path structure. See @apps/web/supabase/schemas/16-storage.sql for proper implementation.
|
||||
- **Use locks if required**: Database locks prevent race conditions and timing attacks in concurrent operations. Make sure to take these into account for all database operations.
|
||||
|
||||
#### Security Definer Function - Dangerous Pattern ❌
|
||||
|
||||
```sql
|
||||
-- NEVER DO THIS - Allows any authenticated user to call function
|
||||
CREATE OR REPLACE FUNCTION public.dangerous_function()
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER AS $
|
||||
BEGIN
|
||||
-- This bypasses all RLS policies!
|
||||
DELETE FROM sensitive_table; -- Anyone can call this!
|
||||
END;
|
||||
$;
|
||||
GRANT EXECUTE ON FUNCTION public.dangerous_function() TO authenticated;
|
||||
```
|
||||
|
||||
#### Security Definer Function - Safe Pattern ✅
|
||||
|
||||
```sql
|
||||
-- ONLY use SECURITY DEFINER with explicit access validation
|
||||
CREATE OR REPLACE FUNCTION public.safe_admin_function(target_account_id uuid)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = '' AS $
|
||||
BEGIN
|
||||
-- MUST validate caller has permission FIRST
|
||||
IF NOT public.is_account_owner(target_account_id) THEN
|
||||
RAISE EXCEPTION 'Access denied: insufficient permissions';
|
||||
END IF;
|
||||
|
||||
-- Now safe to proceed with elevated privileges
|
||||
-- Your admin operation here
|
||||
END;
|
||||
$;
|
||||
```
|
||||
|
||||
#### Existing Helper Functions - Use These! 📚
|
||||
|
||||
**DO NOT recreate these functions - they already exist:**
|
||||
|
||||
```sql
|
||||
-- Account Access Control
|
||||
public.has_role_on_account(account_id, role?) -- Check team membership
|
||||
public.has_permission(user_id, account_id, permission) -- Check permissions
|
||||
public.is_account_owner(account_id) -- Verify ownership
|
||||
public.has_active_subscription(account_id) -- Subscription status
|
||||
public.is_team_member(account_id, user_id) -- Direct membership check
|
||||
public.can_action_account_member(target_account_id, target_user_id) -- Member action rights
|
||||
|
||||
-- Administrative Functions
|
||||
public.is_super_admin() -- Super admin check
|
||||
public.is_aal2() -- MFA verification
|
||||
public.is_mfa_compliant() -- MFA compliance
|
||||
|
||||
-- Configuration
|
||||
public.is_set(field_name) -- Feature flag checks
|
||||
```
|
||||
|
||||
Always check @apps/web/supabase/schemas/ before creating new functions!
|
||||
|
||||
#### RLS Policy Best Practices ✅
|
||||
|
||||
```sql
|
||||
-- Proper RLS using existing helper functions
|
||||
CREATE POLICY "notes_read" ON public.notes FOR SELECT
|
||||
TO authenticated USING (
|
||||
account_id = (select auth.uid()) OR
|
||||
public.has_role_on_account(account_id)
|
||||
);
|
||||
|
||||
-- For operations requiring specific permissions
|
||||
CREATE POLICY "notes_manage" ON public.notes FOR ALL
|
||||
TO authenticated USING (
|
||||
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
|
||||
);
|
||||
```
|
||||
|
||||
### Schema Management Workflow
|
||||
|
||||
1. Create schemas in @apps/web/supabase/schemas/ as `<number>-<name>.sql`
|
||||
2. After changes: `pnpm supabase:web:stop`
|
||||
3. Run: `pnpm --filter web run supabase:db:diff -f <filename>`
|
||||
4. Restart: `pnpm supabase:web:start` and `pnpm supabase:web:reset`
|
||||
5. Generate types: `pnpm supabase:web:typegen`
|
||||
|
||||
Key schema files:
|
||||
|
||||
- Accounts: @apps/web/supabase/schemas/03-accounts.sql
|
||||
- Memberships: @apps/web/supabase/schemas/05-memberships.sql
|
||||
- Permissions: @apps/web/supabase/schemas/06-roles-permissions.sql
|
||||
|
||||
### Type Generation
|
||||
|
||||
```typescript
|
||||
import { Tables } from '@kit/supabase/database';
|
||||
|
||||
type Account = Tables<'accounts'>;
|
||||
```
|
||||
|
||||
Always prefer inferring types from generated Database types.
|
||||
|
||||
## Development Patterns
|
||||
|
||||
### Data Fetching Strategy
|
||||
|
||||
**Quick Decision Framework:**
|
||||
|
||||
- **Server Components**: Default choice for initial data loading
|
||||
- **Client Components**: For interactive features requiring hooks or real-time updates
|
||||
- **Admin Client**: Only for bypassing RLS (rare cases - requires manual auth/authorization)
|
||||
|
||||
#### Server Components (Preferred) ✅
|
||||
|
||||
```typescript
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
async function NotesPage() {
|
||||
const client = getSupabaseServerClient();
|
||||
const { data, error } = await client.from('notes').select('*');
|
||||
|
||||
if (error) return <ErrorMessage error={error} />;
|
||||
return <NotesList notes={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
**Key Insight**: Server Components automatically inherit RLS protection - no additional authorization checks needed!
|
||||
|
||||
#### Client Components (Interactive) 🖱️
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
function InteractiveNotes() {
|
||||
const supabase = useSupabase();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['notes'],
|
||||
queryFn: () => supabase.from('notes').select('*')
|
||||
});
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
return <NotesList notes={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
#### Performance Optimization - Parallel Data Fetching 🚀
|
||||
|
||||
**Sequential (Slow) Pattern ❌**
|
||||
|
||||
```typescript
|
||||
async function SlowDashboard() {
|
||||
const userData = await loadUserData();
|
||||
const notifications = await loadNotifications();
|
||||
const metrics = await loadMetrics();
|
||||
// Total time: sum of all requests
|
||||
}
|
||||
```
|
||||
|
||||
**Parallel (Optimized) Pattern ✅**
|
||||
|
||||
```typescript
|
||||
async function FastDashboard() {
|
||||
// Execute all requests simultaneously
|
||||
const [userData, notifications, metrics] = await Promise.all([
|
||||
loadUserData(),
|
||||
loadNotifications(),
|
||||
loadMetrics()
|
||||
]);
|
||||
// Total time: longest single request
|
||||
|
||||
return <Dashboard user={userData} notifications={notifications} metrics={metrics} />;
|
||||
}
|
||||
```
|
||||
|
||||
**Performance Impact**: Parallel fetching can reduce page load time by 60-80% for multi-data pages!
|
||||
|
||||
### Authorization Patterns - Critical Understanding 🔐
|
||||
|
||||
#### RLS-Protected Data Fetching (Standard) ✅
|
||||
|
||||
```typescript
|
||||
async function getUserNotes(userId: string) {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
// RLS automatically ensures user can only access their own notes
|
||||
// NO additional authorization checks needed!
|
||||
const { data } = await client.from('notes').select('*').eq('user_id', userId); // RLS validates this automatically
|
||||
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
#### Admin Client Usage (Dangerous - Rare Cases Only) ⚠️
|
||||
|
||||
```typescript
|
||||
async function adminGetUserNotes(userId: string) {
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
// CRITICAL: Manual authorization required - bypasses RLS!
|
||||
const currentUser = await getCurrentUser();
|
||||
if (!(await isSuperAdmin(currentUser))) {
|
||||
throw new Error('Unauthorized: Admin access required');
|
||||
}
|
||||
|
||||
// Additional validation: ensure current admin isn't targeting themselves
|
||||
if (currentUser.id === userId) {
|
||||
throw new Error('Cannot perform admin action on own account');
|
||||
}
|
||||
|
||||
// Now safe to proceed with admin privileges
|
||||
const { data } = await adminClient
|
||||
.from('notes')
|
||||
.select('*')
|
||||
.eq('user_id', userId);
|
||||
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
**Rule of thumb**: If using standard Supabase client, trust RLS. If using admin client, validate everything manually.
|
||||
|
||||
### Server Actions Implementation
|
||||
|
||||
Always use `enhanceAction` from @packages/next/src/actions/index.ts:
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
|
||||
export const createNoteAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
// data is validated, user is authenticated
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: CreateNoteSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
Example server actions:
|
||||
|
||||
- Team billing: @apps/web/app/home/[account]/billing/\_lib/server/server-actions.ts
|
||||
- Personal settings: @apps/web/app/home/(user)/settings/\_lib/server/server-actions.ts
|
||||
|
||||
### Forms with React Hook Form & Zod
|
||||
|
||||
```typescript
|
||||
// 1. Schema in separate file
|
||||
export const CreateNoteSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
});
|
||||
|
||||
// 2. Client component with form
|
||||
'use client';
|
||||
const form = useForm({
|
||||
resolver: zodResolver(CreateNoteSchema),
|
||||
});
|
||||
|
||||
const onSubmit = (data) => {
|
||||
startTransition(async () => {
|
||||
await toast.promise(createNoteAction(data), {
|
||||
loading: 'Creating...',
|
||||
success: 'Created!',
|
||||
error: 'Failed!',
|
||||
});
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
Form examples:
|
||||
|
||||
- Contact form: @apps/web/app/(marketing)/contact/\_components/contact-form.tsx
|
||||
- Verify OTP form: @packages/otp/src/components/verify-otp-form.tsx
|
||||
|
||||
### Route Handlers (API Routes)
|
||||
|
||||
Use `enhanceRouteHandler` from @packages/next/src/routes/index.ts:
|
||||
|
||||
```typescript
|
||||
import { enhanceRouteHandler } from '@kit/next/routes';
|
||||
|
||||
export const POST = enhanceRouteHandler(
|
||||
async function ({ body, user, request }) {
|
||||
// body is validated, user available if auth: true
|
||||
return NextResponse.json({ success: true });
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: ZodSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## React & TypeScript Best Practices
|
||||
|
||||
### TS
|
||||
|
||||
- Write clean, clear, well-designed, explicit Typescript
|
||||
- Use implicit type inference, unless impossible
|
||||
- `any` and `unknown` are a code smell and must justified if used
|
||||
- Handle errors gracefully using try/catch and appropriate error types.
|
||||
|
||||
### Components
|
||||
|
||||
- Use functional components with TypeScript
|
||||
- Use functional components
|
||||
- Always use 'use client' directive for client components
|
||||
- Destructure props with proper TypeScript interfaces
|
||||
- Name files to match component name (e.g., user-profile.tsx)
|
||||
|
||||
### Conditional Rendering
|
||||
|
||||
Use the `If` component from @packages/ui/src/makerkit/if.tsx:
|
||||
|
||||
```tsx
|
||||
import { If } from '@kit/ui/if';
|
||||
|
||||
<If condition={isLoading} fallback={<Content />}>
|
||||
<Spinner />
|
||||
</If>
|
||||
|
||||
// With type inference
|
||||
<If condition={error}>
|
||||
{(err) => <ErrorMessage error={err} />}
|
||||
</If>
|
||||
```
|
||||
|
||||
### Testing Attributes
|
||||
|
||||
```tsx
|
||||
<button data-test="submit-button">Submit</button>
|
||||
<div data-test="user-profile" data-user-id={user.id}>Profile</div>
|
||||
```
|
||||
|
||||
### Internationalization
|
||||
|
||||
Always use `Trans` component from @packages/ui/src/makerkit/trans.tsx:
|
||||
|
||||
```tsx
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
<Trans
|
||||
i18nKey="user:welcomeMessage"
|
||||
values={{ name: user.name }}
|
||||
/>
|
||||
|
||||
// With HTML elements
|
||||
<Trans
|
||||
i18nKey="terms:agreement"
|
||||
components={{
|
||||
TermsLink: <a href="/terms" className="underline" />,
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
Adding new languages:
|
||||
|
||||
1. Add language code to @apps/web/lib/i18n/i18n.settings.ts
|
||||
2. Create translation files in @apps/web/public/locales/[new-language]/
|
||||
3. Copy structure from English files
|
||||
|
||||
Translation files: @apps/web/public/locales/<locale>/<namespace>.json
|
||||
|
||||
## Security Guidelines 🛡️
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- Authentication enforced by middleware
|
||||
- Authorization handled by RLS at database level
|
||||
- Avoid defensive code - use RLS instead
|
||||
- For admin client usage, enforce both authentication and authorization
|
||||
|
||||
### Data Passing
|
||||
|
||||
- **Never pass sensitive data** to Client Components
|
||||
- **Never expose server environment variables** to client (unless prefixed with NEXT_PUBLIC)
|
||||
- Always validate user input
|
||||
|
||||
### OTP for Sensitive Operations
|
||||
|
||||
Use one-time tokens from @packages/otp/src/api/index.ts:
|
||||
|
||||
```tsx
|
||||
import { VerifyOtpForm } from '@kit/otp/components';
|
||||
|
||||
<VerifyOtpForm
|
||||
purpose="account-deletion"
|
||||
email={user.email}
|
||||
onSuccess={(otp) => {
|
||||
// Proceed with verified operation
|
||||
}}
|
||||
/>;
|
||||
```
|
||||
|
||||
### Super Admin Protection
|
||||
|
||||
For admin routes, use `AdminGuard` from @packages/features/admin/src/components/admin-guard.tsx:
|
||||
|
||||
```tsx
|
||||
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
||||
|
||||
export default AdminGuard(AdminPageComponent);
|
||||
```
|
||||
|
||||
## UI Components 🎨
|
||||
|
||||
### Core UI Library
|
||||
|
||||
Import from @packages/ui/src/:
|
||||
|
||||
```tsx
|
||||
// Shadcn components
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card } from '@kit/ui/card';
|
||||
// Makerkit components
|
||||
import { If } from '@kit/ui/if';
|
||||
import { ProfileAvatar } from '@kit/ui/profile-avatar';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
```
|
||||
|
||||
### Styling
|
||||
|
||||
- Use Tailwind CSS v4 with semantic classes
|
||||
- Prefer Shadcn-ui classes like `bg-background`, `text-muted-foreground`
|
||||
- Use `cn()` utility from @kit/ui/cn for class merging
|
||||
|
||||
## Workspace Contexts 🏢
|
||||
|
||||
### Personal Account Context (@apps/web/app/home/(user))
|
||||
|
||||
```tsx
|
||||
import { useUserWorkspace } from '@kit/accounts/hooks/use-user-workspace';
|
||||
|
||||
function PersonalComponent() {
|
||||
const { user, account } = useUserWorkspace();
|
||||
// Personal account data
|
||||
}
|
||||
```
|
||||
|
||||
Context provider: @packages/features/accounts/src/components/user-workspace-context-provider.tsx
|
||||
|
||||
### Team Account Context (@apps/web/app/home/[account])
|
||||
|
||||
```tsx
|
||||
import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';
|
||||
|
||||
function TeamComponent() {
|
||||
const { account, user, accounts } = useTeamAccountWorkspace();
|
||||
// Team account data with permissions
|
||||
}
|
||||
```
|
||||
|
||||
Context provider: @packages/features/team-accounts/src/components/team-account-workspace-context-provider.tsx
|
||||
|
||||
## Error Handling & Logging 📊
|
||||
|
||||
### Structured Logging
|
||||
|
||||
Use logger from @packages/shared/src/logger/logger.ts:
|
||||
|
||||
```typescript
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
|
||||
async function myServerAction() {
|
||||
const logger = await getLogger();
|
||||
const ctx = { name: 'myOperation', userId: user.id };
|
||||
|
||||
try {
|
||||
logger.info(ctx, 'Operation started');
|
||||
// ...
|
||||
} catch (error) {
|
||||
logger.error({ ...ctx, error }, 'Operation failed');
|
||||
// handle error
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Services
|
||||
|
||||
### Account Services
|
||||
|
||||
- Personal accounts API: @packages/features/accounts/src/server/api.ts
|
||||
- Team accounts API: @packages/features/team-accounts/src/server/api.ts
|
||||
- Admin service: @packages/features/admin/src/lib/server/services/admin.service.ts
|
||||
|
||||
### Billing Services
|
||||
|
||||
- Personal billing: @apps/web/app/home/(user)/billing/\_lib/server/user-billing.service.ts
|
||||
- Team billing: @apps/web/app/home/[account]/billing/\_lib/server/team-billing.service.ts
|
||||
- Per-seat billing: @packages/features/team-accounts/src/server/services/account-per-seat-billing.service.ts
|
||||
|
||||
## Key Configuration Files
|
||||
|
||||
- **Feature flags**: @apps/web/config/feature-flags.config.ts
|
||||
- **i18n settings**: @apps/web/lib/i18n/i18n.settings.ts
|
||||
- **Supabase config**: @apps/web/supabase/config.toml
|
||||
- **Middleware**: @apps/web/middleware.ts
|
||||
|
||||
## Quick Reference Checklist ✅
|
||||
|
||||
### Development Workflow
|
||||
|
||||
- [ ] Enable RLS on new tables
|
||||
- [ ] Generate TypeScript types after schema changes and infer types from these
|
||||
- [ ] Implement proper error handling with logging
|
||||
- [ ] Use Zod schemas for parsing all user input (including cookies, query params, etc.)
|
||||
- [ ] Add testing attributes to interactive elements
|
||||
- [ ] Validate permissions before sensitive operations
|
||||
- Add `data-test` for E2E tests where appropriate
|
||||
- `useEffect` is a code smell and must be justified - avoid if possible
|
||||
- Do not write many separate `useState`, prefer single state object (unless required)
|
||||
- Prefer server-side data fetching using RSC
|
||||
@@ -8,13 +8,13 @@
|
||||
"format": "prettier --check --write \"**/*.{js,cjs,mjs,ts,tsx,md,json}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^2.0.24",
|
||||
"@ai-sdk/openai": "^2.0.30",
|
||||
"@faker-js/faker": "^10.0.0",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@tanstack/react-query": "5.87.1",
|
||||
"ai": "5.0.33",
|
||||
"lucide-react": "^0.542.0",
|
||||
"next": "15.5.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@tanstack/react-query": "5.89.0",
|
||||
"ai": "5.0.44",
|
||||
"lucide-react": "^0.544.0",
|
||||
"next": "15.5.3",
|
||||
"nodemailer": "^7.0.6",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1",
|
||||
@@ -28,9 +28,9 @@
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/node": "^24.5.0",
|
||||
"@types/nodemailer": "7.0.1",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react": "19.1.13",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
||||
"pino-pretty": "13.0.0",
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/node": "^24.5.0",
|
||||
"dotenv": "17.2.2",
|
||||
"node-html-parser": "^7.0.1",
|
||||
"totp-generator": "^1.0.0"
|
||||
"totp-generator": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ test.describe('Admin', () => {
|
||||
),
|
||||
]);
|
||||
|
||||
await expect(page.getByText('Banned')).toBeVisible();
|
||||
await expect(page.getByText('Banned').first()).toBeVisible();
|
||||
|
||||
await page.context().clearCookies();
|
||||
|
||||
@@ -156,7 +156,7 @@ test.describe('Admin', () => {
|
||||
await page.fill('[placeholder="Type CONFIRM to confirm"]', 'CONFIRM');
|
||||
await page.getByRole('button', { name: 'Ban User' }).click();
|
||||
|
||||
await expect(page.getByText('Banned')).toBeVisible();
|
||||
await expect(page.getByText('Banned').first()).toBeVisible();
|
||||
|
||||
// Now reactivate
|
||||
await page.getByTestId('admin-reactivate-account-button').click();
|
||||
@@ -199,6 +199,7 @@ test.describe('Admin', () => {
|
||||
|
||||
test('impersonate user flow', async ({ page }) => {
|
||||
await page.getByTestId('admin-impersonate-button').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Impersonate User' }),
|
||||
).toBeVisible();
|
||||
@@ -394,14 +395,15 @@ async function filterAccounts(page: Page, email: string) {
|
||||
}
|
||||
|
||||
async function selectAccount(page: Page, email: string) {
|
||||
const link = page
|
||||
.locator('tr', { hasText: email.split('@')[0] })
|
||||
.locator('a');
|
||||
await expect(async () => {
|
||||
const link = page
|
||||
.locator('tr', { hasText: email.split('@')[0] })
|
||||
.locator('a');
|
||||
|
||||
await expect(link).toBeVisible();
|
||||
await expect(link).toBeVisible();
|
||||
|
||||
await link.click();
|
||||
await link.click();
|
||||
|
||||
await page.waitForURL(new RegExp(`/admin/accounts/[a-z0-9-]+`));
|
||||
await page.waitForTimeout(500);
|
||||
await page.waitForLoadState('networkidle');
|
||||
}).toPass();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
import { TOTP } from 'totp-generator';
|
||||
|
||||
import { Mailbox } from '../utils/mailbox';
|
||||
|
||||
@@ -50,7 +49,9 @@ export class AuthPageObject {
|
||||
async submitMFAVerification(key: string) {
|
||||
const period = 30;
|
||||
|
||||
const { otp } = TOTP.generate(key, {
|
||||
const { TOTP } = await import('totp-generator');
|
||||
|
||||
const { otp } = await TOTP.generate(key, {
|
||||
period,
|
||||
});
|
||||
|
||||
|
||||
@@ -59,13 +59,13 @@ export class InvitationsPageObject {
|
||||
navigateToMembers() {
|
||||
return expect(async () => {
|
||||
await this.page
|
||||
.locator('a', {
|
||||
hasText: 'Members',
|
||||
})
|
||||
.click();
|
||||
.locator('a', {
|
||||
hasText: 'Members',
|
||||
})
|
||||
.click();
|
||||
|
||||
await this.page.waitForURL('**/home/*/members');
|
||||
}).toPass()
|
||||
}).toPass();
|
||||
}
|
||||
|
||||
async openInviteForm() {
|
||||
@@ -127,6 +127,8 @@ export class InvitationsPageObject {
|
||||
});
|
||||
|
||||
await Promise.all([click, response]);
|
||||
|
||||
console.log('Invitation accepted');
|
||||
}
|
||||
|
||||
private getInviteForm() {
|
||||
|
||||
@@ -84,7 +84,9 @@ test.describe('Team Invitation with MFA Flow', () => {
|
||||
await invitations.acceptInvitation();
|
||||
|
||||
// Should be redirected to the team dashboard
|
||||
await page.waitForURL(`/home/${teamSlug}`);
|
||||
await page.waitForURL(`/home/${teamSlug}`, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
// Step 4: Verify membership was successful
|
||||
// Open account selector to verify team is available
|
||||
|
||||
264
apps/web/AGENTS.md
Normal file
264
apps/web/AGENTS.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# Web Application Instructions
|
||||
|
||||
This file contains instructions specific to the main Next.js web application.
|
||||
|
||||
## Application Structure
|
||||
|
||||
### Route Organization
|
||||
|
||||
```
|
||||
app/
|
||||
├── (marketing)/ # Public pages (landing, blog, docs)
|
||||
├── (auth)/ # Authentication pages
|
||||
├── home/
|
||||
│ ├── (user)/ # Personal account context
|
||||
│ └── [account]/ # Team account context ([account] = team slug)
|
||||
├── admin/ # Super admin section
|
||||
└── api/ # API routes
|
||||
```
|
||||
|
||||
Key Examples:
|
||||
|
||||
- Marketing layout: `app/(marketing)/layout.tsx`
|
||||
- Personal dashboard: `app/home/(user)/page.tsx`
|
||||
- Team workspace: `app/home/[account]/page.tsx`
|
||||
- Admin section: `app/admin/page.tsx`
|
||||
|
||||
### Component Organization
|
||||
|
||||
- **Route-specific**: Use `_components/` directories
|
||||
- **Route utilities**: Use `_lib/` for client, `_lib/server/` for server-side
|
||||
- **Global components**: Root-level directories
|
||||
|
||||
Example:
|
||||
|
||||
- Team components: `app/home/[account]/_components/`
|
||||
- Team server utils: `app/home/[account]/_lib/server/`
|
||||
- Marketing components: `app/(marketing)/_components/`
|
||||
|
||||
## Data Fetching Strategy
|
||||
|
||||
**Quick Decision Framework:**
|
||||
|
||||
- **Server Components**: Default choice for initial data loading
|
||||
- **Client Components**: For interactive features requiring hooks or real-time updates
|
||||
- **Admin Client**: Only for bypassing RLS (rare cases - requires manual auth/authorization)
|
||||
|
||||
### Server Components (Preferred) ✅
|
||||
|
||||
```typescript
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
async function NotesPage() {
|
||||
const client = getSupabaseServerClient();
|
||||
const { data, error } = await client.from('notes').select('*');
|
||||
|
||||
if (error) return <ErrorMessage error={error} />;
|
||||
return <NotesList notes={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
**Key Insight**: Server Components automatically inherit RLS protection - no additional authorization checks needed!
|
||||
|
||||
### Client Components (Interactive) 🖱️
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
function InteractiveNotes() {
|
||||
const supabase = useSupabase();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['notes'],
|
||||
queryFn: () => supabase.from('notes').select('*')
|
||||
});
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
return <NotesList notes={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Optimization - Parallel Data Fetching 🚀
|
||||
|
||||
**Sequential (Slow) Pattern ❌**
|
||||
|
||||
```typescript
|
||||
async function SlowDashboard() {
|
||||
const userData = await loadUserData();
|
||||
const notifications = await loadNotifications();
|
||||
const metrics = await loadMetrics();
|
||||
// Total time: sum of all requests
|
||||
}
|
||||
```
|
||||
|
||||
**Parallel (Optimized) Pattern ✅**
|
||||
|
||||
```typescript
|
||||
async function FastDashboard() {
|
||||
// Execute all requests simultaneously
|
||||
const [userData, notifications, metrics] = await Promise.all([
|
||||
loadUserData(),
|
||||
loadNotifications(),
|
||||
loadMetrics()
|
||||
]);
|
||||
// Total time: longest single request
|
||||
|
||||
return <Dashboard user={userData} notifications={notifications} metrics={metrics} />;
|
||||
}
|
||||
```
|
||||
|
||||
**Performance Impact**: Parallel fetching can reduce page load time by 60-80% for multi-data pages!
|
||||
|
||||
## Authorization Patterns - Critical Understanding 🔐
|
||||
|
||||
### RLS-Protected Data Fetching (Standard) ✅
|
||||
|
||||
```typescript
|
||||
async function getUserNotes(userId: string) {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
// RLS automatically ensures user can only access their own notes
|
||||
// NO additional authorization checks needed!
|
||||
const { data } = await client.from('notes').select('*').eq('user_id', userId); // RLS validates this automatically
|
||||
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
### Admin Client Usage (Dangerous - Rare Cases Only) ⚠️
|
||||
|
||||
```typescript
|
||||
async function adminGetUserNotes(userId: string) {
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
// CRITICAL: Manual authorization required - bypasses RLS!
|
||||
const currentUser = await getCurrentUser();
|
||||
if (!(await isSuperAdmin(currentUser))) {
|
||||
throw new Error('Unauthorized: Admin access required');
|
||||
}
|
||||
|
||||
// Additional validation: ensure current admin isn't targeting themselves
|
||||
if (currentUser.id === userId) {
|
||||
throw new Error('Cannot perform admin action on own account');
|
||||
}
|
||||
|
||||
// Now safe to proceed with admin privileges
|
||||
const { data } = await adminClient
|
||||
.from('notes')
|
||||
.select('*')
|
||||
.eq('user_id', userId);
|
||||
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
**Rule of thumb**: If using standard Supabase client, trust RLS. If using admin client, validate everything manually.
|
||||
|
||||
## Internationalization
|
||||
|
||||
Always use `Trans` component from `@kit/ui/trans`:
|
||||
|
||||
```tsx
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
<Trans
|
||||
i18nKey="user:welcomeMessage"
|
||||
values={{ name: user.name }}
|
||||
/>
|
||||
|
||||
// With HTML elements
|
||||
<Trans
|
||||
i18nKey="terms:agreement"
|
||||
components={{
|
||||
TermsLink: <a href="/terms" className="underline" />,
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Adding New Languages
|
||||
|
||||
1. Add language code to `lib/i18n/i18n.settings.ts`
|
||||
2. Create translation files in `public/locales/[new-language]/`
|
||||
3. Copy structure from English files
|
||||
|
||||
Translation files: `public/locales/<locale>/<namespace>.json`
|
||||
|
||||
## Workspace Contexts 🏢
|
||||
|
||||
### Personal Account Context (`app/home/(user)`)
|
||||
|
||||
```tsx
|
||||
import { useUserWorkspace } from '@kit/accounts/hooks/use-user-workspace';
|
||||
|
||||
function PersonalComponent() {
|
||||
const { user, account } = useUserWorkspace();
|
||||
// Personal account data
|
||||
}
|
||||
```
|
||||
|
||||
Context provider: `@packages/features/accounts/src/components/user-workspace-context-provider.tsx`
|
||||
|
||||
### Team Account Context (`app/home/[account]`)
|
||||
|
||||
```tsx
|
||||
import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';
|
||||
|
||||
function TeamComponent() {
|
||||
const { account, user, accounts } = useTeamAccountWorkspace();
|
||||
// Team account data with permissions
|
||||
}
|
||||
```
|
||||
|
||||
Context provider: `@packages/features/team-accounts/src/components/team-account-workspace-context-provider.tsx`
|
||||
|
||||
## Key Configuration Files
|
||||
|
||||
- **Feature flags**: `config/feature-flags.config.ts`
|
||||
- **i18n settings**: `lib/i18n/i18n.settings.ts`
|
||||
- **Supabase config**: `supabase/config.toml`
|
||||
- **Middleware**: `middleware.ts`
|
||||
|
||||
## Route Handlers (API Routes)
|
||||
|
||||
Use `enhanceRouteHandler` from `@packages/next/src/routes/index.ts`:
|
||||
|
||||
```typescript
|
||||
import { enhanceRouteHandler } from '@kit/next/routes';
|
||||
|
||||
export const POST = enhanceRouteHandler(
|
||||
async function ({ body, user, request }) {
|
||||
// body is validated, user available if auth: true
|
||||
return NextResponse.json({ success: true });
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: ZodSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Security Guidelines 🛡️
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- Authentication already enforced by middleware
|
||||
- Authorization handled by RLS at database level (in most cases)
|
||||
- Avoid defensive code - use RLS instead
|
||||
- When using the Supabase admin client, must enforce both authentication and authorization
|
||||
|
||||
### Passing data to the client
|
||||
|
||||
- **Never pass sensitive data** to Client Components
|
||||
- **Never expose server environment variables** to client (unless prefixed with NEXT_PUBLIC)
|
||||
- Always validate user input
|
||||
|
||||
### Super Admin Protection
|
||||
|
||||
For admin routes, use `AdminGuard` from `@packages/features/admin/src/components/admin-guard.tsx`:
|
||||
|
||||
```tsx
|
||||
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
||||
|
||||
export default AdminGuard(AdminPageComponent);
|
||||
```
|
||||
264
apps/web/CLAUDE.md
Normal file
264
apps/web/CLAUDE.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# Web Application Instructions
|
||||
|
||||
This file contains instructions specific to the main Next.js web application.
|
||||
|
||||
## Application Structure
|
||||
|
||||
### Route Organization
|
||||
|
||||
```
|
||||
app/
|
||||
├── (marketing)/ # Public pages (landing, blog, docs)
|
||||
├── (auth)/ # Authentication pages
|
||||
├── home/
|
||||
│ ├── (user)/ # Personal account context
|
||||
│ └── [account]/ # Team account context ([account] = team slug)
|
||||
├── admin/ # Super admin section
|
||||
└── api/ # API routes
|
||||
```
|
||||
|
||||
Key Examples:
|
||||
|
||||
- Marketing layout: `app/(marketing)/layout.tsx`
|
||||
- Personal dashboard: `app/home/(user)/page.tsx`
|
||||
- Team workspace: `app/home/[account]/page.tsx`
|
||||
- Admin section: `app/admin/page.tsx`
|
||||
|
||||
### Component Organization
|
||||
|
||||
- **Route-specific**: Use `_components/` directories
|
||||
- **Route utilities**: Use `_lib/` for client, `_lib/server/` for server-side
|
||||
- **Global components**: Root-level directories
|
||||
|
||||
Example:
|
||||
|
||||
- Team components: `app/home/[account]/_components/`
|
||||
- Team server utils: `app/home/[account]/_lib/server/`
|
||||
- Marketing components: `app/(marketing)/_components/`
|
||||
|
||||
## Data Fetching Strategy
|
||||
|
||||
**Quick Decision Framework:**
|
||||
|
||||
- **Server Components**: Default choice for initial data loading
|
||||
- **Client Components**: For interactive features requiring hooks or real-time updates
|
||||
- **Admin Client**: Only for bypassing RLS (rare cases - requires manual auth/authorization)
|
||||
|
||||
### Server Components (Preferred) ✅
|
||||
|
||||
```typescript
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
async function NotesPage() {
|
||||
const client = getSupabaseServerClient();
|
||||
const { data, error } = await client.from('notes').select('*');
|
||||
|
||||
if (error) return <ErrorMessage error={error} />;
|
||||
return <NotesList notes={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
**Key Insight**: Server Components automatically inherit RLS protection - no additional authorization checks needed!
|
||||
|
||||
### Client Components (Interactive) 🖱️
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
function InteractiveNotes() {
|
||||
const supabase = useSupabase();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['notes'],
|
||||
queryFn: () => supabase.from('notes').select('*')
|
||||
});
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
return <NotesList notes={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Optimization - Parallel Data Fetching 🚀
|
||||
|
||||
**Sequential (Slow) Pattern ❌**
|
||||
|
||||
```typescript
|
||||
async function SlowDashboard() {
|
||||
const userData = await loadUserData();
|
||||
const notifications = await loadNotifications();
|
||||
const metrics = await loadMetrics();
|
||||
// Total time: sum of all requests
|
||||
}
|
||||
```
|
||||
|
||||
**Parallel (Optimized) Pattern ✅**
|
||||
|
||||
```typescript
|
||||
async function FastDashboard() {
|
||||
// Execute all requests simultaneously
|
||||
const [userData, notifications, metrics] = await Promise.all([
|
||||
loadUserData(),
|
||||
loadNotifications(),
|
||||
loadMetrics()
|
||||
]);
|
||||
// Total time: longest single request
|
||||
|
||||
return <Dashboard user={userData} notifications={notifications} metrics={metrics} />;
|
||||
}
|
||||
```
|
||||
|
||||
**Performance Impact**: Parallel fetching can reduce page load time by 60-80% for multi-data pages!
|
||||
|
||||
## Authorization Patterns - Critical Understanding 🔐
|
||||
|
||||
### RLS-Protected Data Fetching (Standard) ✅
|
||||
|
||||
```typescript
|
||||
async function getUserNotes(userId: string) {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
// RLS automatically ensures user can only access their own notes
|
||||
// NO additional authorization checks needed!
|
||||
const { data } = await client.from('notes').select('*').eq('user_id', userId); // RLS validates this automatically
|
||||
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
### Admin Client Usage (Dangerous - Rare Cases Only) ⚠️
|
||||
|
||||
```typescript
|
||||
async function adminGetUserNotes(userId: string) {
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
// CRITICAL: Manual authorization required - bypasses RLS!
|
||||
const currentUser = await getCurrentUser();
|
||||
if (!(await isSuperAdmin(currentUser))) {
|
||||
throw new Error('Unauthorized: Admin access required');
|
||||
}
|
||||
|
||||
// Additional validation: ensure current admin isn't targeting themselves
|
||||
if (currentUser.id === userId) {
|
||||
throw new Error('Cannot perform admin action on own account');
|
||||
}
|
||||
|
||||
// Now safe to proceed with admin privileges
|
||||
const { data } = await adminClient
|
||||
.from('notes')
|
||||
.select('*')
|
||||
.eq('user_id', userId);
|
||||
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
**Rule of thumb**: If using standard Supabase client, trust RLS. If using admin client, validate everything manually.
|
||||
|
||||
## Internationalization
|
||||
|
||||
Always use `Trans` component from `@kit/ui/trans`:
|
||||
|
||||
```tsx
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
<Trans
|
||||
i18nKey="user:welcomeMessage"
|
||||
values={{ name: user.name }}
|
||||
/>
|
||||
|
||||
// With HTML elements
|
||||
<Trans
|
||||
i18nKey="terms:agreement"
|
||||
components={{
|
||||
TermsLink: <a href="/terms" className="underline" />,
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Adding New Languages
|
||||
|
||||
1. Add language code to `lib/i18n/i18n.settings.ts`
|
||||
2. Create translation files in `public/locales/[new-language]/`
|
||||
3. Copy structure from English files
|
||||
|
||||
Translation files: `public/locales/<locale>/<namespace>.json`
|
||||
|
||||
## Workspace Contexts 🏢
|
||||
|
||||
### Personal Account Context (`app/home/(user)`)
|
||||
|
||||
```tsx
|
||||
import { useUserWorkspace } from '@kit/accounts/hooks/use-user-workspace';
|
||||
|
||||
function PersonalComponent() {
|
||||
const { user, account } = useUserWorkspace();
|
||||
// Personal account data
|
||||
}
|
||||
```
|
||||
|
||||
Context provider: `@packages/features/accounts/src/components/user-workspace-context-provider.tsx`
|
||||
|
||||
### Team Account Context (`app/home/[account]`)
|
||||
|
||||
```tsx
|
||||
import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';
|
||||
|
||||
function TeamComponent() {
|
||||
const { account, user, accounts } = useTeamAccountWorkspace();
|
||||
// Team account data with permissions
|
||||
}
|
||||
```
|
||||
|
||||
Context provider: `@packages/features/team-accounts/src/components/team-account-workspace-context-provider.tsx`
|
||||
|
||||
## Key Configuration Files
|
||||
|
||||
- **Feature flags**: `config/feature-flags.config.ts`
|
||||
- **i18n settings**: `lib/i18n/i18n.settings.ts`
|
||||
- **Supabase config**: `supabase/config.toml`
|
||||
- **Middleware**: `middleware.ts`
|
||||
|
||||
## Route Handlers (API Routes)
|
||||
|
||||
Use `enhanceRouteHandler` from `@packages/next/src/routes/index.ts`:
|
||||
|
||||
```typescript
|
||||
import { enhanceRouteHandler } from '@kit/next/routes';
|
||||
|
||||
export const POST = enhanceRouteHandler(
|
||||
async function ({ body, user, request }) {
|
||||
// body is validated, user available if auth: true
|
||||
return NextResponse.json({ success: true });
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: ZodSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Security Guidelines 🛡️
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- Authentication already enforced by middleware
|
||||
- Authorization handled by RLS at database level (in most cases)
|
||||
- Avoid defensive code - use RLS instead
|
||||
- When using the Supabase admin client, must enforce both authentication and authorization
|
||||
|
||||
### Passing data to the client
|
||||
|
||||
- **Never pass sensitive data** to Client Components
|
||||
- **Never expose server environment variables** to client (unless prefixed with NEXT_PUBLIC)
|
||||
- Always validate user input
|
||||
|
||||
### Super Admin Protection
|
||||
|
||||
For admin routes, use `AdminGuard` from `@packages/features/admin/src/components/admin-guard.tsx`:
|
||||
|
||||
```tsx
|
||||
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
||||
|
||||
export default AdminGuard(AdminPageComponent);
|
||||
```
|
||||
3
apps/web/app/admin/accounts/loading.tsx
Normal file
3
apps/web/app/admin/accounts/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@edge-csrf/nextjs": "2.5.3-cloudflare-rc1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@kit/accounts": "workspace:*",
|
||||
"@kit/admin": "workspace:*",
|
||||
"@kit/analytics": "workspace:*",
|
||||
@@ -53,15 +53,15 @@
|
||||
"@kit/ui": "workspace:*",
|
||||
"@makerkit/data-loader-supabase-core": "^0.0.10",
|
||||
"@makerkit/data-loader-supabase-nextjs": "^1.2.5",
|
||||
"@marsidev/react-turnstile": "^1.3.0",
|
||||
"@marsidev/react-turnstile": "^1.3.1",
|
||||
"@nosecone/next": "1.0.0-beta.11",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@supabase/supabase-js": "2.57.2",
|
||||
"@tanstack/react-query": "5.87.1",
|
||||
"@supabase/supabase-js": "2.57.4",
|
||||
"@tanstack/react-query": "5.89.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.542.0",
|
||||
"next": "15.5.2",
|
||||
"lucide-react": "^0.544.0",
|
||||
"next": "15.5.3",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"next-themes": "0.4.6",
|
||||
"react": "19.1.1",
|
||||
@@ -76,16 +76,16 @@
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@next/bundle-analyzer": "15.5.2",
|
||||
"@next/bundle-analyzer": "15.5.3",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/node": "^24.5.0",
|
||||
"@types/react": "19.1.13",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
||||
"cssnano": "^7.1.1",
|
||||
"pino-pretty": "13.0.0",
|
||||
"prettier": "^3.6.2",
|
||||
"supabase": "2.39.2",
|
||||
"supabase": "2.40.7",
|
||||
"tailwindcss": "4.1.13",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.9.2"
|
||||
|
||||
292
apps/web/supabase/AGENTS.md
Normal file
292
apps/web/supabase/AGENTS.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# Supabase Database Schema Management
|
||||
|
||||
This file contains guidance for working with database schemas, migrations, and Supabase development workflows.
|
||||
|
||||
## Schema Organization
|
||||
|
||||
Schemas are organized in numbered files in the `schemas/` directory. Numbers are used to sort dependencies.
|
||||
|
||||
## Schema Development Workflow
|
||||
|
||||
### 1. Creating New Schema Files
|
||||
|
||||
```bash
|
||||
# Create new schema file
|
||||
touch schemas/15-my-new-feature.sql
|
||||
|
||||
# Apply changes and create migration
|
||||
pnpm --filter web run supabase:db:diff -f my-new-feature
|
||||
|
||||
# Restart Supabase with fresh schema
|
||||
pnpm supabase:web:reset
|
||||
|
||||
# Generate TypeScript types
|
||||
pnpm supabase:web:typegen
|
||||
```
|
||||
|
||||
### 2. Modifying Existing Schemas
|
||||
|
||||
```bash
|
||||
# Edit schema file (e.g., schemas/03-accounts.sql)
|
||||
# Make your changes...
|
||||
|
||||
# Create migration for changes
|
||||
pnpm --filter web run supabase:db:diff -f update-accounts
|
||||
|
||||
# Apply and test
|
||||
pnpm supabase:web:reset
|
||||
pnpm supabase:web:typegen
|
||||
```
|
||||
|
||||
## Security First Patterns
|
||||
|
||||
## Add permissions (if any)
|
||||
|
||||
```sql
|
||||
ALTER TYPE public.app_permissions ADD VALUE 'notes.manage';
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
### Table Creation with RLS
|
||||
|
||||
```sql
|
||||
-- Create table
|
||||
create table if not exists public.notes (
|
||||
id uuid unique not null default extensions.uuid_generate_v4(),
|
||||
account_id uuid references public.accounts(id) on delete cascade not null,
|
||||
-- ...
|
||||
primary key (id)
|
||||
);
|
||||
|
||||
-- CRITICAL: Always enable RLS
|
||||
alter table "public"."notes" enable row level security;
|
||||
|
||||
-- Revoke default permissions
|
||||
revoke all on public.notes from authenticated, service_role;
|
||||
|
||||
-- Grant specific permissions
|
||||
grant select, insert, update, delete on table public.notes to authenticated;
|
||||
|
||||
-- Add RLS policies
|
||||
create policy "notes_read" on public.notes for select
|
||||
to authenticated using (
|
||||
account_id = (select auth.uid()) or
|
||||
public.has_role_on_account(account_id)
|
||||
);
|
||||
|
||||
create policy "notes_write" on public.notes for insert
|
||||
to authenticated with check (
|
||||
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
|
||||
);
|
||||
|
||||
create policy "notes_update" on public.notes for update
|
||||
to authenticated using (
|
||||
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
|
||||
)
|
||||
with check (
|
||||
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
|
||||
);
|
||||
|
||||
create policy "notes_delete" on public.notes for delete
|
||||
to authenticated using (
|
||||
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
|
||||
);
|
||||
```
|
||||
|
||||
### Storage Bucket Policies
|
||||
|
||||
```sql
|
||||
-- Create storage bucket
|
||||
insert into storage.buckets (id, name, public)
|
||||
values ('documents', 'documents', false);
|
||||
|
||||
-- RLS policy for storage
|
||||
create policy documents_policy on storage.objects for all using (
|
||||
bucket_id = 'documents'
|
||||
and (
|
||||
-- File belongs to user's account
|
||||
kit.get_storage_filename_as_uuid(name) = auth.uid()
|
||||
or
|
||||
-- User has access to the account
|
||||
public.has_role_on_account(kit.get_storage_filename_as_uuid(name))
|
||||
)
|
||||
)
|
||||
with check (
|
||||
bucket_id = 'documents'
|
||||
and (
|
||||
kit.get_storage_filename_as_uuid(name) = auth.uid()
|
||||
or
|
||||
public.has_permission(
|
||||
auth.uid(),
|
||||
kit.get_storage_filename_as_uuid(name),
|
||||
'files.upload'::app_permissions
|
||||
)
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
## Function Creation Patterns
|
||||
|
||||
### Safe Security Definer Functions
|
||||
|
||||
```sql
|
||||
-- NEVER create security definer functions without explicit access controls
|
||||
create or replace function public.create_team_account(account_name text)
|
||||
returns public.accounts
|
||||
language plpgsql
|
||||
security definer -- Elevated privileges
|
||||
set search_path = '' -- Prevent SQL injection
|
||||
as $$
|
||||
declare
|
||||
new_account public.accounts;
|
||||
begin
|
||||
-- CRITICAL: Validate permissions first
|
||||
if not public.is_set('enable_team_accounts') then
|
||||
raise exception 'Team accounts are not enabled';
|
||||
end if;
|
||||
|
||||
-- Additional validation can go here
|
||||
if length(account_name) < 3 then
|
||||
raise exception 'Account name must be at least 3 characters';
|
||||
end if;
|
||||
|
||||
-- Now safe to proceed with elevated privileges
|
||||
insert into public.accounts (name, is_personal_account)
|
||||
values (account_name, false)
|
||||
returning * into new_account;
|
||||
|
||||
return new_account;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- Grant to authenticated users only
|
||||
grant execute on function public.create_team_account(text) to authenticated;
|
||||
```
|
||||
|
||||
### Security Invoker Functions (Safer)
|
||||
|
||||
```sql
|
||||
-- Preferred: Functions that inherit RLS policies
|
||||
create or replace function public.get_account_notes(target_account_id uuid)
|
||||
returns setof public.notes
|
||||
language plpgsql
|
||||
security invoker -- Inherits caller's permissions (RLS applies)
|
||||
set search_path = ''
|
||||
as $$
|
||||
begin
|
||||
-- RLS policies will automatically restrict results
|
||||
return query
|
||||
select * from public.notes
|
||||
where account_id = target_account_id
|
||||
order by created_at desc;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.get_account_notes(uuid) to authenticated;
|
||||
```
|
||||
|
||||
### Safe Column Additions
|
||||
|
||||
```sql
|
||||
-- Safe: Add nullable columns
|
||||
alter table public.accounts
|
||||
add column if not exists description text;
|
||||
|
||||
-- Safe: Add columns with defaults
|
||||
alter table public.accounts
|
||||
add column if not exists is_verified boolean default false not null;
|
||||
|
||||
-- Unsafe: Adding non-null columns without defaults
|
||||
-- alter table public.accounts add column required_field text not null; -- DON'T DO THIS
|
||||
```
|
||||
|
||||
### Index Management
|
||||
|
||||
```sql
|
||||
-- Create indexes concurrently for large tables
|
||||
create index concurrently if not exists ix_accounts_created_at
|
||||
on public.accounts (created_at desc);
|
||||
|
||||
-- Drop unused indexes
|
||||
drop index if exists ix_old_unused_index;
|
||||
```
|
||||
|
||||
## Testing Database Changes
|
||||
|
||||
### Local Testing
|
||||
|
||||
```bash
|
||||
# Test with fresh database
|
||||
pnpm supabase:web:reset
|
||||
|
||||
# Test your changes
|
||||
pnpm run supabase:web:test
|
||||
```
|
||||
|
||||
## Type Generation
|
||||
|
||||
### After Schema Changes
|
||||
|
||||
```bash
|
||||
# Generate types after any schema changes
|
||||
pnpm supabase:web:typegen
|
||||
# Types are generated to src/lib/supabase/database.types.ts
|
||||
|
||||
# Reset DB
|
||||
pnpm supabase:web:reset
|
||||
```
|
||||
|
||||
### Using Generated Types
|
||||
|
||||
```typescript
|
||||
import { Enums, Tables } from '@kit/supabase/database';
|
||||
|
||||
// Table types
|
||||
type Account = Tables<'accounts'>;
|
||||
type Note = Tables<'notes'>;
|
||||
|
||||
// Enum types
|
||||
type AppPermission = Enums<'app_permissions'>;
|
||||
|
||||
// Insert types
|
||||
type AccountInsert = Tables<'accounts'>['Insert'];
|
||||
type AccountUpdate = Tables<'accounts'>['Update'];
|
||||
|
||||
// Use in functions
|
||||
async function createNote(data: Tables<'notes'>['Insert']) {
|
||||
const { data: note, error } = await supabase
|
||||
.from('notes')
|
||||
.insert(data)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
return note;
|
||||
}
|
||||
```
|
||||
|
||||
## Common Schema Patterns
|
||||
|
||||
### Audit Trail
|
||||
|
||||
Add triggers if the properties exist and are appropriate:
|
||||
|
||||
- `public.trigger_set_timestamps()` - for tables with `created_at` and `updated_at`
|
||||
columns
|
||||
- `public.trigger_set_user_tracking()` - for tables with `created_by` and `updated_by`
|
||||
columns
|
||||
|
||||
### Useful Commands
|
||||
|
||||
```bash
|
||||
# View migration status
|
||||
pnpm --filter web supabase migration list
|
||||
|
||||
# Reset database completely
|
||||
pnpm supabase:web:reset
|
||||
|
||||
# Generate migration from schema diff
|
||||
pnpm --filter web run supabase:db:diff -f migration-name
|
||||
|
||||
# Apply specific migration
|
||||
pnpm --filter web supabase migration up --include-schemas public
|
||||
```
|
||||
292
apps/web/supabase/CLAUDE.md
Normal file
292
apps/web/supabase/CLAUDE.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# Supabase Database Schema Management
|
||||
|
||||
This file contains guidance for working with database schemas, migrations, and Supabase development workflows.
|
||||
|
||||
## Schema Organization
|
||||
|
||||
Schemas are organized in numbered files in the `schemas/` directory. Numbers are used to sort dependencies.
|
||||
|
||||
## Schema Development Workflow
|
||||
|
||||
### 1. Creating New Schema Files
|
||||
|
||||
```bash
|
||||
# Create new schema file
|
||||
touch schemas/15-my-new-feature.sql
|
||||
|
||||
# Apply changes and create migration
|
||||
pnpm --filter web run supabase:db:diff -f my-new-feature
|
||||
|
||||
# Restart Supabase with fresh schema
|
||||
pnpm supabase:web:reset
|
||||
|
||||
# Generate TypeScript types
|
||||
pnpm supabase:web:typegen
|
||||
```
|
||||
|
||||
### 2. Modifying Existing Schemas
|
||||
|
||||
```bash
|
||||
# Edit schema file (e.g., schemas/03-accounts.sql)
|
||||
# Make your changes...
|
||||
|
||||
# Create migration for changes
|
||||
pnpm --filter web run supabase:db:diff -f update-accounts
|
||||
|
||||
# Apply and test
|
||||
pnpm supabase:web:reset
|
||||
pnpm supabase:web:typegen
|
||||
```
|
||||
|
||||
## Security First Patterns
|
||||
|
||||
## Add permissions (if any)
|
||||
|
||||
```sql
|
||||
ALTER TYPE public.app_permissions ADD VALUE 'notes.manage';
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
### Table Creation with RLS
|
||||
|
||||
```sql
|
||||
-- Create table
|
||||
create table if not exists public.notes (
|
||||
id uuid unique not null default extensions.uuid_generate_v4(),
|
||||
account_id uuid references public.accounts(id) on delete cascade not null,
|
||||
-- ...
|
||||
primary key (id)
|
||||
);
|
||||
|
||||
-- CRITICAL: Always enable RLS
|
||||
alter table "public"."notes" enable row level security;
|
||||
|
||||
-- Revoke default permissions
|
||||
revoke all on public.notes from authenticated, service_role;
|
||||
|
||||
-- Grant specific permissions
|
||||
grant select, insert, update, delete on table public.notes to authenticated;
|
||||
|
||||
-- Add RLS policies
|
||||
create policy "notes_read" on public.notes for select
|
||||
to authenticated using (
|
||||
account_id = (select auth.uid()) or
|
||||
public.has_role_on_account(account_id)
|
||||
);
|
||||
|
||||
create policy "notes_write" on public.notes for insert
|
||||
to authenticated with check (
|
||||
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
|
||||
);
|
||||
|
||||
create policy "notes_update" on public.notes for update
|
||||
to authenticated using (
|
||||
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
|
||||
)
|
||||
with check (
|
||||
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
|
||||
);
|
||||
|
||||
create policy "notes_delete" on public.notes for delete
|
||||
to authenticated using (
|
||||
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
|
||||
);
|
||||
```
|
||||
|
||||
### Storage Bucket Policies
|
||||
|
||||
```sql
|
||||
-- Create storage bucket
|
||||
insert into storage.buckets (id, name, public)
|
||||
values ('documents', 'documents', false);
|
||||
|
||||
-- RLS policy for storage
|
||||
create policy documents_policy on storage.objects for all using (
|
||||
bucket_id = 'documents'
|
||||
and (
|
||||
-- File belongs to user's account
|
||||
kit.get_storage_filename_as_uuid(name) = auth.uid()
|
||||
or
|
||||
-- User has access to the account
|
||||
public.has_role_on_account(kit.get_storage_filename_as_uuid(name))
|
||||
)
|
||||
)
|
||||
with check (
|
||||
bucket_id = 'documents'
|
||||
and (
|
||||
kit.get_storage_filename_as_uuid(name) = auth.uid()
|
||||
or
|
||||
public.has_permission(
|
||||
auth.uid(),
|
||||
kit.get_storage_filename_as_uuid(name),
|
||||
'files.upload'::app_permissions
|
||||
)
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
## Function Creation Patterns
|
||||
|
||||
### Safe Security Definer Functions
|
||||
|
||||
```sql
|
||||
-- NEVER create security definer functions without explicit access controls
|
||||
create or replace function public.create_team_account(account_name text)
|
||||
returns public.accounts
|
||||
language plpgsql
|
||||
security definer -- Elevated privileges
|
||||
set search_path = '' -- Prevent SQL injection
|
||||
as $$
|
||||
declare
|
||||
new_account public.accounts;
|
||||
begin
|
||||
-- CRITICAL: Validate permissions first
|
||||
if not public.is_set('enable_team_accounts') then
|
||||
raise exception 'Team accounts are not enabled';
|
||||
end if;
|
||||
|
||||
-- Additional validation can go here
|
||||
if length(account_name) < 3 then
|
||||
raise exception 'Account name must be at least 3 characters';
|
||||
end if;
|
||||
|
||||
-- Now safe to proceed with elevated privileges
|
||||
insert into public.accounts (name, is_personal_account)
|
||||
values (account_name, false)
|
||||
returning * into new_account;
|
||||
|
||||
return new_account;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- Grant to authenticated users only
|
||||
grant execute on function public.create_team_account(text) to authenticated;
|
||||
```
|
||||
|
||||
### Security Invoker Functions (Safer)
|
||||
|
||||
```sql
|
||||
-- Preferred: Functions that inherit RLS policies
|
||||
create or replace function public.get_account_notes(target_account_id uuid)
|
||||
returns setof public.notes
|
||||
language plpgsql
|
||||
security invoker -- Inherits caller's permissions (RLS applies)
|
||||
set search_path = ''
|
||||
as $$
|
||||
begin
|
||||
-- RLS policies will automatically restrict results
|
||||
return query
|
||||
select * from public.notes
|
||||
where account_id = target_account_id
|
||||
order by created_at desc;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.get_account_notes(uuid) to authenticated;
|
||||
```
|
||||
|
||||
### Safe Column Additions
|
||||
|
||||
```sql
|
||||
-- Safe: Add nullable columns
|
||||
alter table public.accounts
|
||||
add column if not exists description text;
|
||||
|
||||
-- Safe: Add columns with defaults
|
||||
alter table public.accounts
|
||||
add column if not exists is_verified boolean default false not null;
|
||||
|
||||
-- Unsafe: Adding non-null columns without defaults
|
||||
-- alter table public.accounts add column required_field text not null; -- DON'T DO THIS
|
||||
```
|
||||
|
||||
### Index Management
|
||||
|
||||
```sql
|
||||
-- Create indexes concurrently for large tables
|
||||
create index concurrently if not exists ix_accounts_created_at
|
||||
on public.accounts (created_at desc);
|
||||
|
||||
-- Drop unused indexes
|
||||
drop index if exists ix_old_unused_index;
|
||||
```
|
||||
|
||||
## Testing Database Changes
|
||||
|
||||
### Local Testing
|
||||
|
||||
```bash
|
||||
# Test with fresh database
|
||||
pnpm supabase:web:reset
|
||||
|
||||
# Test your changes
|
||||
pnpm run supabase:web:test
|
||||
```
|
||||
|
||||
## Type Generation
|
||||
|
||||
### After Schema Changes
|
||||
|
||||
```bash
|
||||
# Generate types after any schema changes
|
||||
pnpm supabase:web:typegen
|
||||
# Types are generated to src/lib/supabase/database.types.ts
|
||||
|
||||
# Reset DB
|
||||
pnpm supabase:web:reset
|
||||
```
|
||||
|
||||
### Using Generated Types
|
||||
|
||||
```typescript
|
||||
import { Enums, Tables } from '@kit/supabase/database';
|
||||
|
||||
// Table types
|
||||
type Account = Tables<'accounts'>;
|
||||
type Note = Tables<'notes'>;
|
||||
|
||||
// Enum types
|
||||
type AppPermission = Enums<'app_permissions'>;
|
||||
|
||||
// Insert types
|
||||
type AccountInsert = Tables<'accounts'>['Insert'];
|
||||
type AccountUpdate = Tables<'accounts'>['Update'];
|
||||
|
||||
// Use in functions
|
||||
async function createNote(data: Tables<'notes'>['Insert']) {
|
||||
const { data: note, error } = await supabase
|
||||
.from('notes')
|
||||
.insert(data)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
return note;
|
||||
}
|
||||
```
|
||||
|
||||
## Common Schema Patterns
|
||||
|
||||
### Audit Trail
|
||||
|
||||
Add triggers if the properties exist and are appropriate:
|
||||
|
||||
- `public.trigger_set_timestamps()` - for tables with `created_at` and `updated_at`
|
||||
columns
|
||||
- `public.trigger_set_user_tracking()` - for tables with `created_by` and `updated_by`
|
||||
columns
|
||||
|
||||
### Useful Commands
|
||||
|
||||
```bash
|
||||
# View migration status
|
||||
pnpm --filter web supabase migration list
|
||||
|
||||
# Reset database completely
|
||||
pnpm supabase:web:reset
|
||||
|
||||
# Generate migration from schema diff
|
||||
pnpm --filter web run supabase:db:diff -f migration-name
|
||||
|
||||
# Apply specific migration
|
||||
pnpm --filter web supabase migration up --include-schemas public
|
||||
```
|
||||
42
apps/web/supabase/migrations/20250917024249_triggers.sql
Normal file
42
apps/web/supabase/migrations/20250917024249_triggers.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- Triggers for accounts table
|
||||
create trigger accounts_set_timestamps
|
||||
before insert or update on public.accounts
|
||||
for each row execute function public.trigger_set_timestamps();
|
||||
|
||||
create trigger accounts_set_user_tracking
|
||||
before insert or update on public.accounts
|
||||
for each row execute function public.trigger_set_user_tracking();
|
||||
|
||||
-- Triggers for accounts_memberships table
|
||||
create trigger accounts_memberships_set_timestamps
|
||||
before insert or update on public.accounts_memberships
|
||||
for each row execute function public.trigger_set_timestamps();
|
||||
|
||||
create trigger accounts_memberships_set_user_tracking
|
||||
before insert or update on public.accounts_memberships
|
||||
for each row execute function public.trigger_set_user_tracking();
|
||||
|
||||
-- Triggers for invitations table
|
||||
create trigger invitations_set_timestamps
|
||||
before insert or update on public.invitations
|
||||
for each row execute function public.trigger_set_timestamps();
|
||||
|
||||
-- Triggers for subscriptions table
|
||||
create trigger subscriptions_set_timestamps
|
||||
before insert or update on public.subscriptions
|
||||
for each row execute function public.trigger_set_timestamps();
|
||||
|
||||
-- Triggers for subscription_items table
|
||||
create trigger subscription_items_set_timestamps
|
||||
before insert or update on public.subscription_items
|
||||
for each row execute function public.trigger_set_timestamps();
|
||||
|
||||
-- Triggers for orders table
|
||||
create trigger orders_set_timestamps
|
||||
before insert or update on public.orders
|
||||
for each row execute function public.trigger_set_timestamps();
|
||||
|
||||
-- Triggers for order_items table
|
||||
create trigger order_items_set_timestamps
|
||||
before insert or update on public.order_items
|
||||
for each row execute function public.trigger_set_timestamps();
|
||||
@@ -77,6 +77,15 @@ create unique index unique_personal_account on public.accounts (primary_owner_us
|
||||
where
|
||||
is_personal_account = true;
|
||||
|
||||
-- Triggers for accounts table
|
||||
create trigger accounts_set_timestamps
|
||||
before insert or update on public.accounts
|
||||
for each row execute function public.trigger_set_timestamps();
|
||||
|
||||
create trigger accounts_set_user_tracking
|
||||
before insert or update on public.accounts
|
||||
for each row execute function public.trigger_set_user_tracking();
|
||||
|
||||
-- RLS on the accounts table
|
||||
-- UPDATE(accounts):
|
||||
-- Team owners can update their accounts
|
||||
|
||||
@@ -46,6 +46,15 @@ create index ix_accounts_memberships_user_id on public.accounts_memberships (use
|
||||
|
||||
create index ix_accounts_memberships_account_role on public.accounts_memberships (account_role);
|
||||
|
||||
-- Triggers for accounts_memberships table
|
||||
create trigger accounts_memberships_set_timestamps
|
||||
before insert or update on public.accounts_memberships
|
||||
for each row execute function public.trigger_set_timestamps();
|
||||
|
||||
create trigger accounts_memberships_set_user_tracking
|
||||
before insert or update on public.accounts_memberships
|
||||
for each row execute function public.trigger_set_user_tracking();
|
||||
|
||||
-- Enable RLS on the accounts_memberships table
|
||||
alter table public.accounts_memberships enable row level security;
|
||||
|
||||
|
||||
@@ -36,6 +36,11 @@ comment on column public.invitations.email is 'The email of the user being invit
|
||||
-- Indexes on the invitations table
|
||||
create index ix_invitations_account_id on public.invitations (account_id);
|
||||
|
||||
-- Triggers for invitations table
|
||||
create trigger invitations_set_timestamps
|
||||
before insert or update on public.invitations
|
||||
for each row execute function public.trigger_set_timestamps();
|
||||
|
||||
-- Revoke all on invitations table from authenticated and service_role
|
||||
revoke all on public.invitations
|
||||
from
|
||||
|
||||
@@ -69,6 +69,11 @@ select
|
||||
-- Indexes on the subscriptions table
|
||||
create index ix_subscriptions_account_id on public.subscriptions (account_id);
|
||||
|
||||
-- Triggers for subscriptions table
|
||||
create trigger subscriptions_set_timestamps
|
||||
before insert or update on public.subscriptions
|
||||
for each row execute function public.trigger_set_timestamps();
|
||||
|
||||
-- Enable RLS on subscriptions table
|
||||
alter table public.subscriptions enable row level security;
|
||||
|
||||
@@ -314,6 +319,11 @@ delete on table public.subscription_items to service_role;
|
||||
-- Indexes on the subscription_items table
|
||||
create index ix_subscription_items_subscription_id on public.subscription_items (subscription_id);
|
||||
|
||||
-- Triggers for subscription_items table
|
||||
create trigger subscription_items_set_timestamps
|
||||
before insert or update on public.subscription_items
|
||||
for each row execute function public.trigger_set_timestamps();
|
||||
|
||||
-- RLS
|
||||
alter table public.subscription_items enable row level security;
|
||||
|
||||
|
||||
@@ -55,6 +55,11 @@ delete on table public.orders to service_role;
|
||||
-- Indexes on the orders table
|
||||
create index ix_orders_account_id on public.orders (account_id);
|
||||
|
||||
-- Triggers for orders table
|
||||
create trigger orders_set_timestamps
|
||||
before insert or update on public.orders
|
||||
for each row execute function public.trigger_set_timestamps();
|
||||
|
||||
-- RLS
|
||||
alter table public.orders enable row level security;
|
||||
|
||||
@@ -130,6 +135,11 @@ grant insert, update, delete on table public.order_items to service_role;
|
||||
-- Indexes on the order_items table
|
||||
create index ix_order_items_order_id on public.order_items (order_id);
|
||||
|
||||
-- Triggers for order_items table
|
||||
create trigger order_items_set_timestamps
|
||||
before insert or update on public.order_items
|
||||
for each row execute function public.trigger_set_timestamps();
|
||||
|
||||
-- RLS
|
||||
alter table public.order_items enable row level security;
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
BEGIN;
|
||||
create extension "basejump-supabase_test_helpers" version '0.0.6';
|
||||
|
||||
select plan(12);
|
||||
|
||||
--- Test the trigger_set_timestamps function on all tables
|
||||
--- This test verifies that created_at and updated_at are properly set on insert
|
||||
|
||||
--- Create test users
|
||||
select tests.create_supabase_user('trigger_test_user1', 'test1@example.com');
|
||||
|
||||
-- Authenticate as test user
|
||||
select makerkit.authenticate_as('trigger_test_user1');
|
||||
|
||||
------------
|
||||
--- Test accounts table timestamp triggers - INSERT
|
||||
------------
|
||||
|
||||
INSERT INTO public.accounts (name, is_personal_account)
|
||||
VALUES ('Test Account', false);
|
||||
|
||||
SELECT ok(
|
||||
(SELECT created_at IS NOT NULL FROM public.accounts WHERE name = 'Test Account'),
|
||||
'accounts: created_at should be set automatically on insert'
|
||||
);
|
||||
|
||||
SELECT ok(
|
||||
(SELECT updated_at IS NOT NULL FROM public.accounts WHERE name = 'Test Account'),
|
||||
'accounts: updated_at should be set automatically on insert'
|
||||
);
|
||||
|
||||
SELECT ok(
|
||||
(SELECT created_at = updated_at FROM public.accounts WHERE name = 'Test Account'),
|
||||
'accounts: created_at should equal updated_at on insert'
|
||||
);
|
||||
|
||||
------------
|
||||
--- Test invitations table timestamp triggers - INSERT
|
||||
------------
|
||||
|
||||
-- Create a team account for invitation testing
|
||||
INSERT INTO public.accounts (name, is_personal_account)
|
||||
VALUES ('Invitation Test Team', false);
|
||||
|
||||
-- Test invitation insert
|
||||
INSERT INTO public.invitations (email, account_id, invited_by, role, invite_token, expires_at)
|
||||
VALUES (
|
||||
'invitee@example.com',
|
||||
(SELECT id FROM public.accounts WHERE name = 'Invitation Test Team'),
|
||||
tests.get_supabase_uid('trigger_test_user1'),
|
||||
'member',
|
||||
'test-token-123',
|
||||
now() + interval '7 days'
|
||||
);
|
||||
|
||||
SELECT ok(
|
||||
(SELECT created_at IS NOT NULL FROM public.invitations WHERE email = 'invitee@example.com'),
|
||||
'invitations: created_at should be set automatically on insert'
|
||||
);
|
||||
|
||||
SELECT ok(
|
||||
(SELECT updated_at IS NOT NULL FROM public.invitations WHERE email = 'invitee@example.com'),
|
||||
'invitations: updated_at should be set automatically on insert'
|
||||
);
|
||||
|
||||
SELECT ok(
|
||||
(SELECT created_at = updated_at FROM public.invitations WHERE email = 'invitee@example.com'),
|
||||
'invitations: created_at should equal updated_at on insert'
|
||||
);
|
||||
|
||||
------------
|
||||
--- Test subscriptions table timestamp triggers - INSERT (service_role required)
|
||||
------------
|
||||
|
||||
set role service_role;
|
||||
|
||||
-- Create billing customer first
|
||||
INSERT INTO public.billing_customers (account_id, provider, customer_id, email)
|
||||
VALUES (
|
||||
(SELECT id FROM public.accounts WHERE name = 'Invitation Test Team'),
|
||||
'stripe',
|
||||
'cus_test123',
|
||||
'billing@example.com'
|
||||
);
|
||||
|
||||
-- Test subscription insert
|
||||
INSERT INTO public.subscriptions (
|
||||
id, account_id, billing_customer_id, status, active, billing_provider,
|
||||
cancel_at_period_end, currency, period_starts_at, period_ends_at
|
||||
)
|
||||
VALUES (
|
||||
'sub_test123',
|
||||
(SELECT id FROM public.accounts WHERE name = 'Invitation Test Team'),
|
||||
(SELECT id FROM public.billing_customers WHERE customer_id = 'cus_test123'),
|
||||
'active',
|
||||
true,
|
||||
'stripe',
|
||||
false,
|
||||
'USD',
|
||||
now(),
|
||||
now() + interval '1 month'
|
||||
);
|
||||
|
||||
SELECT ok(
|
||||
(SELECT created_at IS NOT NULL FROM public.subscriptions WHERE id = 'sub_test123'),
|
||||
'subscriptions: created_at should be set automatically on insert'
|
||||
);
|
||||
|
||||
SELECT ok(
|
||||
(SELECT updated_at IS NOT NULL FROM public.subscriptions WHERE id = 'sub_test123'),
|
||||
'subscriptions: updated_at should be set automatically on insert'
|
||||
);
|
||||
|
||||
SELECT ok(
|
||||
(SELECT created_at = updated_at FROM public.subscriptions WHERE id = 'sub_test123'),
|
||||
'subscriptions: created_at should equal updated_at on insert'
|
||||
);
|
||||
|
||||
------------
|
||||
--- Test subscription_items table timestamp triggers - INSERT
|
||||
------------
|
||||
|
||||
-- Test subscription_item insert
|
||||
INSERT INTO public.subscription_items (
|
||||
id, subscription_id, product_id, variant_id, type, quantity, interval, interval_count
|
||||
)
|
||||
VALUES (
|
||||
'si_test123',
|
||||
'sub_test123',
|
||||
'prod_test123',
|
||||
'var_test123',
|
||||
'flat',
|
||||
1,
|
||||
'month',
|
||||
1
|
||||
);
|
||||
|
||||
SELECT ok(
|
||||
(SELECT created_at IS NOT NULL FROM public.subscription_items WHERE id = 'si_test123'),
|
||||
'subscription_items: created_at should be set automatically on insert'
|
||||
);
|
||||
|
||||
SELECT ok(
|
||||
(SELECT updated_at IS NOT NULL FROM public.subscription_items WHERE id = 'si_test123'),
|
||||
'subscription_items: updated_at should be set automatically on insert'
|
||||
);
|
||||
|
||||
SELECT ok(
|
||||
(SELECT created_at = updated_at FROM public.subscription_items WHERE id = 'si_test123'),
|
||||
'subscription_items: created_at should equal updated_at on insert'
|
||||
);
|
||||
|
||||
SELECT * FROM finish();
|
||||
|
||||
ROLLBACK;
|
||||
@@ -0,0 +1,43 @@
|
||||
BEGIN;
|
||||
create extension "basejump-supabase_test_helpers" version '0.0.6';
|
||||
|
||||
select plan(3);
|
||||
|
||||
--- Test the trigger_set_user_tracking function on accounts table
|
||||
--- This test verifies that created_by and updated_by are properly set on insert
|
||||
|
||||
--- Create test users
|
||||
select tests.create_supabase_user('user_tracking_test1', 'tracking1@example.com');
|
||||
|
||||
------------
|
||||
--- Test accounts table user tracking triggers - INSERT
|
||||
------------
|
||||
|
||||
-- Authenticate as first user for insert
|
||||
select makerkit.authenticate_as('user_tracking_test1');
|
||||
|
||||
-- Test INSERT: created_by and updated_by should be set to current user
|
||||
INSERT INTO public.accounts (name, is_personal_account)
|
||||
VALUES ('User Tracking Test Account', false);
|
||||
|
||||
SELECT ok(
|
||||
(SELECT created_by = tests.get_supabase_uid('user_tracking_test1')
|
||||
FROM public.accounts WHERE name = 'User Tracking Test Account'),
|
||||
'accounts: created_by should be set to current user on insert'
|
||||
);
|
||||
|
||||
SELECT ok(
|
||||
(SELECT updated_by = tests.get_supabase_uid('user_tracking_test1')
|
||||
FROM public.accounts WHERE name = 'User Tracking Test Account'),
|
||||
'accounts: updated_by should be set to current user on insert'
|
||||
);
|
||||
|
||||
SELECT ok(
|
||||
(SELECT created_by = updated_by
|
||||
FROM public.accounts WHERE name = 'User Tracking Test Account'),
|
||||
'accounts: created_by should equal updated_by on insert'
|
||||
);
|
||||
|
||||
SELECT * FROM finish();
|
||||
|
||||
ROLLBACK;
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "next-supabase-saas-kit-turbo",
|
||||
"version": "2.13.1",
|
||||
"version": "2.14.0",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"engines": {
|
||||
|
||||
105
packages/analytics/AGENTS.md
Normal file
105
packages/analytics/AGENTS.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# @kit/analytics Package
|
||||
|
||||
Analytics package providing a unified interface for tracking events, page views, and user identification across multiple analytics providers.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **AnalyticsManager**: Central manager orchestrating multiple analytics providers
|
||||
- **AnalyticsService**: Interface defining analytics operations (track, identify, pageView)
|
||||
- **Provider System**: Pluggable providers (currently includes NullAnalyticsService)
|
||||
- **Client/Server Split**: Separate entry points for client and server-side usage
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Import
|
||||
|
||||
```typescript
|
||||
// Client-side
|
||||
import { analytics } from '@kit/analytics';
|
||||
|
||||
// Server-side
|
||||
import { analytics } from '@kit/analytics/server';
|
||||
```
|
||||
|
||||
### Core Methods
|
||||
|
||||
```typescript
|
||||
// Track events
|
||||
await analytics.trackEvent('button_clicked', {
|
||||
button_id: 'signup',
|
||||
page: 'homepage'
|
||||
});
|
||||
|
||||
// Track page views
|
||||
await analytics.trackPageView('/dashboard');
|
||||
|
||||
// Identify users
|
||||
await analytics.identify('user123', {
|
||||
email: 'user@example.com',
|
||||
plan: 'premium'
|
||||
});
|
||||
```
|
||||
|
||||
Page views and user identification are handled by the plugin by default.
|
||||
|
||||
## Creating Custom Providers
|
||||
|
||||
Implement the `AnalyticsService` interface:
|
||||
|
||||
```typescript
|
||||
import { AnalyticsService } from '@kit/analytics';
|
||||
|
||||
class CustomAnalyticsService implements AnalyticsService {
|
||||
async initialize(): Promise<void> {
|
||||
// Initialize your analytics service
|
||||
}
|
||||
|
||||
async trackEvent(name: string, properties?: Record<string, string | string[]>): Promise<void> {
|
||||
// Track event implementation
|
||||
}
|
||||
|
||||
async trackPageView(path: string): Promise<void> {
|
||||
// Track page view implementation
|
||||
}
|
||||
|
||||
async identify(userId: string, traits?: Record<string, string>): Promise<void> {
|
||||
// Identify user implementation
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Default Behavior
|
||||
|
||||
- Uses `NullAnalyticsService` when no providers are active
|
||||
- All methods return Promises that resolve to arrays of provider results
|
||||
- Console debug logging when no active services or using null service
|
||||
- Graceful error handling with console warnings for missing providers
|
||||
|
||||
## Server-Side Analytics
|
||||
|
||||
When using PostHog, you can track events server-side for better reliability and privacy:
|
||||
|
||||
```typescript
|
||||
import { analytics } from '@kit/analytics/server';
|
||||
|
||||
// Server-side event tracking (e.g., in API routes)
|
||||
export async function POST(request: Request) {
|
||||
// ... handle request
|
||||
|
||||
// Track server-side events
|
||||
await analytics.trackEvent('api_call', {
|
||||
endpoint: '/api/users',
|
||||
method: 'POST',
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
|
||||
// Track user registration server-side
|
||||
await analytics.identify(user.id, {
|
||||
email: user.email,
|
||||
created_at: user.created_at,
|
||||
plan: user.plan,
|
||||
});
|
||||
```
|
||||
105
packages/analytics/CLAUDE.md
Normal file
105
packages/analytics/CLAUDE.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# @kit/analytics Package
|
||||
|
||||
Analytics package providing a unified interface for tracking events, page views, and user identification across multiple analytics providers.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **AnalyticsManager**: Central manager orchestrating multiple analytics providers
|
||||
- **AnalyticsService**: Interface defining analytics operations (track, identify, pageView)
|
||||
- **Provider System**: Pluggable providers (currently includes NullAnalyticsService)
|
||||
- **Client/Server Split**: Separate entry points for client and server-side usage
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Import
|
||||
|
||||
```typescript
|
||||
// Client-side
|
||||
import { analytics } from '@kit/analytics';
|
||||
|
||||
// Server-side
|
||||
import { analytics } from '@kit/analytics/server';
|
||||
```
|
||||
|
||||
### Core Methods
|
||||
|
||||
```typescript
|
||||
// Track events
|
||||
await analytics.trackEvent('button_clicked', {
|
||||
button_id: 'signup',
|
||||
page: 'homepage'
|
||||
});
|
||||
|
||||
// Track page views
|
||||
await analytics.trackPageView('/dashboard');
|
||||
|
||||
// Identify users
|
||||
await analytics.identify('user123', {
|
||||
email: 'user@example.com',
|
||||
plan: 'premium'
|
||||
});
|
||||
```
|
||||
|
||||
Page views and user identification are handled by the plugin by default.
|
||||
|
||||
## Creating Custom Providers
|
||||
|
||||
Implement the `AnalyticsService` interface:
|
||||
|
||||
```typescript
|
||||
import { AnalyticsService } from '@kit/analytics';
|
||||
|
||||
class CustomAnalyticsService implements AnalyticsService {
|
||||
async initialize(): Promise<void> {
|
||||
// Initialize your analytics service
|
||||
}
|
||||
|
||||
async trackEvent(name: string, properties?: Record<string, string | string[]>): Promise<void> {
|
||||
// Track event implementation
|
||||
}
|
||||
|
||||
async trackPageView(path: string): Promise<void> {
|
||||
// Track page view implementation
|
||||
}
|
||||
|
||||
async identify(userId: string, traits?: Record<string, string>): Promise<void> {
|
||||
// Identify user implementation
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Default Behavior
|
||||
|
||||
- Uses `NullAnalyticsService` when no providers are active
|
||||
- All methods return Promises that resolve to arrays of provider results
|
||||
- Console debug logging when no active services or using null service
|
||||
- Graceful error handling with console warnings for missing providers
|
||||
|
||||
## Server-Side Analytics
|
||||
|
||||
When using PostHog, you can track events server-side for better reliability and privacy:
|
||||
|
||||
```typescript
|
||||
import { analytics } from '@kit/analytics/server';
|
||||
|
||||
// Server-side event tracking (e.g., in API routes)
|
||||
export async function POST(request: Request) {
|
||||
// ... handle request
|
||||
|
||||
// Track server-side events
|
||||
await analytics.trackEvent('api_call', {
|
||||
endpoint: '/api/users',
|
||||
method: 'POST',
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
|
||||
// Track user registration server-side
|
||||
await analytics.identify(user.id, {
|
||||
email: user.email,
|
||||
created_at: user.created_at,
|
||||
plan: user.plan,
|
||||
});
|
||||
```
|
||||
@@ -17,7 +17,7 @@
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@types/node": "^24.3.1"
|
||||
"@types/node": "^24.5.0"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"./marketing": "./src/components/marketing.tsx"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@kit/billing": "workspace:*",
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/lemon-squeezy": "workspace:*",
|
||||
@@ -26,11 +26,11 @@
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@supabase/supabase-js": "2.57.2",
|
||||
"@types/react": "19.1.12",
|
||||
"@supabase/supabase-js": "2.57.4",
|
||||
"@types/react": "19.1.13",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.542.0",
|
||||
"next": "15.5.2",
|
||||
"lucide-react": "^0.544.0",
|
||||
"next": "15.5.3",
|
||||
"react": "19.1.1",
|
||||
"react-hook-form": "^7.62.0",
|
||||
"react-i18next": "^15.7.3",
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@types/react": "19.1.12",
|
||||
"next": "15.5.2",
|
||||
"@types/react": "19.1.13",
|
||||
"next": "15.5.3",
|
||||
"react": "19.1.1",
|
||||
"zod": "^3.25.74"
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"./components": "./src/components/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stripe/react-stripe-js": "^4.0.0",
|
||||
"@stripe/react-stripe-js": "^4.0.2",
|
||||
"@stripe/stripe-js": "^7.9.0",
|
||||
"stripe": "^18.5.0"
|
||||
},
|
||||
@@ -27,9 +27,9 @@
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react": "19.1.13",
|
||||
"date-fns": "^4.1.0",
|
||||
"next": "15.5.2",
|
||||
"next": "15.5.3",
|
||||
"react": "19.1.1",
|
||||
"zod": "^3.25.74"
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/wordpress": "workspace:*",
|
||||
"@types/node": "^24.3.1"
|
||||
"@types/node": "^24.5.0"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/node": "^24.5.0",
|
||||
"@types/react": "19.1.13",
|
||||
"react": "19.1.1",
|
||||
"zod": "^3.25.74"
|
||||
},
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/node": "^24.5.0",
|
||||
"@types/react": "19.1.13",
|
||||
"wp-types": "^4.68.1"
|
||||
},
|
||||
"typesVersions": {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/team-accounts": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@supabase/supabase-js": "2.57.2",
|
||||
"@supabase/supabase-js": "2.57.4",
|
||||
"zod": "^3.25.74"
|
||||
},
|
||||
"typesVersions": {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-email/components": "0.5.1"
|
||||
"@react-email/components": "0.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
|
||||
289
packages/features/AGENTS.md
Normal file
289
packages/features/AGENTS.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# Feature Packages Instructions
|
||||
|
||||
This file contains instructions for working with feature packages including accounts, teams, billing, auth, and notifications.
|
||||
|
||||
## Feature Package Structure
|
||||
|
||||
- `accounts/` - Personal account management
|
||||
- `admin/` - Super admin functionality
|
||||
- `auth/` - Authentication features
|
||||
- `notifications/` - Notification system
|
||||
- `team-accounts/` - Team account management
|
||||
|
||||
## Account Services
|
||||
|
||||
### Personal Accounts API
|
||||
|
||||
Located at: `packages/features/accounts/src/server/api.ts`
|
||||
|
||||
```typescript
|
||||
import { createAccountsApi } from '@kit/accounts/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createAccountsApi(client);
|
||||
|
||||
// Get account data
|
||||
const account = await api.getAccount(accountId);
|
||||
|
||||
// Get account workspace
|
||||
const workspace = await api.getAccountWorkspace();
|
||||
|
||||
// Load user accounts
|
||||
const accounts = await api.loadUserAccounts();
|
||||
|
||||
// Get subscription
|
||||
const subscription = await api.getSubscription(accountId);
|
||||
|
||||
// Get customer ID
|
||||
const customerId = await api.getCustomerId(accountId);
|
||||
```
|
||||
|
||||
### Team Accounts API
|
||||
|
||||
Located at: `packages/features/team-accounts/src/server/api.ts`
|
||||
|
||||
```typescript
|
||||
import { createTeamAccountsApi } from '@kit/team-accounts/api';
|
||||
|
||||
const api = createTeamAccountsApi(client);
|
||||
|
||||
// Get team account by slug
|
||||
const account = await api.getTeamAccount(slug);
|
||||
|
||||
// Get account workspace
|
||||
const workspace = await api.getAccountWorkspace(slug);
|
||||
|
||||
// Check permissions
|
||||
const hasPermission = await api.hasPermission({
|
||||
accountId,
|
||||
userId,
|
||||
permission: 'billing.manage'
|
||||
});
|
||||
|
||||
// Get members count
|
||||
const count = await api.getMembersCount(accountId);
|
||||
|
||||
// Get invitation
|
||||
const invitation = await api.getInvitation(adminClient, token);
|
||||
```
|
||||
|
||||
## Workspace Contexts
|
||||
|
||||
### Personal Account Context
|
||||
|
||||
Use in `apps/web/app/home/(user)` routes:
|
||||
|
||||
```tsx
|
||||
import { useUserWorkspace } from 'kit/accounts/hooks/use-user-workspace';
|
||||
|
||||
function PersonalComponent() {
|
||||
const { user, account } = useUserWorkspace();
|
||||
|
||||
// user: authenticated user data
|
||||
// account: personal account data
|
||||
|
||||
return <div>Welcome {user.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
Context provider: `packages/features/accounts/src/components/user-workspace-context-provider.tsx`
|
||||
|
||||
### Team Account Context
|
||||
|
||||
Use in `apps/web/app/home/[account]` routes:
|
||||
|
||||
```tsx
|
||||
import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';
|
||||
|
||||
function TeamComponent() {
|
||||
const { account, user, accounts } = useTeamAccountWorkspace();
|
||||
|
||||
// account: current team account data
|
||||
// user: authenticated user data
|
||||
// accounts: all accounts user has access to
|
||||
|
||||
return <div>Team: {account.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
Context provider: `packages/features/team-accounts/src/components/team-account-workspace-context-provider.tsx`
|
||||
|
||||
## Billing Services
|
||||
|
||||
### Personal Billing
|
||||
|
||||
Located at: `apps/web/app/home/(user)/billing/_lib/server/user-billing.service.ts`
|
||||
|
||||
```typescript
|
||||
// Personal billing operations
|
||||
// - Manage individual user subscriptions
|
||||
// - Handle personal account payments
|
||||
// - Process individual billing changes
|
||||
```
|
||||
|
||||
### Team Billing
|
||||
|
||||
Located at: `apps/web/app/home/[account]/billing/_lib/server/team-billing.service.ts`
|
||||
|
||||
```typescript
|
||||
// Team billing operations
|
||||
// - Manage team subscriptions
|
||||
// - Handle team payments
|
||||
// - Process team billing changes
|
||||
```
|
||||
|
||||
### Per-Seat Billing Service
|
||||
|
||||
Located at: `packages/features/team-accounts/src/server/services/account-per-seat-billing.service.ts`
|
||||
|
||||
```typescript
|
||||
import { createAccountPerSeatBillingService } from '@kit/team-accounts/billing';
|
||||
|
||||
const billingService = createAccountPerSeatBillingService(client);
|
||||
|
||||
// Increase seats when adding team members
|
||||
await billingService.increaseSeats(accountId);
|
||||
|
||||
// Decrease seats when removing team members
|
||||
await billingService.decreaseSeats(accountId);
|
||||
|
||||
// Get per-seat subscription item
|
||||
const subscription = await billingService.getPerSeatSubscriptionItem(accountId);
|
||||
```
|
||||
|
||||
## Authentication Features
|
||||
|
||||
### OTP for Sensitive Operations
|
||||
|
||||
Use one-time tokens from `packages/otp/src/api/index.ts`:
|
||||
|
||||
```tsx
|
||||
import { VerifyOtpForm } from '@kit/otp/components';
|
||||
|
||||
<VerifyOtpForm
|
||||
purpose="account-deletion"
|
||||
email={user.email}
|
||||
onSuccess={(otp) => {
|
||||
// Proceed with verified operation
|
||||
handleSensitiveOperation(otp);
|
||||
}}
|
||||
CancelButton={<Button variant="outline">Cancel</Button>}
|
||||
/>
|
||||
```
|
||||
|
||||
## Admin Features
|
||||
|
||||
### Super Admin Protection
|
||||
|
||||
For admin routes, use `AdminGuard`:
|
||||
|
||||
```tsx
|
||||
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
||||
|
||||
function AdminPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Admin Dashboard</h1>
|
||||
{/* Admin content */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap the page component
|
||||
export default AdminGuard(AdminPage);
|
||||
```
|
||||
|
||||
### Admin Service
|
||||
|
||||
Located at: `packages/features/admin/src/lib/server/services/admin.service.ts`
|
||||
|
||||
```typescript
|
||||
// Admin service operations
|
||||
// - Manage all accounts
|
||||
// - Handle admin-level operations
|
||||
// - Access system-wide data
|
||||
```
|
||||
|
||||
### Checking Admin Status
|
||||
|
||||
```typescript
|
||||
import { isSuperAdmin } from '@kit/admin';
|
||||
|
||||
function criticalAdminFeature() {
|
||||
const isAdmin = await isSuperAdmin(client);
|
||||
|
||||
if (!isAdmin) {
|
||||
throw new Error('Access denied: Admin privileges required');
|
||||
}
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling & Logging
|
||||
|
||||
### Structured Logging
|
||||
|
||||
Use logger from `packages/shared/src/logger/logger.ts`:
|
||||
|
||||
```typescript
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
|
||||
async function featureOperation() {
|
||||
const logger = await getLogger();
|
||||
|
||||
const ctx = {
|
||||
name: 'feature-operation',
|
||||
userId: user.id,
|
||||
accountId: account.id
|
||||
};
|
||||
|
||||
try {
|
||||
logger.info(ctx, 'Starting feature operation');
|
||||
|
||||
// Perform operation
|
||||
const result = await performOperation();
|
||||
|
||||
logger.info({ ...ctx, result }, 'Feature operation completed');
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error({ ...ctx, error }, 'Feature operation failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Permission Patterns
|
||||
|
||||
### Team Permissions
|
||||
|
||||
```typescript
|
||||
import { createTeamAccountsApi } from '@kit/team-accounts/api';
|
||||
|
||||
const api = createTeamAccountsApi(client);
|
||||
|
||||
// Check if user has specific permission on account
|
||||
const canManageBilling = await api.hasPermission({
|
||||
accountId,
|
||||
userId,
|
||||
permission: 'billing.manage'
|
||||
});
|
||||
|
||||
if (!canManageBilling) {
|
||||
throw new Error('Insufficient permissions');
|
||||
}
|
||||
```
|
||||
|
||||
### Account Ownership
|
||||
|
||||
```typescript
|
||||
// Check if user is account owner (works for both personal and team accounts)
|
||||
const isOwner = await client.rpc('is_account_owner', {
|
||||
account_id: accountId
|
||||
});
|
||||
|
||||
if (!isOwner) {
|
||||
throw new Error('Only account owners can perform this action');
|
||||
}
|
||||
```
|
||||
289
packages/features/CLAUDE.md
Normal file
289
packages/features/CLAUDE.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# Feature Packages Instructions
|
||||
|
||||
This file contains instructions for working with feature packages including accounts, teams, billing, auth, and notifications.
|
||||
|
||||
## Feature Package Structure
|
||||
|
||||
- `accounts/` - Personal account management
|
||||
- `admin/` - Super admin functionality
|
||||
- `auth/` - Authentication features
|
||||
- `notifications/` - Notification system
|
||||
- `team-accounts/` - Team account management
|
||||
|
||||
## Account Services
|
||||
|
||||
### Personal Accounts API
|
||||
|
||||
Located at: `packages/features/accounts/src/server/api.ts`
|
||||
|
||||
```typescript
|
||||
import { createAccountsApi } from '@kit/accounts/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createAccountsApi(client);
|
||||
|
||||
// Get account data
|
||||
const account = await api.getAccount(accountId);
|
||||
|
||||
// Get account workspace
|
||||
const workspace = await api.getAccountWorkspace();
|
||||
|
||||
// Load user accounts
|
||||
const accounts = await api.loadUserAccounts();
|
||||
|
||||
// Get subscription
|
||||
const subscription = await api.getSubscription(accountId);
|
||||
|
||||
// Get customer ID
|
||||
const customerId = await api.getCustomerId(accountId);
|
||||
```
|
||||
|
||||
### Team Accounts API
|
||||
|
||||
Located at: `packages/features/team-accounts/src/server/api.ts`
|
||||
|
||||
```typescript
|
||||
import { createTeamAccountsApi } from '@kit/team-accounts/api';
|
||||
|
||||
const api = createTeamAccountsApi(client);
|
||||
|
||||
// Get team account by slug
|
||||
const account = await api.getTeamAccount(slug);
|
||||
|
||||
// Get account workspace
|
||||
const workspace = await api.getAccountWorkspace(slug);
|
||||
|
||||
// Check permissions
|
||||
const hasPermission = await api.hasPermission({
|
||||
accountId,
|
||||
userId,
|
||||
permission: 'billing.manage'
|
||||
});
|
||||
|
||||
// Get members count
|
||||
const count = await api.getMembersCount(accountId);
|
||||
|
||||
// Get invitation
|
||||
const invitation = await api.getInvitation(adminClient, token);
|
||||
```
|
||||
|
||||
## Workspace Contexts
|
||||
|
||||
### Personal Account Context
|
||||
|
||||
Use in `apps/web/app/home/(user)` routes:
|
||||
|
||||
```tsx
|
||||
import { useUserWorkspace } from 'kit/accounts/hooks/use-user-workspace';
|
||||
|
||||
function PersonalComponent() {
|
||||
const { user, account } = useUserWorkspace();
|
||||
|
||||
// user: authenticated user data
|
||||
// account: personal account data
|
||||
|
||||
return <div>Welcome {user.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
Context provider: `packages/features/accounts/src/components/user-workspace-context-provider.tsx`
|
||||
|
||||
### Team Account Context
|
||||
|
||||
Use in `apps/web/app/home/[account]` routes:
|
||||
|
||||
```tsx
|
||||
import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';
|
||||
|
||||
function TeamComponent() {
|
||||
const { account, user, accounts } = useTeamAccountWorkspace();
|
||||
|
||||
// account: current team account data
|
||||
// user: authenticated user data
|
||||
// accounts: all accounts user has access to
|
||||
|
||||
return <div>Team: {account.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
Context provider: `packages/features/team-accounts/src/components/team-account-workspace-context-provider.tsx`
|
||||
|
||||
## Billing Services
|
||||
|
||||
### Personal Billing
|
||||
|
||||
Located at: `apps/web/app/home/(user)/billing/_lib/server/user-billing.service.ts`
|
||||
|
||||
```typescript
|
||||
// Personal billing operations
|
||||
// - Manage individual user subscriptions
|
||||
// - Handle personal account payments
|
||||
// - Process individual billing changes
|
||||
```
|
||||
|
||||
### Team Billing
|
||||
|
||||
Located at: `apps/web/app/home/[account]/billing/_lib/server/team-billing.service.ts`
|
||||
|
||||
```typescript
|
||||
// Team billing operations
|
||||
// - Manage team subscriptions
|
||||
// - Handle team payments
|
||||
// - Process team billing changes
|
||||
```
|
||||
|
||||
### Per-Seat Billing Service
|
||||
|
||||
Located at: `packages/features/team-accounts/src/server/services/account-per-seat-billing.service.ts`
|
||||
|
||||
```typescript
|
||||
import { createAccountPerSeatBillingService } from '@kit/team-accounts/billing';
|
||||
|
||||
const billingService = createAccountPerSeatBillingService(client);
|
||||
|
||||
// Increase seats when adding team members
|
||||
await billingService.increaseSeats(accountId);
|
||||
|
||||
// Decrease seats when removing team members
|
||||
await billingService.decreaseSeats(accountId);
|
||||
|
||||
// Get per-seat subscription item
|
||||
const subscription = await billingService.getPerSeatSubscriptionItem(accountId);
|
||||
```
|
||||
|
||||
## Authentication Features
|
||||
|
||||
### OTP for Sensitive Operations
|
||||
|
||||
Use one-time tokens from `packages/otp/src/api/index.ts`:
|
||||
|
||||
```tsx
|
||||
import { VerifyOtpForm } from '@kit/otp/components';
|
||||
|
||||
<VerifyOtpForm
|
||||
purpose="account-deletion"
|
||||
email={user.email}
|
||||
onSuccess={(otp) => {
|
||||
// Proceed with verified operation
|
||||
handleSensitiveOperation(otp);
|
||||
}}
|
||||
CancelButton={<Button variant="outline">Cancel</Button>}
|
||||
/>
|
||||
```
|
||||
|
||||
## Admin Features
|
||||
|
||||
### Super Admin Protection
|
||||
|
||||
For admin routes, use `AdminGuard`:
|
||||
|
||||
```tsx
|
||||
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
||||
|
||||
function AdminPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Admin Dashboard</h1>
|
||||
{/* Admin content */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap the page component
|
||||
export default AdminGuard(AdminPage);
|
||||
```
|
||||
|
||||
### Admin Service
|
||||
|
||||
Located at: `packages/features/admin/src/lib/server/services/admin.service.ts`
|
||||
|
||||
```typescript
|
||||
// Admin service operations
|
||||
// - Manage all accounts
|
||||
// - Handle admin-level operations
|
||||
// - Access system-wide data
|
||||
```
|
||||
|
||||
### Checking Admin Status
|
||||
|
||||
```typescript
|
||||
import { isSuperAdmin } from '@kit/admin';
|
||||
|
||||
function criticalAdminFeature() {
|
||||
const isAdmin = await isSuperAdmin(client);
|
||||
|
||||
if (!isAdmin) {
|
||||
throw new Error('Access denied: Admin privileges required');
|
||||
}
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling & Logging
|
||||
|
||||
### Structured Logging
|
||||
|
||||
Use logger from `packages/shared/src/logger/logger.ts`:
|
||||
|
||||
```typescript
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
|
||||
async function featureOperation() {
|
||||
const logger = await getLogger();
|
||||
|
||||
const ctx = {
|
||||
name: 'feature-operation',
|
||||
userId: user.id,
|
||||
accountId: account.id
|
||||
};
|
||||
|
||||
try {
|
||||
logger.info(ctx, 'Starting feature operation');
|
||||
|
||||
// Perform operation
|
||||
const result = await performOperation();
|
||||
|
||||
logger.info({ ...ctx, result }, 'Feature operation completed');
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error({ ...ctx, error }, 'Feature operation failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Permission Patterns
|
||||
|
||||
### Team Permissions
|
||||
|
||||
```typescript
|
||||
import { createTeamAccountsApi } from '@kit/team-accounts/api';
|
||||
|
||||
const api = createTeamAccountsApi(client);
|
||||
|
||||
// Check if user has specific permission on account
|
||||
const canManageBilling = await api.hasPermission({
|
||||
accountId,
|
||||
userId,
|
||||
permission: 'billing.manage'
|
||||
});
|
||||
|
||||
if (!canManageBilling) {
|
||||
throw new Error('Insufficient permissions');
|
||||
}
|
||||
```
|
||||
|
||||
### Account Ownership
|
||||
|
||||
```typescript
|
||||
// Check if user is account owner (works for both personal and team accounts)
|
||||
const isOwner = await client.rpc('is_account_owner', {
|
||||
account_id: accountId
|
||||
});
|
||||
|
||||
if (!isOwner) {
|
||||
throw new Error('Only account owners can perform this action');
|
||||
}
|
||||
```
|
||||
@@ -20,7 +20,7 @@
|
||||
"nanoid": "^5.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@kit/billing-gateway": "workspace:*",
|
||||
"@kit/email-templates": "workspace:*",
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
@@ -34,12 +34,12 @@
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@supabase/supabase-js": "2.57.2",
|
||||
"@tanstack/react-query": "5.87.1",
|
||||
"@types/react": "19.1.12",
|
||||
"@supabase/supabase-js": "2.57.4",
|
||||
"@tanstack/react-query": "5.89.0",
|
||||
"@types/react": "19.1.13",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"lucide-react": "^0.542.0",
|
||||
"next": "15.5.2",
|
||||
"lucide-react": "^0.544.0",
|
||||
"next": "15.5.3",
|
||||
"next-themes": "0.4.6",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/next": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
@@ -20,12 +20,12 @@
|
||||
"@kit/ui": "workspace:*",
|
||||
"@makerkit/data-loader-supabase-core": "^0.0.10",
|
||||
"@makerkit/data-loader-supabase-nextjs": "^1.2.5",
|
||||
"@supabase/supabase-js": "2.57.2",
|
||||
"@tanstack/react-query": "5.87.1",
|
||||
"@supabase/supabase-js": "2.57.4",
|
||||
"@tanstack/react-query": "5.89.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/react": "19.1.12",
|
||||
"lucide-react": "^0.542.0",
|
||||
"next": "15.5.2",
|
||||
"@types/react": "19.1.13",
|
||||
"lucide-react": "^0.544.0",
|
||||
"next": "15.5.3",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1",
|
||||
"react-hook-form": "^7.62.0",
|
||||
|
||||
@@ -46,18 +46,18 @@ export function AdminAccountPage(props: {
|
||||
async function PersonalAccountPage(props: { account: Account }) {
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
const { data, error } = await adminClient.auth.admin.getUserById(
|
||||
props.account.id,
|
||||
);
|
||||
const [memberships, userResult] = await Promise.all([
|
||||
getMemberships(props.account.id),
|
||||
adminClient.auth.admin.getUserById(props.account.id),
|
||||
]);
|
||||
|
||||
if (!data || error) {
|
||||
throw new Error(`User not found`);
|
||||
if (userResult.error) {
|
||||
throw userResult.error;
|
||||
}
|
||||
|
||||
const memberships = await getMemberships(props.account.id);
|
||||
|
||||
const isBanned =
|
||||
'banned_until' in data.user && data.user.banned_until !== 'none';
|
||||
'banned_until' in userResult.data.user &&
|
||||
userResult.data.user.banned_until !== 'none';
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -168,6 +168,7 @@ function getColumns(): ColumnDef<Account>[] {
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Link
|
||||
prefetch={false}
|
||||
className={'hover:underline'}
|
||||
href={`/admin/accounts/${row.original.id}`}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect-error';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
@@ -77,11 +79,9 @@ function BanUserForm(props: { userId: string }) {
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await banUserAction(data);
|
||||
|
||||
setError(!result.success);
|
||||
} catch {
|
||||
setError(true);
|
||||
await banUserAction(data);
|
||||
} catch (error) {
|
||||
setError(!isRedirectError(error));
|
||||
}
|
||||
});
|
||||
})}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect-error';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
@@ -76,11 +78,9 @@ function ReactivateUserForm(props: { userId: string }) {
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await reactivateUserAction(data);
|
||||
|
||||
setError(!result.success);
|
||||
} catch {
|
||||
setError(true);
|
||||
await reactivateUserAction(data);
|
||||
} catch (error) {
|
||||
setError(!isRedirectError(error));
|
||||
}
|
||||
});
|
||||
})}
|
||||
|
||||
@@ -47,9 +47,7 @@ export const banUserAction = adminAction(
|
||||
|
||||
revalidateAdmin();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
return redirect(`/admin/accounts/${userId}`);
|
||||
},
|
||||
{
|
||||
schema: BanUserSchema,
|
||||
@@ -83,9 +81,7 @@ export const reactivateUserAction = adminAction(
|
||||
|
||||
logger.info({ userId }, `Super Admin has successfully reactivated user`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
return redirect(`/admin/accounts/${userId}`);
|
||||
},
|
||||
{
|
||||
schema: ReactivateUserSchema,
|
||||
|
||||
@@ -20,20 +20,20 @@
|
||||
"./oauth-provider-logo-image": "./src/components/oauth-provider-logo-image.tsx"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@marsidev/react-turnstile": "^1.3.0",
|
||||
"@marsidev/react-turnstile": "^1.3.1",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@supabase/supabase-js": "2.57.2",
|
||||
"@tanstack/react-query": "5.87.1",
|
||||
"@types/react": "19.1.12",
|
||||
"lucide-react": "^0.542.0",
|
||||
"next": "15.5.2",
|
||||
"@supabase/supabase-js": "2.57.4",
|
||||
"@tanstack/react-query": "5.89.0",
|
||||
"@types/react": "19.1.13",
|
||||
"lucide-react": "^0.544.0",
|
||||
"next": "15.5.3",
|
||||
"react-hook-form": "^7.62.0",
|
||||
"react-i18next": "^15.7.3",
|
||||
"sonner": "^2.0.7",
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@supabase/supabase-js": "2.57.2",
|
||||
"@tanstack/react-query": "5.87.1",
|
||||
"@types/react": "19.1.12",
|
||||
"lucide-react": "^0.542.0",
|
||||
"@supabase/supabase-js": "2.57.4",
|
||||
"@tanstack/react-query": "5.89.0",
|
||||
"@types/react": "19.1.13",
|
||||
"lucide-react": "^0.544.0",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1",
|
||||
"react-i18next": "^15.7.3"
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"nanoid": "^5.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@kit/accounts": "workspace:*",
|
||||
"@kit/billing-gateway": "workspace:*",
|
||||
"@kit/email-templates": "workspace:*",
|
||||
@@ -32,15 +32,15 @@
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@supabase/supabase-js": "2.57.2",
|
||||
"@tanstack/react-query": "5.87.1",
|
||||
"@supabase/supabase-js": "2.57.4",
|
||||
"@tanstack/react-query": "5.89.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react": "19.1.13",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.542.0",
|
||||
"next": "15.5.2",
|
||||
"lucide-react": "^0.544.0",
|
||||
"next": "15.5.3",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1",
|
||||
"react-hook-form": "^7.62.0",
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@tanstack/react-query": "5.87.1",
|
||||
"next": "15.5.2",
|
||||
"@tanstack/react-query": "5.89.0",
|
||||
"next": "15.5.3",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1",
|
||||
"react-i18next": "^15.7.3"
|
||||
|
||||
66
packages/mailers/AGENTS.md
Normal file
66
packages/mailers/AGENTS.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Email Service Instructions
|
||||
|
||||
This file contains guidance for working with the email service supporting Resend and Nodemailer.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import { getMailer } from '@kit/mailers';
|
||||
import { renderAccountDeleteEmail } from '@kit/email-templates';
|
||||
|
||||
async function sendSimpleEmail() {
|
||||
// Get mailer instance
|
||||
const mailer = await getMailer();
|
||||
|
||||
// Send simple email
|
||||
await mailer.sendEmail({
|
||||
to: 'user@example.com',
|
||||
from: 'noreply@yourdomain.com',
|
||||
subject: 'Welcome!',
|
||||
html: '<h1>Welcome!</h1><p>Thank you for joining us.</p>',
|
||||
});
|
||||
}
|
||||
|
||||
async function sendComplexEmail() {
|
||||
// Send with email template
|
||||
const { html, subject } = await renderAccountDeleteEmail({
|
||||
userDisplayName: user.name,
|
||||
productName: 'My SaaS App',
|
||||
});
|
||||
|
||||
await mailer.sendEmail({
|
||||
to: user.email,
|
||||
from: 'noreply@yourdomain.com',
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Email Templates
|
||||
|
||||
Email templates are located in `@kit/email-templates` and return `{ html, subject }`:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
renderAccountDeleteEmail,
|
||||
renderWelcomeEmail,
|
||||
renderPasswordResetEmail
|
||||
} from '@kit/email-templates';
|
||||
|
||||
// Render template
|
||||
const { html, subject } = await renderWelcomeEmail({
|
||||
userDisplayName: 'John Doe',
|
||||
loginUrl: 'https://app.com/login'
|
||||
});
|
||||
|
||||
// Send rendered email
|
||||
const mailer = await getMailer();
|
||||
|
||||
await mailer.sendEmail({
|
||||
to: user.email,
|
||||
from: 'welcome@yourdomain.com',
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
```
|
||||
66
packages/mailers/CLAUDE.md
Normal file
66
packages/mailers/CLAUDE.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Email Service Instructions
|
||||
|
||||
This file contains guidance for working with the email service supporting Resend and Nodemailer.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import { getMailer } from '@kit/mailers';
|
||||
import { renderAccountDeleteEmail } from '@kit/email-templates';
|
||||
|
||||
async function sendSimpleEmail() {
|
||||
// Get mailer instance
|
||||
const mailer = await getMailer();
|
||||
|
||||
// Send simple email
|
||||
await mailer.sendEmail({
|
||||
to: 'user@example.com',
|
||||
from: 'noreply@yourdomain.com',
|
||||
subject: 'Welcome!',
|
||||
html: '<h1>Welcome!</h1><p>Thank you for joining us.</p>',
|
||||
});
|
||||
}
|
||||
|
||||
async function sendComplexEmail() {
|
||||
// Send with email template
|
||||
const { html, subject } = await renderAccountDeleteEmail({
|
||||
userDisplayName: user.name,
|
||||
productName: 'My SaaS App',
|
||||
});
|
||||
|
||||
await mailer.sendEmail({
|
||||
to: user.email,
|
||||
from: 'noreply@yourdomain.com',
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Email Templates
|
||||
|
||||
Email templates are located in `@kit/email-templates` and return `{ html, subject }`:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
renderAccountDeleteEmail,
|
||||
renderWelcomeEmail,
|
||||
renderPasswordResetEmail
|
||||
} from '@kit/email-templates';
|
||||
|
||||
// Render template
|
||||
const { html, subject } = await renderWelcomeEmail({
|
||||
userDisplayName: 'John Doe',
|
||||
loginUrl: 'https://app.com/login'
|
||||
});
|
||||
|
||||
// Send rendered email
|
||||
const mailer = await getMailer();
|
||||
|
||||
await mailer.sendEmail({
|
||||
to: user.email,
|
||||
from: 'welcome@yourdomain.com',
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
```
|
||||
@@ -20,7 +20,7 @@
|
||||
"@kit/resend": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/node": "^24.5.0",
|
||||
"zod": "^3.25.74"
|
||||
},
|
||||
"typesVersions": {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"@kit/mailers-shared": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/node": "^24.5.0",
|
||||
"zod": "^3.25.74"
|
||||
},
|
||||
"typesVersions": {
|
||||
|
||||
1
packages/mcp-server/.gitignore
vendored
Normal file
1
packages/mcp-server/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
build
|
||||
58
packages/mcp-server/README.md
Normal file
58
packages/mcp-server/README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Makerkit MCP Server
|
||||
|
||||
The Makerkit MCP Server provides tools to AI Agents for working with the codebase.
|
||||
|
||||
## Build MCP Server
|
||||
|
||||
Run the command:
|
||||
|
||||
```bash
|
||||
pnpm --filter "@kit/mcp-server" build
|
||||
```
|
||||
|
||||
The command will build the MCP Server at `packages/mcp-server/build/index.js`.
|
||||
|
||||
## Adding MCP Servers to AI Coding tools
|
||||
|
||||
Before getting started, retrieve the absolute path to the `index.js` file created above. You can normally do this in your IDE by right-clicking the `index.js` file and selecting `Copy Path`.
|
||||
|
||||
I will reference this as `<full-path>` in the steps below: please replace it with the full path to your `index.js`.
|
||||
|
||||
### Claude Code
|
||||
|
||||
Run the command below:
|
||||
|
||||
```bash
|
||||
claude mcp add makerkit node <full-path>
|
||||
```
|
||||
|
||||
Restart Claude Code. If no errors appear, the MCP should be correctly configured.
|
||||
|
||||
### Codex
|
||||
|
||||
Open the Codex YAML config and add the following:
|
||||
|
||||
```
|
||||
[mcp_servers.makerkit]
|
||||
command = "node"
|
||||
args = ["<full-path>"]
|
||||
```
|
||||
|
||||
### Cursor
|
||||
|
||||
Open the `mcp.json` config in Cursor and add the following config:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"makerkit": {
|
||||
"command": "node",
|
||||
"args": ["<full-path>"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Additional MCP Servers
|
||||
|
||||
I strongly suggest using [the Postgres MCP Server](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/postgres) that allows AI Agents to understand the structure of your Database.
|
||||
3
packages/mcp-server/eslint.config.mjs
Normal file
3
packages/mcp-server/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import eslintConfigBase from '@kit/eslint-config/base.js';
|
||||
|
||||
export default eslintConfigBase;
|
||||
31
packages/mcp-server/package.json
Normal file
31
packages/mcp-server/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@kit/mcp-server",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"main": "./build/index.js",
|
||||
"bin": {
|
||||
"makerkit-mcp-server": "./build/index.js"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
|
||||
"build": "tsc && chmod 755 build/index.js",
|
||||
"mcp": "node build/index.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@modelcontextprotocol/sdk": "1.18.0",
|
||||
"@types/node": "^24.5.0",
|
||||
"zod": "^3.25.74"
|
||||
},
|
||||
"prettier": "@kit/prettier-config"
|
||||
}
|
||||
31
packages/mcp-server/src/index.ts
Normal file
31
packages/mcp-server/src/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
|
||||
import { registerComponentsTools } from './tools/components';
|
||||
import { registerDatabaseTools } from './tools/database';
|
||||
import { registerGetMigrationsTools } from './tools/migrations';
|
||||
import { registerScriptsTools } from './tools/scripts';
|
||||
|
||||
// Create server instance
|
||||
const server = new McpServer({
|
||||
name: 'makerkit',
|
||||
version: '1.0.0',
|
||||
capabilities: {},
|
||||
});
|
||||
|
||||
registerGetMigrationsTools(server);
|
||||
registerDatabaseTools(server);
|
||||
registerComponentsTools(server);
|
||||
registerScriptsTools(server);
|
||||
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
|
||||
console.error('Makerkit MCP Server running on stdio');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error in main():', error);
|
||||
process.exit(1);
|
||||
});
|
||||
0
packages/mcp-server/src/server.ts
Normal file
0
packages/mcp-server/src/server.ts
Normal file
493
packages/mcp-server/src/tools/components.ts
Normal file
493
packages/mcp-server/src/tools/components.ts
Normal file
@@ -0,0 +1,493 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
interface ComponentInfo {
|
||||
name: string;
|
||||
exportPath: string;
|
||||
filePath: string;
|
||||
category: 'shadcn' | 'makerkit' | 'utils';
|
||||
description: string;
|
||||
}
|
||||
|
||||
export class ComponentsTool {
|
||||
static async getComponents(): Promise<ComponentInfo[]> {
|
||||
const packageJsonPath = join(
|
||||
process.cwd(),
|
||||
'packages',
|
||||
'ui',
|
||||
'package.json',
|
||||
);
|
||||
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'));
|
||||
|
||||
const components: ComponentInfo[] = [];
|
||||
|
||||
for (const [exportName, filePath] of Object.entries(packageJson.exports)) {
|
||||
if (typeof filePath === 'string' && filePath.endsWith('.tsx')) {
|
||||
const category = this.determineCategory(filePath);
|
||||
const description = await this.generateDescription(
|
||||
exportName,
|
||||
filePath,
|
||||
category,
|
||||
);
|
||||
|
||||
components.push({
|
||||
name: exportName.replace('./', ''),
|
||||
exportPath: exportName,
|
||||
filePath: filePath,
|
||||
category,
|
||||
description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return components.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
static async searchComponents(query: string): Promise<ComponentInfo[]> {
|
||||
const allComponents = await this.getComponents();
|
||||
const searchTerm = query.toLowerCase();
|
||||
|
||||
return allComponents.filter((component) => {
|
||||
return (
|
||||
component.name.toLowerCase().includes(searchTerm) ||
|
||||
component.description.toLowerCase().includes(searchTerm) ||
|
||||
component.category.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
static async getComponentProps(componentName: string): Promise<{
|
||||
componentName: string;
|
||||
props: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
optional: boolean;
|
||||
description?: string;
|
||||
}>;
|
||||
interfaces: string[];
|
||||
variants?: Record<string, string[]>;
|
||||
}> {
|
||||
const content = await this.getComponentContent(componentName);
|
||||
|
||||
return {
|
||||
componentName,
|
||||
props: this.extractProps(content),
|
||||
interfaces: this.extractInterfaces(content),
|
||||
variants: this.extractVariants(content),
|
||||
};
|
||||
}
|
||||
|
||||
private static extractProps(content: string): Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
optional: boolean;
|
||||
description?: string;
|
||||
}> {
|
||||
const props: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
optional: boolean;
|
||||
description?: string;
|
||||
}> = [];
|
||||
|
||||
// Look for interface definitions that end with "Props"
|
||||
const interfaceRegex =
|
||||
/interface\s+(\w*Props)\s*(?:extends[^{]*?)?\s*{([^}]*)}/gs;
|
||||
let match;
|
||||
|
||||
while ((match = interfaceRegex.exec(content)) !== null) {
|
||||
const interfaceBody = match[2];
|
||||
const propLines = interfaceBody
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line);
|
||||
|
||||
for (const line of propLines) {
|
||||
// Skip comments and empty lines
|
||||
if (
|
||||
line.startsWith('//') ||
|
||||
line.startsWith('*') ||
|
||||
!line.includes(':')
|
||||
)
|
||||
continue;
|
||||
|
||||
// Extract prop name and type
|
||||
const propMatch = line.match(/(\w+)(\?)?\s*:\s*([^;,]+)/);
|
||||
if (propMatch) {
|
||||
const [, name, optional, type] = propMatch;
|
||||
props.push({
|
||||
name,
|
||||
type: type.trim(),
|
||||
optional: Boolean(optional),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return props;
|
||||
}
|
||||
|
||||
private static extractInterfaces(content: string): string[] {
|
||||
const interfaces: string[] = [];
|
||||
const interfaceRegex = /(?:export\s+)?interface\s+(\w+)/g;
|
||||
let match;
|
||||
|
||||
while ((match = interfaceRegex.exec(content)) !== null) {
|
||||
interfaces.push(match[1]);
|
||||
}
|
||||
|
||||
return interfaces;
|
||||
}
|
||||
|
||||
private static extractVariants(
|
||||
content: string,
|
||||
): Record<string, string[]> | undefined {
|
||||
// Look for CVA (class-variance-authority) variants
|
||||
const cvaRegex = /cva\s*\([^,]*,\s*{[^}]*variants:\s*{([^}]*)}/s;
|
||||
const match = cvaRegex.exec(content);
|
||||
|
||||
if (!match) return undefined;
|
||||
|
||||
const variantsSection = match[1];
|
||||
const variants: Record<string, string[]> = {};
|
||||
|
||||
// Extract each variant category
|
||||
const variantRegex = /(\w+):\s*{([^}]*)}/g;
|
||||
let variantMatch;
|
||||
|
||||
while ((variantMatch = variantRegex.exec(variantsSection)) !== null) {
|
||||
const [, variantName, variantOptions] = variantMatch;
|
||||
const options: string[] = [];
|
||||
|
||||
// Extract option names
|
||||
const optionRegex = /(\w+):/g;
|
||||
let optionMatch;
|
||||
|
||||
while ((optionMatch = optionRegex.exec(variantOptions)) !== null) {
|
||||
options.push(optionMatch[1]);
|
||||
}
|
||||
|
||||
if (options.length > 0) {
|
||||
variants[variantName] = options;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(variants).length > 0 ? variants : undefined;
|
||||
}
|
||||
|
||||
static async getComponentContent(componentName: string): Promise<string> {
|
||||
const packageJsonPath = join(
|
||||
process.cwd(),
|
||||
'packages',
|
||||
'ui',
|
||||
'package.json',
|
||||
);
|
||||
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'));
|
||||
|
||||
const exportPath = `./${componentName}`;
|
||||
const filePath = packageJson.exports[exportPath];
|
||||
|
||||
if (!filePath) {
|
||||
throw new Error(`Component "${componentName}" not found in exports`);
|
||||
}
|
||||
|
||||
const fullPath = join(process.cwd(), 'packages', 'ui', filePath);
|
||||
return readFile(fullPath, 'utf8');
|
||||
}
|
||||
|
||||
private static determineCategory(
|
||||
filePath: string,
|
||||
): 'shadcn' | 'makerkit' | 'utils' {
|
||||
if (filePath.includes('/shadcn/')) return 'shadcn';
|
||||
if (filePath.includes('/makerkit/')) return 'makerkit';
|
||||
return 'utils';
|
||||
}
|
||||
|
||||
private static async generateDescription(
|
||||
exportName: string,
|
||||
_filePath: string,
|
||||
category: 'shadcn' | 'makerkit' | 'utils',
|
||||
): Promise<string> {
|
||||
const componentName = exportName.replace('./', '');
|
||||
|
||||
if (category === 'shadcn') {
|
||||
return this.getShadcnDescription(componentName);
|
||||
} else if (category === 'makerkit') {
|
||||
return this.getMakerkitDescription(componentName);
|
||||
} else {
|
||||
return this.getUtilsDescription(componentName);
|
||||
}
|
||||
}
|
||||
|
||||
private static getShadcnDescription(componentName: string): string {
|
||||
const descriptions: Record<string, string> = {
|
||||
accordion:
|
||||
'A vertically stacked set of interactive headings that each reveal a section of content',
|
||||
'alert-dialog':
|
||||
'A modal dialog that interrupts the user with important content and expects a response',
|
||||
alert:
|
||||
'Displays a callout for user attention with different severity levels',
|
||||
avatar: 'An image element with a fallback for representing the user',
|
||||
badge: 'A small status descriptor for UI elements',
|
||||
breadcrumb:
|
||||
'Displays the path to the current resource using a hierarchy of links',
|
||||
button: 'Displays a button or a component that looks like a button',
|
||||
calendar:
|
||||
'A date field component that allows users to enter and edit date',
|
||||
card: 'Displays a card with header, content, and footer',
|
||||
chart: 'A collection of chart components built on top of Recharts',
|
||||
checkbox:
|
||||
'A control that allows the user to toggle between checked and not checked',
|
||||
collapsible: 'An interactive component which can be expanded/collapsed',
|
||||
command: 'A fast, composable, unstyled command menu for React',
|
||||
'data-table': 'A powerful table component built on top of TanStack Table',
|
||||
dialog:
|
||||
'A window overlaid on either the primary window or another dialog window',
|
||||
'dropdown-menu': 'Displays a menu to the user triggered by a button',
|
||||
form: 'Building forms with validation and error handling',
|
||||
heading: 'Typography component for displaying headings',
|
||||
input:
|
||||
'Displays a form input field or a component that looks like an input field',
|
||||
'input-otp':
|
||||
'Accessible one-time password component with copy paste functionality',
|
||||
label: 'Renders an accessible label associated with controls',
|
||||
'navigation-menu': 'A collection of links for navigating websites',
|
||||
popover: 'Displays rich content in a portal, triggered by a button',
|
||||
progress:
|
||||
'Displays an indicator showing the completion progress of a task',
|
||||
'radio-group':
|
||||
'A set of checkable buttons where no more than one can be checked at a time',
|
||||
'scroll-area':
|
||||
'Augments native scroll functionality for custom, cross-browser styling',
|
||||
select: 'Displays a list of options for the user to pick from',
|
||||
separator: 'Visually or semantically separates content',
|
||||
sheet:
|
||||
'Extends the Dialog component to display content that complements the main content of the screen',
|
||||
sidebar: 'A collapsible sidebar component with navigation',
|
||||
skeleton: 'Use to show a placeholder while content is loading',
|
||||
slider:
|
||||
'An input where the user selects a value from within a given range',
|
||||
sonner: 'An opinionated toast component for React',
|
||||
switch:
|
||||
'A control that allows the user to toggle between checked and not checked',
|
||||
table: 'A responsive table component',
|
||||
tabs: 'A set of layered sections of content - known as tab panels - that are displayed one at a time',
|
||||
textarea:
|
||||
'Displays a form textarea or a component that looks like a textarea',
|
||||
tooltip:
|
||||
'A popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it',
|
||||
};
|
||||
|
||||
return (
|
||||
descriptions[componentName] || `Shadcn UI component: ${componentName}`
|
||||
);
|
||||
}
|
||||
|
||||
private static getMakerkitDescription(componentName: string): string {
|
||||
const descriptions: Record<string, string> = {
|
||||
if: 'Conditional rendering component that shows children only when condition is true',
|
||||
trans:
|
||||
'Internationalization component for translating text with interpolation support',
|
||||
sidebar:
|
||||
'Application sidebar component with navigation and collapsible functionality',
|
||||
'bordered-navigation-menu':
|
||||
'Navigation menu component with bordered styling',
|
||||
spinner: 'Loading spinner component with customizable size and styling',
|
||||
page: 'Page layout component that provides consistent structure and styling',
|
||||
'image-uploader':
|
||||
'Component for uploading and displaying images with drag-and-drop support',
|
||||
'global-loader':
|
||||
'Global loading indicator component for application-wide loading states',
|
||||
'loading-overlay':
|
||||
'Overlay component that shows loading state over content',
|
||||
'profile-avatar':
|
||||
'User profile avatar component with fallback and customization options',
|
||||
'enhanced-data-table':
|
||||
'Enhanced data table component with sorting, filtering, and pagination (best table component)',
|
||||
'language-selector':
|
||||
'Component for selecting application language/locale',
|
||||
stepper: 'Step-by-step navigation component for multi-step processes',
|
||||
'card-button': 'Clickable card component that acts as a button',
|
||||
'multi-step-form':
|
||||
'Multi-step form component with validation and navigation',
|
||||
'app-breadcrumbs': 'Application breadcrumb navigation component',
|
||||
'empty-state':
|
||||
'Component for displaying empty states with customizable content',
|
||||
marketing: 'Collection of marketing-focused components and layouts',
|
||||
'file-uploader':
|
||||
'File upload component with drag-and-drop and preview functionality',
|
||||
};
|
||||
|
||||
return (
|
||||
descriptions[componentName] ||
|
||||
`Makerkit custom component: ${componentName}`
|
||||
);
|
||||
}
|
||||
|
||||
private static getUtilsDescription(componentName: string): string {
|
||||
const descriptions: Record<string, string> = {
|
||||
utils:
|
||||
'Utility functions for styling, class management, and common operations',
|
||||
'navigation-schema': 'Schema and types for navigation configuration',
|
||||
};
|
||||
|
||||
return descriptions[componentName] || `Utility module: ${componentName}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerComponentsTools(server: McpServer) {
|
||||
createGetComponentsTool(server);
|
||||
createGetComponentContentTool(server);
|
||||
createComponentsSearchTool(server);
|
||||
createGetComponentPropsTool(server);
|
||||
}
|
||||
|
||||
function createGetComponentsTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'get_components',
|
||||
'Get all available UI components from the @kit/ui package with descriptions',
|
||||
async () => {
|
||||
const components = await ComponentsTool.getComponents();
|
||||
|
||||
const componentsList = components
|
||||
.map(
|
||||
(component) =>
|
||||
`${component.name} (${component.category}): ${component.description}`,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: componentsList,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createGetComponentContentTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'get_component_content',
|
||||
'Get the source code content of a specific UI component',
|
||||
{
|
||||
state: z.object({
|
||||
componentName: z.string(),
|
||||
}),
|
||||
},
|
||||
async ({ state }) => {
|
||||
const content = await ComponentsTool.getComponentContent(
|
||||
state.componentName,
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: content,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createComponentsSearchTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'components_search',
|
||||
'Search UI components by keyword in name, description, or category',
|
||||
{
|
||||
state: z.object({
|
||||
query: z.string(),
|
||||
}),
|
||||
},
|
||||
async ({ state }) => {
|
||||
const components = await ComponentsTool.searchComponents(state.query);
|
||||
|
||||
if (components.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `No components found matching "${state.query}"`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const componentsList = components
|
||||
.map(
|
||||
(component) =>
|
||||
`${component.name} (${component.category}): ${component.description}`,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Found ${components.length} components matching "${state.query}":\n\n${componentsList}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createGetComponentPropsTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'get_component_props',
|
||||
'Extract component props, interfaces, and variants from a UI component',
|
||||
{
|
||||
state: z.object({
|
||||
componentName: z.string(),
|
||||
}),
|
||||
},
|
||||
async ({ state }) => {
|
||||
const propsInfo = await ComponentsTool.getComponentProps(
|
||||
state.componentName,
|
||||
);
|
||||
|
||||
let result = `Component: ${propsInfo.componentName}\n\n`;
|
||||
|
||||
if (propsInfo.interfaces.length > 0) {
|
||||
result += `Interfaces: ${propsInfo.interfaces.join(', ')}\n\n`;
|
||||
}
|
||||
|
||||
if (propsInfo.props.length > 0) {
|
||||
result += `Props:\n`;
|
||||
propsInfo.props.forEach((prop) => {
|
||||
const optional = prop.optional ? '?' : '';
|
||||
result += ` - ${prop.name}${optional}: ${prop.type}\n`;
|
||||
});
|
||||
result += '\n';
|
||||
}
|
||||
|
||||
if (propsInfo.variants) {
|
||||
result += `Variants (CVA):\n`;
|
||||
Object.entries(propsInfo.variants).forEach(([variantName, options]) => {
|
||||
result += ` - ${variantName}: ${options.join(' | ')}\n`;
|
||||
});
|
||||
result += '\n';
|
||||
}
|
||||
|
||||
if (propsInfo.props.length === 0 && !propsInfo.variants) {
|
||||
result +=
|
||||
'No props or variants found. This might be a simple component or utility.';
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: result,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
706
packages/mcp-server/src/tools/database.ts
Normal file
706
packages/mcp-server/src/tools/database.ts
Normal file
@@ -0,0 +1,706 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
interface DatabaseFunction {
|
||||
name: string;
|
||||
parameters: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
defaultValue?: string;
|
||||
}>;
|
||||
returnType: string;
|
||||
description: string;
|
||||
purpose: string;
|
||||
securityLevel: 'definer' | 'invoker';
|
||||
schema: string;
|
||||
sourceFile: string;
|
||||
}
|
||||
|
||||
interface SchemaFile {
|
||||
name: string;
|
||||
path: string;
|
||||
description: string;
|
||||
section: string;
|
||||
lastModified: Date;
|
||||
tables: string[];
|
||||
functions: string[];
|
||||
dependencies: string[];
|
||||
topic: string;
|
||||
}
|
||||
|
||||
export class DatabaseTool {
|
||||
static async getSchemaFiles(): Promise<SchemaFile[]> {
|
||||
const schemasPath = join(
|
||||
process.cwd(),
|
||||
'apps',
|
||||
'web',
|
||||
'supabase',
|
||||
'schemas',
|
||||
);
|
||||
const files = await readdir(schemasPath);
|
||||
|
||||
const schemaFiles: SchemaFile[] = [];
|
||||
|
||||
for (const file of files.filter((f) => f.endsWith('.sql'))) {
|
||||
const filePath = join(schemasPath, file);
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const stats = await stat(filePath);
|
||||
|
||||
// Extract section and description from the file header
|
||||
const sectionMatch = content.match(/\* Section: ([^\n*]+)/);
|
||||
const descriptionMatch = content.match(/\* ([^*\n]+)\n \* We create/);
|
||||
|
||||
// Extract tables and functions from content
|
||||
const tables = this.extractTables(content);
|
||||
const functions = this.extractFunctionNames(content);
|
||||
const dependencies = this.extractDependencies(content);
|
||||
const topic = this.determineTopic(file, content);
|
||||
|
||||
schemaFiles.push({
|
||||
name: file,
|
||||
path: filePath,
|
||||
section: sectionMatch?.[1]?.trim() || 'Unknown',
|
||||
description:
|
||||
descriptionMatch?.[1]?.trim() || 'No description available',
|
||||
lastModified: stats.mtime,
|
||||
tables,
|
||||
functions,
|
||||
dependencies,
|
||||
topic,
|
||||
});
|
||||
}
|
||||
|
||||
return schemaFiles.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
static async getFunctions(): Promise<DatabaseFunction[]> {
|
||||
const schemaFiles = await this.getSchemaFiles();
|
||||
const functions: DatabaseFunction[] = [];
|
||||
|
||||
for (const schemaFile of schemaFiles) {
|
||||
const content = await readFile(schemaFile.path, 'utf8');
|
||||
const fileFunctions = this.extractFunctionsFromContent(
|
||||
content,
|
||||
schemaFile.name,
|
||||
);
|
||||
functions.push(...fileFunctions);
|
||||
}
|
||||
|
||||
return functions.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
static async getFunctionDetails(
|
||||
functionName: string,
|
||||
): Promise<DatabaseFunction> {
|
||||
const functions = await this.getFunctions();
|
||||
const func = functions.find((f) => f.name === functionName);
|
||||
|
||||
if (!func) {
|
||||
throw new Error(`Function "${functionName}" not found`);
|
||||
}
|
||||
|
||||
return func;
|
||||
}
|
||||
|
||||
static async searchFunctions(query: string): Promise<DatabaseFunction[]> {
|
||||
const allFunctions = await this.getFunctions();
|
||||
const searchTerm = query.toLowerCase();
|
||||
|
||||
return allFunctions.filter((func) => {
|
||||
return (
|
||||
func.name.toLowerCase().includes(searchTerm) ||
|
||||
func.description.toLowerCase().includes(searchTerm) ||
|
||||
func.purpose.toLowerCase().includes(searchTerm) ||
|
||||
func.returnType.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
static async getSchemaContent(fileName: string): Promise<string> {
|
||||
const schemasPath = join(
|
||||
process.cwd(),
|
||||
'apps',
|
||||
'web',
|
||||
'supabase',
|
||||
'schemas',
|
||||
);
|
||||
const filePath = join(schemasPath, fileName);
|
||||
|
||||
try {
|
||||
return await readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
throw new Error(`Schema file "${fileName}" not found`);
|
||||
}
|
||||
}
|
||||
|
||||
static async getSchemasByTopic(topic: string): Promise<SchemaFile[]> {
|
||||
const allSchemas = await this.getSchemaFiles();
|
||||
const searchTerm = topic.toLowerCase();
|
||||
|
||||
return allSchemas.filter((schema) => {
|
||||
return (
|
||||
schema.topic.toLowerCase().includes(searchTerm) ||
|
||||
schema.section.toLowerCase().includes(searchTerm) ||
|
||||
schema.description.toLowerCase().includes(searchTerm) ||
|
||||
schema.name.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
static async getSchemaBySection(section: string): Promise<SchemaFile | null> {
|
||||
const allSchemas = await this.getSchemaFiles();
|
||||
return (
|
||||
allSchemas.find(
|
||||
(schema) => schema.section.toLowerCase() === section.toLowerCase(),
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
private static extractFunctionsFromContent(
|
||||
content: string,
|
||||
sourceFile: string,
|
||||
): DatabaseFunction[] {
|
||||
const functions: DatabaseFunction[] = [];
|
||||
|
||||
// Updated regex to capture function definitions with optional "or replace"
|
||||
const functionRegex =
|
||||
/create\s+(?:or\s+replace\s+)?function\s+([a-zA-Z_][a-zA-Z0-9_.]*)\s*\(([^)]*)\)\s*returns?\s+([^;\n]+)(?:\s+language\s+\w+)?(?:\s+security\s+(definer|invoker))?[^$]*?\$\$([^$]*)\$\$/gi;
|
||||
|
||||
let match;
|
||||
while ((match = functionRegex.exec(content)) !== null) {
|
||||
const [, fullName, params, returnType, securityLevel, body] = match;
|
||||
|
||||
if (!fullName || !returnType) continue;
|
||||
|
||||
// Extract schema and function name
|
||||
const nameParts = fullName.split('.');
|
||||
const functionName = nameParts[nameParts.length - 1];
|
||||
const schema = nameParts.length > 1 ? nameParts[0] : 'public';
|
||||
|
||||
// Parse parameters
|
||||
const parameters = this.parseParameters(params || '');
|
||||
|
||||
// Extract description and purpose from comments before function
|
||||
const functionIndex = match.index || 0;
|
||||
const beforeFunction = content.substring(
|
||||
Math.max(0, functionIndex - 500),
|
||||
functionIndex,
|
||||
);
|
||||
const description = this.extractDescription(beforeFunction, body || '');
|
||||
const purpose = this.extractPurpose(description, functionName);
|
||||
|
||||
functions.push({
|
||||
name: functionName,
|
||||
parameters,
|
||||
returnType: returnType.trim(),
|
||||
description,
|
||||
purpose,
|
||||
securityLevel: (securityLevel as 'definer' | 'invoker') || 'invoker',
|
||||
schema,
|
||||
sourceFile,
|
||||
});
|
||||
}
|
||||
|
||||
return functions;
|
||||
}
|
||||
|
||||
private static parseParameters(paramString: string): Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
defaultValue?: string;
|
||||
}> {
|
||||
if (!paramString.trim()) return [];
|
||||
|
||||
const parameters: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
defaultValue?: string;
|
||||
}> = [];
|
||||
|
||||
// Split by comma, but be careful of nested types
|
||||
const params = paramString.split(',');
|
||||
|
||||
for (const param of params) {
|
||||
const cleaned = param.trim();
|
||||
if (!cleaned) continue;
|
||||
|
||||
// Match parameter pattern: name type [default value]
|
||||
const paramMatch = cleaned.match(
|
||||
/^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s+([^=\s]+)(?:\s+default\s+(.+))?\s*$/i,
|
||||
);
|
||||
|
||||
if (paramMatch) {
|
||||
const [, name, type, defaultValue] = paramMatch;
|
||||
if (name && type) {
|
||||
parameters.push({
|
||||
name: name.trim(),
|
||||
type: type.trim(),
|
||||
defaultValue: defaultValue?.trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
private static extractDescription(
|
||||
beforeFunction: string,
|
||||
body: string,
|
||||
): string {
|
||||
// Look for comments before the function
|
||||
const commentMatch = beforeFunction.match(/--\s*(.+?)(?:\n|$)/);
|
||||
if (commentMatch?.[1]) {
|
||||
return commentMatch[1].trim();
|
||||
}
|
||||
|
||||
// Look for comments inside the function body
|
||||
const bodyCommentMatch = body.match(/--\s*(.+?)(?:\n|$)/);
|
||||
if (bodyCommentMatch?.[1]) {
|
||||
return bodyCommentMatch[1].trim();
|
||||
}
|
||||
|
||||
return 'No description available';
|
||||
}
|
||||
|
||||
private static extractPurpose(
|
||||
description: string,
|
||||
functionName: string,
|
||||
): string {
|
||||
// Map function names to purposes
|
||||
const purposeMap: Record<string, string> = {
|
||||
create_nonce:
|
||||
'Create one-time authentication tokens for secure operations',
|
||||
verify_nonce: 'Verify and consume one-time tokens for authentication',
|
||||
is_mfa_compliant:
|
||||
'Check if user has completed multi-factor authentication',
|
||||
team_account_workspace:
|
||||
'Load comprehensive team account data with permissions',
|
||||
has_role_on_account: 'Check if user has access to a specific account',
|
||||
has_permission: 'Verify user permissions for specific account operations',
|
||||
get_user_billing_account: 'Retrieve billing account information for user',
|
||||
create_team_account: 'Create new team account with proper permissions',
|
||||
invite_user_to_account: 'Send invitation to join team account',
|
||||
accept_invitation: 'Process and accept team invitation',
|
||||
transfer_account_ownership: 'Transfer account ownership between users',
|
||||
delete_account: 'Safely delete account and associated data',
|
||||
};
|
||||
|
||||
if (purposeMap[functionName]) {
|
||||
return purposeMap[functionName];
|
||||
}
|
||||
|
||||
// Analyze function name for purpose hints
|
||||
if (functionName.includes('create'))
|
||||
return 'Create database records with validation';
|
||||
if (functionName.includes('delete') || functionName.includes('remove'))
|
||||
return 'Delete records with proper authorization';
|
||||
if (functionName.includes('update') || functionName.includes('modify'))
|
||||
return 'Update existing records with validation';
|
||||
if (functionName.includes('get') || functionName.includes('fetch'))
|
||||
return 'Retrieve data with access control';
|
||||
if (functionName.includes('verify') || functionName.includes('validate'))
|
||||
return 'Validate data or permissions';
|
||||
if (functionName.includes('check') || functionName.includes('is_'))
|
||||
return 'Check conditions or permissions';
|
||||
if (functionName.includes('invite'))
|
||||
return 'Handle user invitations and access';
|
||||
if (functionName.includes('transfer'))
|
||||
return 'Transfer ownership or data between entities';
|
||||
|
||||
return `Custom database function: ${description}`;
|
||||
}
|
||||
|
||||
private static extractTables(content: string): string[] {
|
||||
const tables: string[] = [];
|
||||
const tableRegex =
|
||||
/create\s+table\s+(?:if\s+not\s+exists\s+)?(?:public\.)?([a-zA-Z_][a-zA-Z0-9_]*)/gi;
|
||||
let match;
|
||||
|
||||
while ((match = tableRegex.exec(content)) !== null) {
|
||||
if (match[1]) {
|
||||
tables.push(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(tables)]; // Remove duplicates
|
||||
}
|
||||
|
||||
private static extractFunctionNames(content: string): string[] {
|
||||
const functions: string[] = [];
|
||||
const functionRegex =
|
||||
/create\s+(?:or\s+replace\s+)?function\s+(?:public\.)?([a-zA-Z_][a-zA-Z0-9_]*)/gi;
|
||||
let match;
|
||||
|
||||
while ((match = functionRegex.exec(content)) !== null) {
|
||||
if (match[1]) {
|
||||
functions.push(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(functions)]; // Remove duplicates
|
||||
}
|
||||
|
||||
private static extractDependencies(content: string): string[] {
|
||||
const dependencies: string[] = [];
|
||||
|
||||
// Look for references to other tables
|
||||
const referencesRegex =
|
||||
/references\s+(?:public\.)?([a-zA-Z_][a-zA-Z0-9_]*)/gi;
|
||||
let match;
|
||||
|
||||
while ((match = referencesRegex.exec(content)) !== null) {
|
||||
if (match[1] && match[1] !== 'users') {
|
||||
// Exclude auth.users as it's external
|
||||
dependencies.push(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(dependencies)]; // Remove duplicates
|
||||
}
|
||||
|
||||
private static determineTopic(fileName: string, content: string): string {
|
||||
// Map file names to topics
|
||||
const fileTopicMap: Record<string, string> = {
|
||||
'00-privileges.sql': 'security',
|
||||
'01-enums.sql': 'types',
|
||||
'02-config.sql': 'configuration',
|
||||
'03-accounts.sql': 'accounts',
|
||||
'04-roles.sql': 'permissions',
|
||||
'05-memberships.sql': 'teams',
|
||||
'06-roles-permissions.sql': 'permissions',
|
||||
'07-invitations.sql': 'teams',
|
||||
'08-billing-customers.sql': 'billing',
|
||||
'09-subscriptions.sql': 'billing',
|
||||
'10-orders.sql': 'billing',
|
||||
'11-notifications.sql': 'notifications',
|
||||
'12-one-time-tokens.sql': 'auth',
|
||||
'13-mfa.sql': 'auth',
|
||||
'14-super-admin.sql': 'admin',
|
||||
'15-account-views.sql': 'accounts',
|
||||
'16-storage.sql': 'storage',
|
||||
'17-roles-seed.sql': 'permissions',
|
||||
};
|
||||
|
||||
if (fileTopicMap[fileName]) {
|
||||
return fileTopicMap[fileName];
|
||||
}
|
||||
|
||||
// Analyze content for topic hints
|
||||
const contentLower = content.toLowerCase();
|
||||
if (contentLower.includes('account') && contentLower.includes('team'))
|
||||
return 'accounts';
|
||||
if (
|
||||
contentLower.includes('subscription') ||
|
||||
contentLower.includes('billing')
|
||||
)
|
||||
return 'billing';
|
||||
if (
|
||||
contentLower.includes('auth') ||
|
||||
contentLower.includes('mfa') ||
|
||||
contentLower.includes('token')
|
||||
)
|
||||
return 'auth';
|
||||
if (contentLower.includes('permission') || contentLower.includes('role'))
|
||||
return 'permissions';
|
||||
if (contentLower.includes('notification') || contentLower.includes('email'))
|
||||
return 'notifications';
|
||||
if (contentLower.includes('storage') || contentLower.includes('bucket'))
|
||||
return 'storage';
|
||||
if (contentLower.includes('admin') || contentLower.includes('super'))
|
||||
return 'admin';
|
||||
|
||||
return 'general';
|
||||
}
|
||||
}
|
||||
|
||||
export function registerDatabaseTools(server: McpServer) {
|
||||
createGetSchemaFilesTool(server);
|
||||
createGetSchemaContentTool(server);
|
||||
createGetSchemasByTopicTool(server);
|
||||
createGetSchemaBySectionTool(server);
|
||||
createGetFunctionsTool(server);
|
||||
createGetFunctionDetailsTool(server);
|
||||
createSearchFunctionsTool(server);
|
||||
}
|
||||
|
||||
function createGetSchemaFilesTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'get_schema_files',
|
||||
'🔥 DATABASE SCHEMA FILES (SOURCE OF TRUTH - ALWAYS CURRENT) - Use these over migrations!',
|
||||
async () => {
|
||||
const schemaFiles = await DatabaseTool.getSchemaFiles();
|
||||
|
||||
const filesList = schemaFiles
|
||||
.map((file) => {
|
||||
const tablesInfo =
|
||||
file.tables.length > 0
|
||||
? ` | Tables: ${file.tables.join(', ')}`
|
||||
: '';
|
||||
const functionsInfo =
|
||||
file.functions.length > 0
|
||||
? ` | Functions: ${file.functions.join(', ')}`
|
||||
: '';
|
||||
return `${file.name} (${file.topic}): ${file.section} - ${file.description}${tablesInfo}${functionsInfo}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `🔥 DATABASE SCHEMA FILES (ALWAYS UP TO DATE)\n\nThese files represent the current database state. Use these instead of migrations for current schema understanding.\n\n${filesList}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createGetFunctionsTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'get_database_functions',
|
||||
'Get all database functions with descriptions and usage guidance',
|
||||
async () => {
|
||||
const functions = await DatabaseTool.getFunctions();
|
||||
|
||||
const functionsList = functions
|
||||
.map((func) => {
|
||||
const security =
|
||||
func.securityLevel === 'definer' ? ' [SECURITY DEFINER]' : '';
|
||||
const params = func.parameters
|
||||
.map((p) => {
|
||||
const defaultVal = p.defaultValue ? ` = ${p.defaultValue}` : '';
|
||||
return `${p.name}: ${p.type}${defaultVal}`;
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
return `${func.name}(${params}) <20> ${func.returnType}${security}\n Purpose: ${func.purpose}\n Source: ${func.sourceFile}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Database Functions:\n\n${functionsList}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createGetFunctionDetailsTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'get_function_details',
|
||||
'Get detailed information about a specific database function',
|
||||
{
|
||||
state: z.object({
|
||||
functionName: z.string(),
|
||||
}),
|
||||
},
|
||||
async ({ state }) => {
|
||||
const func = await DatabaseTool.getFunctionDetails(state.functionName);
|
||||
|
||||
const params =
|
||||
func.parameters.length > 0
|
||||
? func.parameters
|
||||
.map((p) => {
|
||||
const defaultVal = p.defaultValue
|
||||
? ` (default: ${p.defaultValue})`
|
||||
: '';
|
||||
return ` - ${p.name}: ${p.type}${defaultVal}`;
|
||||
})
|
||||
.join('\n')
|
||||
: ' No parameters';
|
||||
|
||||
const securityNote =
|
||||
func.securityLevel === 'definer'
|
||||
? '\n<> SECURITY DEFINER: This function runs with elevated privileges and bypasses RLS.'
|
||||
: '\n SECURITY INVOKER: This function inherits caller permissions and respects RLS.';
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Function: ${func.schema}.${func.name}
|
||||
|
||||
Purpose: ${func.purpose}
|
||||
Description: ${func.description}
|
||||
Return Type: ${func.returnType}
|
||||
Security Level: ${func.securityLevel}${securityNote}
|
||||
|
||||
Parameters:
|
||||
${params}
|
||||
|
||||
Source File: ${func.sourceFile}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createSearchFunctionsTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'search_database_functions',
|
||||
'Search database functions by name, description, or purpose',
|
||||
{
|
||||
state: z.object({
|
||||
query: z.string(),
|
||||
}),
|
||||
},
|
||||
async ({ state }) => {
|
||||
const functions = await DatabaseTool.searchFunctions(state.query);
|
||||
|
||||
if (functions.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `No database functions found matching "${state.query}"`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const functionsList = functions
|
||||
.map((func) => {
|
||||
const security = func.securityLevel === 'definer' ? ' [DEFINER]' : '';
|
||||
return `${func.name}${security}: ${func.purpose}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Found ${functions.length} functions matching "${state.query}":\n\n${functionsList}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createGetSchemaContentTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'get_schema_content',
|
||||
'📋 Get raw schema file content (CURRENT DATABASE STATE) - Source of truth for database structure',
|
||||
{
|
||||
state: z.object({
|
||||
fileName: z.string(),
|
||||
}),
|
||||
},
|
||||
async ({ state }) => {
|
||||
const content = await DatabaseTool.getSchemaContent(state.fileName);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `📋 SCHEMA FILE: ${state.fileName} (CURRENT STATE)\n\n${content}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createGetSchemasByTopicTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'get_schemas_by_topic',
|
||||
'🎯 Find schema files by topic (accounts, auth, billing, permissions, etc.) - Fastest way to find relevant schemas',
|
||||
{
|
||||
state: z.object({
|
||||
topic: z.string(),
|
||||
}),
|
||||
},
|
||||
async ({ state }) => {
|
||||
const schemas = await DatabaseTool.getSchemasByTopic(state.topic);
|
||||
|
||||
if (schemas.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `No schema files found for topic "${state.topic}". Available topics: accounts, auth, billing, permissions, teams, notifications, storage, admin, security, types, configuration.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const schemasList = schemas
|
||||
.map((schema) => {
|
||||
const tablesInfo =
|
||||
schema.tables.length > 0
|
||||
? `\n Tables: ${schema.tables.join(', ')}`
|
||||
: '';
|
||||
const functionsInfo =
|
||||
schema.functions.length > 0
|
||||
? `\n Functions: ${schema.functions.join(', ')}`
|
||||
: '';
|
||||
return `${schema.name}: ${schema.description}${tablesInfo}${functionsInfo}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `🎯 SCHEMAS FOR TOPIC: "${state.topic}"\n\n${schemasList}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createGetSchemaBySectionTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'get_schema_by_section',
|
||||
'📂 Get specific schema by section name (Accounts, Permissions, etc.) - Direct access to schema sections',
|
||||
{
|
||||
state: z.object({
|
||||
section: z.string(),
|
||||
}),
|
||||
},
|
||||
async ({ state }) => {
|
||||
const schema = await DatabaseTool.getSchemaBySection(state.section);
|
||||
|
||||
if (!schema) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `No schema found for section "${state.section}". Use get_schema_files to see available sections.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const tablesInfo =
|
||||
schema.tables.length > 0 ? `\nTables: ${schema.tables.join(', ')}` : '';
|
||||
const functionsInfo =
|
||||
schema.functions.length > 0
|
||||
? `\nFunctions: ${schema.functions.join(', ')}`
|
||||
: '';
|
||||
const dependenciesInfo =
|
||||
schema.dependencies.length > 0
|
||||
? `\nDependencies: ${schema.dependencies.join(', ')}`
|
||||
: '';
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `📂 SCHEMA SECTION: ${schema.section}\n\nFile: ${schema.name}\nTopic: ${schema.topic}\nDescription: ${schema.description}${tablesInfo}${functionsInfo}${dependenciesInfo}\n\nLast Modified: ${schema.lastModified.toISOString()}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
122
packages/mcp-server/src/tools/migrations.ts
Normal file
122
packages/mcp-server/src/tools/migrations.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { exec } from 'node:child_process';
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { z } from 'zod';
|
||||
|
||||
export class MigrationsTool {
|
||||
static GetMigrations() {
|
||||
return readdir(
|
||||
join(process.cwd(), 'apps', 'web', 'supabase', 'migrations'),
|
||||
);
|
||||
}
|
||||
|
||||
static getMigrationContent(path: string) {
|
||||
return readFile(
|
||||
join(process.cwd(), 'apps', 'web', 'supabase', 'migrations', path),
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
static CreateMigration(path: string) {
|
||||
return promisify(exec)(`supabase migration new ${path}`);
|
||||
}
|
||||
|
||||
static Diff() {
|
||||
return promisify(exec)(`supabase migration diff`);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerGetMigrationsTools(server: McpServer) {
|
||||
createGetMigrationsTool(server);
|
||||
createGetMigrationContentTool(server);
|
||||
createCreateMigrationTool(server);
|
||||
createDiffMigrationTool(server);
|
||||
}
|
||||
|
||||
function createDiffMigrationTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'diff_migrations',
|
||||
'Compare differences between the declarative schemas and the applied migrations in Supabase',
|
||||
async () => {
|
||||
const { stdout } = await MigrationsTool.Diff();
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: stdout,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createCreateMigrationTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'create_migration',
|
||||
'Create a new Supabase Postgres migration file',
|
||||
{
|
||||
state: z.object({
|
||||
name: z.string(),
|
||||
}),
|
||||
},
|
||||
async ({ state }) => {
|
||||
const { stdout } = await MigrationsTool.CreateMigration(state.name);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: stdout,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createGetMigrationContentTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'get_migration_content',
|
||||
'📜 Get migration file content (HISTORICAL) - For current state use get_schema_content instead',
|
||||
{
|
||||
state: z.object({
|
||||
path: z.string(),
|
||||
}),
|
||||
},
|
||||
async ({ state }) => {
|
||||
const content = await MigrationsTool.getMigrationContent(state.path);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `📜 MIGRATION FILE: ${state.path} (HISTORICAL)\n\nNote: This shows historical changes. For current database state, use get_schema_content instead.\n\n${content}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createGetMigrationsTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'get_migrations',
|
||||
'📜 Get migration files (HISTORICAL CHANGES) - Use schema files for current state instead',
|
||||
async () => {
|
||||
const migrations = await MigrationsTool.GetMigrations();
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `📜 MIGRATION FILES (HISTORICAL CHANGES)\n\nNote: For current database state, use get_schema_files instead. Migrations show historical changes.\n\n${migrations.join('\n')}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
323
packages/mcp-server/src/tools/scripts.ts
Normal file
323
packages/mcp-server/src/tools/scripts.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
interface ScriptInfo {
|
||||
name: string;
|
||||
command: string;
|
||||
category:
|
||||
| 'development'
|
||||
| 'build'
|
||||
| 'testing'
|
||||
| 'linting'
|
||||
| 'database'
|
||||
| 'maintenance'
|
||||
| 'environment';
|
||||
description: string;
|
||||
usage: string;
|
||||
importance: 'critical' | 'high' | 'medium' | 'low';
|
||||
healthcheck?: boolean;
|
||||
}
|
||||
|
||||
export class ScriptsTool {
|
||||
static async getScripts(): Promise<ScriptInfo[]> {
|
||||
const packageJsonPath = join(process.cwd(), 'package.json');
|
||||
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'));
|
||||
|
||||
const scripts: ScriptInfo[] = [];
|
||||
|
||||
for (const [scriptName, command] of Object.entries(packageJson.scripts)) {
|
||||
if (typeof command === 'string') {
|
||||
const scriptInfo = this.getScriptInfo(scriptName, command);
|
||||
scripts.push(scriptInfo);
|
||||
}
|
||||
}
|
||||
|
||||
return scripts.sort((a, b) => {
|
||||
const importanceOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
return importanceOrder[a.importance] - importanceOrder[b.importance];
|
||||
});
|
||||
}
|
||||
|
||||
static async getScriptDetails(scriptName: string): Promise<ScriptInfo> {
|
||||
const packageJsonPath = join(process.cwd(), 'package.json');
|
||||
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'));
|
||||
|
||||
const command = packageJson.scripts[scriptName];
|
||||
if (!command) {
|
||||
throw new Error(`Script "${scriptName}" not found`);
|
||||
}
|
||||
|
||||
return this.getScriptInfo(scriptName, command);
|
||||
}
|
||||
|
||||
private static getScriptInfo(
|
||||
scriptName: string,
|
||||
command: string,
|
||||
): ScriptInfo {
|
||||
const scriptDescriptions: Record<
|
||||
string,
|
||||
Omit<ScriptInfo, 'name' | 'command'>
|
||||
> = {
|
||||
dev: {
|
||||
category: 'development',
|
||||
description:
|
||||
'Start development servers for all applications in parallel with hot reloading',
|
||||
usage: 'Run this to start developing. Opens web app on port 3000.',
|
||||
importance: 'medium',
|
||||
},
|
||||
build: {
|
||||
category: 'build',
|
||||
description:
|
||||
'Build all applications and packages for production deployment',
|
||||
usage:
|
||||
'Use before deploying to production. Ensures all code compiles correctly.',
|
||||
importance: 'medium',
|
||||
},
|
||||
typecheck: {
|
||||
category: 'linting',
|
||||
description:
|
||||
'Run TypeScript compiler to check for type errors across all packages',
|
||||
usage:
|
||||
'CRITICAL: Run after writing code to ensure type safety. Must pass before commits.',
|
||||
importance: 'critical',
|
||||
healthcheck: true,
|
||||
},
|
||||
lint: {
|
||||
category: 'linting',
|
||||
description:
|
||||
'Run ESLint to check code quality and enforce coding standards',
|
||||
usage:
|
||||
'CRITICAL: Run after writing code to ensure code quality. Must pass before commits.',
|
||||
importance: 'medium',
|
||||
healthcheck: true,
|
||||
},
|
||||
'lint:fix': {
|
||||
category: 'linting',
|
||||
description:
|
||||
'Run ESLint with auto-fix to automatically resolve fixable issues',
|
||||
usage:
|
||||
'Use to automatically fix linting issues. Run before manual fixes.',
|
||||
importance: 'high',
|
||||
healthcheck: true,
|
||||
},
|
||||
format: {
|
||||
category: 'linting',
|
||||
description: 'Check code formatting with Prettier across all files',
|
||||
usage: 'Verify code follows consistent formatting standards.',
|
||||
importance: 'high',
|
||||
},
|
||||
'format:fix': {
|
||||
category: 'linting',
|
||||
description:
|
||||
'Auto-format all code with Prettier to ensure consistent styling',
|
||||
usage: 'Use to automatically format code. Run before commits.',
|
||||
importance: 'high',
|
||||
healthcheck: true,
|
||||
},
|
||||
test: {
|
||||
category: 'testing',
|
||||
description: 'Run all test suites across the monorepo',
|
||||
usage: 'Execute to verify functionality. Should pass before commits.',
|
||||
importance: 'high',
|
||||
healthcheck: true,
|
||||
},
|
||||
'supabase:web:start': {
|
||||
category: 'database',
|
||||
description: 'Start local Supabase instance for development',
|
||||
usage: 'Required for local development with database access.',
|
||||
importance: 'critical',
|
||||
},
|
||||
'supabase:web:stop': {
|
||||
category: 'database',
|
||||
description: 'Stop the local Supabase instance',
|
||||
usage: 'Use when done developing to free up resources.',
|
||||
importance: 'medium',
|
||||
},
|
||||
'supabase:web:reset': {
|
||||
category: 'database',
|
||||
description: 'Reset local database to latest schema and seed data',
|
||||
usage: 'Use when database state is corrupted or needs fresh start.',
|
||||
importance: 'high',
|
||||
},
|
||||
'supabase:web:typegen': {
|
||||
category: 'database',
|
||||
description: 'Generate TypeScript types from Supabase database schema',
|
||||
usage: 'Run after database schema changes to update types.',
|
||||
importance: 'high',
|
||||
},
|
||||
'supabase:web:test': {
|
||||
category: 'testing',
|
||||
description: 'Run Supabase-specific tests',
|
||||
usage: 'Test database functions, RLS policies, and migrations.',
|
||||
importance: 'high',
|
||||
},
|
||||
clean: {
|
||||
category: 'maintenance',
|
||||
description: 'Remove all generated files and dependencies',
|
||||
usage:
|
||||
'Use when build artifacts are corrupted. Requires reinstall after.',
|
||||
importance: 'medium',
|
||||
},
|
||||
'clean:workspaces': {
|
||||
category: 'maintenance',
|
||||
description: 'Clean all workspace packages using Turbo',
|
||||
usage: 'Lighter cleanup that preserves node_modules.',
|
||||
importance: 'medium',
|
||||
},
|
||||
'stripe:listen': {
|
||||
category: 'development',
|
||||
description: 'Start Stripe webhook listener for local development',
|
||||
usage: 'Required when testing payment workflows locally.',
|
||||
importance: 'medium',
|
||||
},
|
||||
'env:generate': {
|
||||
category: 'environment',
|
||||
description: 'Generate environment variable templates',
|
||||
usage: 'Creates .env templates for new environments.',
|
||||
importance: 'low',
|
||||
},
|
||||
'env:validate': {
|
||||
category: 'environment',
|
||||
description: 'Validate environment variables against schema',
|
||||
usage: 'Ensures all required environment variables are properly set.',
|
||||
importance: 'medium',
|
||||
},
|
||||
update: {
|
||||
category: 'maintenance',
|
||||
description: 'Update all dependencies across the monorepo',
|
||||
usage: 'Keep dependencies current. Test thoroughly after updating.',
|
||||
importance: 'low',
|
||||
},
|
||||
'syncpack:list': {
|
||||
category: 'maintenance',
|
||||
description: 'List dependency version mismatches across packages',
|
||||
usage: 'Identify inconsistent package versions in monorepo.',
|
||||
importance: 'low',
|
||||
},
|
||||
'syncpack:fix': {
|
||||
category: 'maintenance',
|
||||
description: 'Fix dependency version mismatches across packages',
|
||||
usage: 'Automatically align package versions across workspaces.',
|
||||
importance: 'low',
|
||||
},
|
||||
};
|
||||
|
||||
const scriptInfo = scriptDescriptions[scriptName] || {
|
||||
category: 'maintenance' as const,
|
||||
description: `Custom script: ${scriptName}`,
|
||||
usage: 'See package.json for command details.',
|
||||
importance: 'low' as const,
|
||||
};
|
||||
|
||||
return {
|
||||
name: scriptName,
|
||||
command,
|
||||
...scriptInfo,
|
||||
};
|
||||
}
|
||||
|
||||
static getHealthcheckScripts(): ScriptInfo[] {
|
||||
const allScripts = ['typecheck', 'lint', 'lint:fix', 'format:fix', 'test'];
|
||||
|
||||
return allScripts.map((scriptName) =>
|
||||
this.getScriptInfo(scriptName, `[healthcheck] ${scriptName}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerScriptsTools(server: McpServer) {
|
||||
createGetScriptsTool(server);
|
||||
createGetScriptDetailsTool(server);
|
||||
createGetHealthcheckScriptsTool(server);
|
||||
}
|
||||
|
||||
function createGetScriptsTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'get_scripts',
|
||||
'Get all available npm/pnpm scripts with descriptions and usage guidance',
|
||||
async () => {
|
||||
const scripts = await ScriptsTool.getScripts();
|
||||
|
||||
const scriptsList = scripts
|
||||
.map((script) => {
|
||||
const healthcheck = script.healthcheck ? ' [HEALTHCHECK]' : '';
|
||||
return `${script.name} (${script.category})${healthcheck}: ${script.description}\n Usage: ${script.usage}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Available Scripts (sorted by importance):\n\n${scriptsList}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createGetScriptDetailsTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'get_script_details',
|
||||
'Get detailed information about a specific script',
|
||||
{
|
||||
state: z.object({
|
||||
scriptName: z.string(),
|
||||
}),
|
||||
},
|
||||
async ({ state }) => {
|
||||
const script = await ScriptsTool.getScriptDetails(state.scriptName);
|
||||
|
||||
const healthcheck = script.healthcheck
|
||||
? '\n<> HEALTHCHECK SCRIPT: This script should be run after writing code to ensure quality.'
|
||||
: '';
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Script: ${script.name}
|
||||
Command: ${script.command}
|
||||
Category: ${script.category}
|
||||
Importance: ${script.importance}
|
||||
Description: ${script.description}
|
||||
Usage: ${script.usage}${healthcheck}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createGetHealthcheckScriptsTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'get_healthcheck_scripts',
|
||||
'Get critical scripts that should be run after writing code (typecheck, lint, format, test)',
|
||||
async () => {
|
||||
const scripts = await ScriptsTool.getScripts();
|
||||
const healthcheckScripts = scripts.filter((script) => script.healthcheck);
|
||||
|
||||
const scriptsList = healthcheckScripts
|
||||
.map((script) => `pnpm ${script.name}: ${script.usage}`)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `<<3C> CODE HEALTHCHECK SCRIPTS
|
||||
|
||||
These scripts MUST be run after writing code to ensure quality:
|
||||
|
||||
${scriptsList}
|
||||
|
||||
<EFBFBD> IMPORTANT: Always run these scripts before considering your work complete. They catch type errors, code quality issues, and ensure consistent formatting.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
391
packages/mcp-server/test.ts
Normal file
391
packages/mcp-server/test.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import { ComponentsTool } from './src/tools/components';
|
||||
import { DatabaseTool } from './src/tools/database';
|
||||
import { MigrationsTool } from './src/tools/migrations';
|
||||
import { ScriptsTool } from './src/tools/scripts';
|
||||
|
||||
console.log('=== Testing MigrationsTool ===');
|
||||
console.log(await MigrationsTool.GetMigrations());
|
||||
|
||||
console.log(
|
||||
await MigrationsTool.getMigrationContent('20240319163440_roles-seed.sql'),
|
||||
);
|
||||
|
||||
console.log('\n=== Testing ComponentsTool ===');
|
||||
|
||||
console.log('\n--- Getting all components ---');
|
||||
const components = await ComponentsTool.getComponents();
|
||||
console.log(`Found ${components.length} components:`);
|
||||
components.slice(0, 5).forEach((component) => {
|
||||
console.log(
|
||||
`- ${component.name} (${component.category}): ${component.description}`,
|
||||
);
|
||||
});
|
||||
console.log('...');
|
||||
|
||||
console.log('\n--- Testing component content retrieval ---');
|
||||
try {
|
||||
const buttonContent = await ComponentsTool.getComponentContent('button');
|
||||
console.log('Button component content length:', buttonContent.length);
|
||||
console.log('First 200 characters:', buttonContent.substring(0, 200));
|
||||
} catch (error) {
|
||||
console.error('Error getting button component:', error);
|
||||
}
|
||||
|
||||
console.log('\n--- Testing component filtering by category ---');
|
||||
const shadcnComponents = components.filter((c) => c.category === 'shadcn');
|
||||
const makerkitComponents = components.filter((c) => c.category === 'makerkit');
|
||||
const utilsComponents = components.filter((c) => c.category === 'utils');
|
||||
|
||||
console.log(`Shadcn components: ${shadcnComponents.length}`);
|
||||
console.log(`Makerkit components: ${makerkitComponents.length}`);
|
||||
console.log(`Utils components: ${utilsComponents.length}`);
|
||||
|
||||
console.log('\n--- Sample components by category ---');
|
||||
console.log(
|
||||
'Shadcn:',
|
||||
shadcnComponents
|
||||
.slice(0, 3)
|
||||
.map((c) => c.name)
|
||||
.join(', '),
|
||||
);
|
||||
console.log(
|
||||
'Makerkit:',
|
||||
makerkitComponents
|
||||
.slice(0, 3)
|
||||
.map((c) => c.name)
|
||||
.join(', '),
|
||||
);
|
||||
console.log('Utils:', utilsComponents.map((c) => c.name).join(', '));
|
||||
|
||||
console.log('\n--- Testing error handling ---');
|
||||
try {
|
||||
await ComponentsTool.getComponentContent('non-existent-component');
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'Expected error for non-existent component:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n=== Testing ScriptsTool ===');
|
||||
|
||||
console.log('\n--- Getting all scripts ---');
|
||||
const scripts = await ScriptsTool.getScripts();
|
||||
console.log(`Found ${scripts.length} scripts:`);
|
||||
|
||||
console.log('\n--- Critical and High importance scripts ---');
|
||||
const importantScripts = scripts.filter(
|
||||
(s) => s.importance === 'critical' || s.importance === 'high',
|
||||
);
|
||||
importantScripts.forEach((script) => {
|
||||
const healthcheck = script.healthcheck ? ' [HEALTHCHECK]' : '';
|
||||
console.log(
|
||||
`- ${script.name} (${script.importance})${healthcheck}: ${script.description}`,
|
||||
);
|
||||
});
|
||||
|
||||
console.log('\n--- Healthcheck scripts (code quality) ---');
|
||||
const healthcheckScripts = scripts.filter((s) => s.healthcheck);
|
||||
console.log('Scripts that should be run after writing code:');
|
||||
healthcheckScripts.forEach((script) => {
|
||||
console.log(`- pnpm ${script.name}: ${script.usage}`);
|
||||
});
|
||||
|
||||
console.log('\n--- Scripts by category ---');
|
||||
const categories = [...new Set(scripts.map((s) => s.category))];
|
||||
categories.forEach((category) => {
|
||||
const categoryScripts = scripts.filter((s) => s.category === category);
|
||||
console.log(`${category}: ${categoryScripts.map((s) => s.name).join(', ')}`);
|
||||
});
|
||||
|
||||
console.log('\n--- Testing script details ---');
|
||||
try {
|
||||
const typecheckDetails = await ScriptsTool.getScriptDetails('typecheck');
|
||||
console.log('Typecheck script details:');
|
||||
console.log(` Command: ${typecheckDetails.command}`);
|
||||
console.log(` Importance: ${typecheckDetails.importance}`);
|
||||
console.log(` Healthcheck: ${typecheckDetails.healthcheck}`);
|
||||
console.log(` Usage: ${typecheckDetails.usage}`);
|
||||
} catch (error) {
|
||||
console.error('Error getting typecheck details:', error);
|
||||
}
|
||||
|
||||
console.log('\n--- Testing error handling for scripts ---');
|
||||
try {
|
||||
await ScriptsTool.getScriptDetails('non-existent-script');
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'Expected error for non-existent script:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n=== Testing New ComponentsTool Features ===');
|
||||
|
||||
console.log('\n--- Testing component search ---');
|
||||
const buttonSearchResults = await ComponentsTool.searchComponents('button');
|
||||
console.log(`Search for "button": ${buttonSearchResults.length} results`);
|
||||
buttonSearchResults.forEach((component) => {
|
||||
console.log(` - ${component.name}: ${component.description}`);
|
||||
});
|
||||
|
||||
console.log('\n--- Testing search by category ---');
|
||||
const shadcnSearchResults = await ComponentsTool.searchComponents('shadcn');
|
||||
console.log(
|
||||
`Search for "shadcn": ${shadcnSearchResults.length} results (showing first 3)`,
|
||||
);
|
||||
shadcnSearchResults.slice(0, 3).forEach((component) => {
|
||||
console.log(` - ${component.name}`);
|
||||
});
|
||||
|
||||
console.log('\n--- Testing search by description keyword ---');
|
||||
const formSearchResults = await ComponentsTool.searchComponents('form');
|
||||
console.log(`Search for "form": ${formSearchResults.length} results`);
|
||||
formSearchResults.forEach((component) => {
|
||||
console.log(` - ${component.name}: ${component.description}`);
|
||||
});
|
||||
|
||||
console.log('\n--- Testing component props extraction ---');
|
||||
try {
|
||||
console.log('\n--- Button component props ---');
|
||||
const buttonProps = await ComponentsTool.getComponentProps('button');
|
||||
console.log(`Component: ${buttonProps.componentName}`);
|
||||
console.log(`Interfaces: ${buttonProps.interfaces.join(', ')}`);
|
||||
console.log(`Props (${buttonProps.props.length}):`);
|
||||
buttonProps.props.forEach((prop) => {
|
||||
const optional = prop.optional ? '?' : '';
|
||||
console.log(` - ${prop.name}${optional}: ${prop.type}`);
|
||||
});
|
||||
if (buttonProps.variants) {
|
||||
console.log('Variants:');
|
||||
Object.entries(buttonProps.variants).forEach(([variantName, options]) => {
|
||||
console.log(` - ${variantName}: ${options.join(' | ')}`);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting button props:', error);
|
||||
}
|
||||
|
||||
console.log('\n--- Testing simpler component props ---');
|
||||
try {
|
||||
const ifProps = await ComponentsTool.getComponentProps('if');
|
||||
console.log(`Component: ${ifProps.componentName}`);
|
||||
console.log(`Interfaces: ${ifProps.interfaces.join(', ')}`);
|
||||
console.log(`Props count: ${ifProps.props.length}`);
|
||||
if (ifProps.props.length > 0) {
|
||||
ifProps.props.forEach((prop) => {
|
||||
const optional = prop.optional ? '?' : '';
|
||||
console.log(` - ${prop.name}${optional}: ${prop.type}`);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting if component props:', error);
|
||||
}
|
||||
|
||||
console.log('\n--- Testing search with no results ---');
|
||||
const noResults = await ComponentsTool.searchComponents('xyz123nonexistent');
|
||||
console.log(`Search for non-existent: ${noResults.length} results`);
|
||||
|
||||
console.log('\n--- Testing props extraction error handling ---');
|
||||
try {
|
||||
await ComponentsTool.getComponentProps('non-existent-component');
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'Expected error for non-existent component props:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n=== Testing DatabaseTool ===');
|
||||
|
||||
console.log('\n--- Getting schema files ---');
|
||||
const schemaFiles = await DatabaseTool.getSchemaFiles();
|
||||
console.log(`Found ${schemaFiles.length} schema files:`);
|
||||
schemaFiles.slice(0, 5).forEach((file) => {
|
||||
console.log(` - ${file.name}: ${file.section}`);
|
||||
});
|
||||
|
||||
console.log('\n--- Getting database functions ---');
|
||||
const dbFunctions = await DatabaseTool.getFunctions();
|
||||
console.log(`Found ${dbFunctions.length} database functions:`);
|
||||
dbFunctions.forEach((func) => {
|
||||
const security = func.securityLevel === 'definer' ? ' [DEFINER]' : '';
|
||||
console.log(` - ${func.name}${security}: ${func.purpose}`);
|
||||
});
|
||||
|
||||
console.log('\n--- Testing function search ---');
|
||||
const authFunctions = await DatabaseTool.searchFunctions('auth');
|
||||
console.log(`Functions related to "auth": ${authFunctions.length}`);
|
||||
authFunctions.forEach((func) => {
|
||||
console.log(` - ${func.name}: ${func.purpose}`);
|
||||
});
|
||||
|
||||
console.log('\n--- Testing function search by security ---');
|
||||
const definerFunctions = await DatabaseTool.searchFunctions('definer');
|
||||
console.log(`Functions with security definer: ${definerFunctions.length}`);
|
||||
definerFunctions.forEach((func) => {
|
||||
console.log(` - ${func.name}: ${func.purpose}`);
|
||||
});
|
||||
|
||||
console.log('\n--- Testing function details ---');
|
||||
if (dbFunctions.length > 0) {
|
||||
try {
|
||||
const firstFunction = dbFunctions[0];
|
||||
if (firstFunction) {
|
||||
const functionDetails = await DatabaseTool.getFunctionDetails(
|
||||
firstFunction.name,
|
||||
);
|
||||
console.log(`Details for ${functionDetails.name}:`);
|
||||
console.log(` Purpose: ${functionDetails.purpose}`);
|
||||
console.log(` Return Type: ${functionDetails.returnType}`);
|
||||
console.log(` Security: ${functionDetails.securityLevel}`);
|
||||
console.log(` Parameters: ${functionDetails.parameters.length}`);
|
||||
functionDetails.parameters.forEach((param) => {
|
||||
const defaultVal = param.defaultValue
|
||||
? ` (default: ${param.defaultValue})`
|
||||
: '';
|
||||
console.log(` - ${param.name}: ${param.type}${defaultVal}`);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting function details:', error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n--- Testing function search with no results ---');
|
||||
const noFunctionResults =
|
||||
await DatabaseTool.searchFunctions('xyz123nonexistent');
|
||||
console.log(
|
||||
`Search for non-existent function: ${noFunctionResults.length} results`,
|
||||
);
|
||||
|
||||
console.log('\n--- Testing function details error handling ---');
|
||||
try {
|
||||
await DatabaseTool.getFunctionDetails('non-existent-function');
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'Expected error for non-existent function:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n=== Testing Enhanced DatabaseTool Features ===');
|
||||
|
||||
console.log('\n--- Testing direct schema content access ---');
|
||||
try {
|
||||
const accountsSchemaContent =
|
||||
await DatabaseTool.getSchemaContent('03-accounts.sql');
|
||||
console.log('Accounts schema content length:', accountsSchemaContent.length);
|
||||
console.log('First 200 characters:', accountsSchemaContent.substring(0, 200));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Error getting accounts schema content:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n--- Testing schema search by topic ---');
|
||||
const authSchemas = await DatabaseTool.getSchemasByTopic('auth');
|
||||
console.log(`Schemas related to "auth": ${authSchemas.length}`);
|
||||
authSchemas.forEach((schema) => {
|
||||
console.log(` - ${schema.name} (${schema.topic}): ${schema.section}`);
|
||||
if (schema.functions.length > 0) {
|
||||
console.log(` Functions: ${schema.functions.join(', ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n--- Testing schema search by topic - billing ---');
|
||||
const billingSchemas = await DatabaseTool.getSchemasByTopic('billing');
|
||||
console.log(`Schemas related to "billing": ${billingSchemas.length}`);
|
||||
billingSchemas.forEach((schema) => {
|
||||
console.log(` - ${schema.name}: ${schema.description}`);
|
||||
if (schema.tables.length > 0) {
|
||||
console.log(` Tables: ${schema.tables.join(', ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n--- Testing schema search by topic - accounts ---');
|
||||
const accountSchemas = await DatabaseTool.getSchemasByTopic('accounts');
|
||||
console.log(`Schemas related to "accounts": ${accountSchemas.length}`);
|
||||
accountSchemas.forEach((schema) => {
|
||||
console.log(` - ${schema.name}: ${schema.description}`);
|
||||
if (schema.dependencies.length > 0) {
|
||||
console.log(` Dependencies: ${schema.dependencies.join(', ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n--- Testing schema by section lookup ---');
|
||||
try {
|
||||
const accountsSection = await DatabaseTool.getSchemaBySection('Accounts');
|
||||
if (accountsSection) {
|
||||
console.log(`Found section: ${accountsSection.section}`);
|
||||
console.log(`File: ${accountsSection.name}`);
|
||||
console.log(`Topic: ${accountsSection.topic}`);
|
||||
console.log(`Tables: ${accountsSection.tables.join(', ')}`);
|
||||
console.log(`Last modified: ${accountsSection.lastModified.toISOString()}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting accounts section:', error);
|
||||
}
|
||||
|
||||
console.log('\n--- Testing enhanced schema metadata ---');
|
||||
const enhancedSchemas = await DatabaseTool.getSchemaFiles();
|
||||
console.log(`Total schemas with metadata: ${enhancedSchemas.length}`);
|
||||
|
||||
// Show schemas with the most tables
|
||||
const schemasWithTables = enhancedSchemas.filter((s) => s.tables.length > 0);
|
||||
console.log(`Schemas with tables: ${schemasWithTables.length}`);
|
||||
schemasWithTables.slice(0, 3).forEach((schema) => {
|
||||
console.log(
|
||||
` - ${schema.name}: ${schema.tables.length} tables (${schema.tables.join(', ')})`,
|
||||
);
|
||||
});
|
||||
|
||||
// Show schemas with functions
|
||||
const schemasWithFunctions = enhancedSchemas.filter(
|
||||
(s) => s.functions.length > 0,
|
||||
);
|
||||
console.log(`Schemas with functions: ${schemasWithFunctions.length}`);
|
||||
schemasWithFunctions.slice(0, 3).forEach((schema) => {
|
||||
console.log(
|
||||
` - ${schema.name}: ${schema.functions.length} functions (${schema.functions.join(', ')})`,
|
||||
);
|
||||
});
|
||||
|
||||
// Show topic distribution
|
||||
const topicCounts = enhancedSchemas.reduce(
|
||||
(acc, schema) => {
|
||||
acc[schema.topic] = (acc[schema.topic] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
console.log('\n--- Topic distribution ---');
|
||||
Object.entries(topicCounts).forEach(([topic, count]) => {
|
||||
console.log(` - ${topic}: ${count} files`);
|
||||
});
|
||||
|
||||
console.log('\n--- Testing error handling for enhanced features ---');
|
||||
try {
|
||||
await DatabaseTool.getSchemaContent('non-existent-schema.sql');
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'Expected error for non-existent schema:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const nonExistentSection =
|
||||
await DatabaseTool.getSchemaBySection('NonExistentSection');
|
||||
console.log('Non-existent section result:', nonExistentSection);
|
||||
} catch (error) {
|
||||
console.error('Unexpected error for non-existent section:', error);
|
||||
}
|
||||
|
||||
const emptyTopicResults =
|
||||
await DatabaseTool.getSchemasByTopic('xyz123nonexistent');
|
||||
console.log(
|
||||
`Search for non-existent topic: ${emptyTopicResults.length} results`,
|
||||
);
|
||||
14
packages/mcp-server/tsconfig.json
Normal file
14
packages/mcp-server/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
|
||||
"outDir": "./build",
|
||||
"noEmit": false,
|
||||
"strict": false,
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"files": ["src/index.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
"@kit/sentry": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react": "19.1.13",
|
||||
"react": "19.1.1",
|
||||
"zod": "^3.25.74"
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react": "19.1.13",
|
||||
"react": "19.1.1"
|
||||
},
|
||||
"typesVersions": {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"./config/server": "./src/sentry.client.server.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/nextjs": "^10.10.0",
|
||||
"@sentry/nextjs": "^10.11.0",
|
||||
"import-in-the-middle": "1.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -24,7 +24,7 @@
|
||||
"@kit/monitoring-core": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react": "19.1.13",
|
||||
"react": "19.1.1"
|
||||
},
|
||||
"typesVersions": {
|
||||
|
||||
435
packages/next/AGENTS.md
Normal file
435
packages/next/AGENTS.md
Normal file
@@ -0,0 +1,435 @@
|
||||
# Next.js Utilities Instructions
|
||||
|
||||
This file contains instructions for working with Next.js utilities including server actions and route handlers.
|
||||
|
||||
## Server Actions Implementation
|
||||
|
||||
Always use `enhanceAction` from `@packages/next/src/actions/index.ts`:
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Define your schema
|
||||
const CreateNoteSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
content: z.string().min(1, 'Content is required'),
|
||||
accountId: z.string().uuid('Invalid account ID'),
|
||||
});
|
||||
|
||||
export const createNoteAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
// data is automatically validated against the schema
|
||||
// user is automatically authenticated if auth: true
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: note, error } = await client
|
||||
.from('notes')
|
||||
.insert({
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
account_id: data.accountId,
|
||||
user_id: user.id,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { success: true, note };
|
||||
},
|
||||
{
|
||||
auth: true, // Require authentication
|
||||
schema: CreateNoteSchema, // Validate input with Zod
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Server Action Examples
|
||||
|
||||
- Team billing: `@apps/web/app/home/[account]/billing/_lib/server/server-actions.ts`
|
||||
- Personal settings: `@apps/web/app/home/(user)/settings/_lib/server/server-actions.ts`
|
||||
|
||||
### Server Action Options
|
||||
|
||||
```typescript
|
||||
export const myAction = enhanceAction(
|
||||
async function (data, user, requestData) {
|
||||
// data: validated input data
|
||||
// user: authenticated user (if auth: true)
|
||||
// requestData: additional request information
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
auth: true, // Require authentication (default: false)
|
||||
schema: MySchema, // Zod schema for validation (optional)
|
||||
// Additional options available
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Route Handlers (API Routes)
|
||||
|
||||
Use `enhanceRouteHandler` from `@packages/next/src/routes/index.ts`:
|
||||
|
||||
```typescript
|
||||
import { enhanceRouteHandler } from '@kit/next/routes';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Define your schema
|
||||
const CreateItemSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export const POST = enhanceRouteHandler(
|
||||
async function ({ body, user, request }) {
|
||||
// body is validated against schema
|
||||
// user is available if auth: true
|
||||
// request is the original NextRequest
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data, error } = await client
|
||||
.from('items')
|
||||
.insert({
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
user_id: user.id,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create item' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data });
|
||||
},
|
||||
{
|
||||
auth: true, // Require authentication
|
||||
schema: CreateItemSchema, // Validate request body
|
||||
},
|
||||
);
|
||||
|
||||
export const GET = enhanceRouteHandler(
|
||||
async function ({ user, request }) {
|
||||
const url = new URL(request.url);
|
||||
const limit = url.searchParams.get('limit') || '10';
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data, error } = await client
|
||||
.from('items')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.limit(parseInt(limit));
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch items' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ data });
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
// No schema needed for GET requests
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Route Handler Options
|
||||
|
||||
```typescript
|
||||
export const POST = enhanceRouteHandler(
|
||||
async function ({ body, user, request }) {
|
||||
// Handler function
|
||||
return NextResponse.json({ success: true });
|
||||
},
|
||||
{
|
||||
auth: true, // Require authentication (default: false)
|
||||
schema: MySchema, // Zod schema for body validation (optional)
|
||||
// Additional options available
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Error Handling Patterns
|
||||
|
||||
### Server Actions with Error Handling
|
||||
|
||||
```typescript
|
||||
export const createNoteAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
const logger = await getLogger();
|
||||
const ctx = { name: 'create-note', userId: user.id };
|
||||
|
||||
try {
|
||||
logger.info(ctx, 'Creating note');
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: note, error } = await client
|
||||
.from('notes')
|
||||
.insert({
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
user_id: user.id,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
logger.error({ ...ctx, error }, 'Failed to create note');
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info({ ...ctx, noteId: note.id }, 'Note created successfully');
|
||||
|
||||
return { success: true, note };
|
||||
} catch (error) {
|
||||
logger.error({ ...ctx, error }, 'Create note action failed');
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: CreateNoteSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Route Handler with Error Handling
|
||||
|
||||
```typescript
|
||||
export const POST = enhanceRouteHandler(
|
||||
async function ({ body, user }) {
|
||||
const logger = await getLogger();
|
||||
const ctx = { name: 'api-create-item', userId: user.id };
|
||||
|
||||
try {
|
||||
logger.info(ctx, 'Processing API request');
|
||||
|
||||
// Process request
|
||||
const result = await processRequest(body, user);
|
||||
|
||||
logger.info({ ...ctx, result }, 'API request successful');
|
||||
|
||||
return NextResponse.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
logger.error({ ...ctx, error }, 'API request failed');
|
||||
|
||||
if (error.message.includes('validation')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input data' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: CreateItemSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Client-Side Integration
|
||||
|
||||
### Using Server Actions in Components
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useTransition } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
import { createNoteAction } from './actions';
|
||||
import { CreateNoteSchema } from './schemas';
|
||||
|
||||
function CreateNoteForm() {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(CreateNoteSchema),
|
||||
defaultValues: {
|
||||
title: '',
|
||||
content: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await createNoteAction(data);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Note created successfully!');
|
||||
form.reset();
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to create note');
|
||||
console.error('Create note error:', error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
{/* Form fields */}
|
||||
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? 'Creating...' : 'Create Note'}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Using Route Handlers with Fetch
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
async function createItem(data: CreateItemInput) {
|
||||
const response = await fetch('/api/items', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Failed to create item');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Usage in component
|
||||
const handleCreateItem = async (data) => {
|
||||
try {
|
||||
const result = await createItem(data);
|
||||
toast.success('Item created successfully!');
|
||||
return result;
|
||||
} catch (error) {
|
||||
toast.error('Failed to create item');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### Input Validation
|
||||
|
||||
Always use Zod schemas for input validation:
|
||||
|
||||
```typescript
|
||||
// Define strict schemas
|
||||
const UpdateUserSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
email: z.string().email(),
|
||||
age: z.number().int().min(18).max(120),
|
||||
});
|
||||
|
||||
// Server action with validation
|
||||
export const updateUserAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
// data is guaranteed to match the schema
|
||||
// Additional business logic validation can go here
|
||||
|
||||
if (data.email !== user.email) {
|
||||
// Check if email change is allowed
|
||||
const canChangeEmail = await checkEmailChangePermission(user);
|
||||
if (!canChangeEmail) {
|
||||
throw new Error('Email change not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
// Update user
|
||||
return await updateUser(user.id, data);
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: UpdateUserSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Authorization Checks
|
||||
|
||||
```typescript
|
||||
export const deleteAccountAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
// Verify user owns the account
|
||||
const { data: account, error } = await client
|
||||
.from('accounts')
|
||||
.select('id, primary_owner_user_id')
|
||||
.eq('id', data.accountId)
|
||||
.single();
|
||||
|
||||
if (error || !account) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
if (account.primary_owner_user_id !== user.id) {
|
||||
throw new Error('Only account owners can delete accounts');
|
||||
}
|
||||
|
||||
// Additional checks
|
||||
const hasActiveSubscription = await client
|
||||
.rpc('has_active_subscription', { account_id: data.accountId });
|
||||
|
||||
if (hasActiveSubscription) {
|
||||
throw new Error('Cannot delete account with active subscription');
|
||||
}
|
||||
|
||||
// Proceed with deletion
|
||||
await deleteAccount(data.accountId);
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: DeleteAccountSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Middleware Integration
|
||||
|
||||
The `enhanceAction` and `enhanceRouteHandler` utilities integrate with the application middleware for:
|
||||
|
||||
- CSRF protection
|
||||
- Authentication verification
|
||||
- Request logging
|
||||
- Error handling
|
||||
- Input validation
|
||||
|
||||
This ensures consistent security and monitoring across all server actions and API routes.
|
||||
435
packages/next/CLAUDE.md
Normal file
435
packages/next/CLAUDE.md
Normal file
@@ -0,0 +1,435 @@
|
||||
# Next.js Utilities Instructions
|
||||
|
||||
This file contains instructions for working with Next.js utilities including server actions and route handlers.
|
||||
|
||||
## Server Actions Implementation
|
||||
|
||||
Always use `enhanceAction` from `@packages/next/src/actions/index.ts`:
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Define your schema
|
||||
const CreateNoteSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
content: z.string().min(1, 'Content is required'),
|
||||
accountId: z.string().uuid('Invalid account ID'),
|
||||
});
|
||||
|
||||
export const createNoteAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
// data is automatically validated against the schema
|
||||
// user is automatically authenticated if auth: true
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: note, error } = await client
|
||||
.from('notes')
|
||||
.insert({
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
account_id: data.accountId,
|
||||
user_id: user.id,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { success: true, note };
|
||||
},
|
||||
{
|
||||
auth: true, // Require authentication
|
||||
schema: CreateNoteSchema, // Validate input with Zod
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Server Action Examples
|
||||
|
||||
- Team billing: `@apps/web/app/home/[account]/billing/_lib/server/server-actions.ts`
|
||||
- Personal settings: `@apps/web/app/home/(user)/settings/_lib/server/server-actions.ts`
|
||||
|
||||
### Server Action Options
|
||||
|
||||
```typescript
|
||||
export const myAction = enhanceAction(
|
||||
async function (data, user, requestData) {
|
||||
// data: validated input data
|
||||
// user: authenticated user (if auth: true)
|
||||
// requestData: additional request information
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
auth: true, // Require authentication (default: false)
|
||||
schema: MySchema, // Zod schema for validation (optional)
|
||||
// Additional options available
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Route Handlers (API Routes)
|
||||
|
||||
Use `enhanceRouteHandler` from `@packages/next/src/routes/index.ts`:
|
||||
|
||||
```typescript
|
||||
import { enhanceRouteHandler } from '@kit/next/routes';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Define your schema
|
||||
const CreateItemSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export const POST = enhanceRouteHandler(
|
||||
async function ({ body, user, request }) {
|
||||
// body is validated against schema
|
||||
// user is available if auth: true
|
||||
// request is the original NextRequest
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data, error } = await client
|
||||
.from('items')
|
||||
.insert({
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
user_id: user.id,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create item' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data });
|
||||
},
|
||||
{
|
||||
auth: true, // Require authentication
|
||||
schema: CreateItemSchema, // Validate request body
|
||||
},
|
||||
);
|
||||
|
||||
export const GET = enhanceRouteHandler(
|
||||
async function ({ user, request }) {
|
||||
const url = new URL(request.url);
|
||||
const limit = url.searchParams.get('limit') || '10';
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data, error } = await client
|
||||
.from('items')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.limit(parseInt(limit));
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch items' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ data });
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
// No schema needed for GET requests
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Route Handler Options
|
||||
|
||||
```typescript
|
||||
export const POST = enhanceRouteHandler(
|
||||
async function ({ body, user, request }) {
|
||||
// Handler function
|
||||
return NextResponse.json({ success: true });
|
||||
},
|
||||
{
|
||||
auth: true, // Require authentication (default: false)
|
||||
schema: MySchema, // Zod schema for body validation (optional)
|
||||
// Additional options available
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Error Handling Patterns
|
||||
|
||||
### Server Actions with Error Handling
|
||||
|
||||
```typescript
|
||||
export const createNoteAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
const logger = await getLogger();
|
||||
const ctx = { name: 'create-note', userId: user.id };
|
||||
|
||||
try {
|
||||
logger.info(ctx, 'Creating note');
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: note, error } = await client
|
||||
.from('notes')
|
||||
.insert({
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
user_id: user.id,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
logger.error({ ...ctx, error }, 'Failed to create note');
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info({ ...ctx, noteId: note.id }, 'Note created successfully');
|
||||
|
||||
return { success: true, note };
|
||||
} catch (error) {
|
||||
logger.error({ ...ctx, error }, 'Create note action failed');
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: CreateNoteSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Route Handler with Error Handling
|
||||
|
||||
```typescript
|
||||
export const POST = enhanceRouteHandler(
|
||||
async function ({ body, user }) {
|
||||
const logger = await getLogger();
|
||||
const ctx = { name: 'api-create-item', userId: user.id };
|
||||
|
||||
try {
|
||||
logger.info(ctx, 'Processing API request');
|
||||
|
||||
// Process request
|
||||
const result = await processRequest(body, user);
|
||||
|
||||
logger.info({ ...ctx, result }, 'API request successful');
|
||||
|
||||
return NextResponse.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
logger.error({ ...ctx, error }, 'API request failed');
|
||||
|
||||
if (error.message.includes('validation')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input data' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: CreateItemSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Client-Side Integration
|
||||
|
||||
### Using Server Actions in Components
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useTransition } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
import { createNoteAction } from './actions';
|
||||
import { CreateNoteSchema } from './schemas';
|
||||
|
||||
function CreateNoteForm() {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(CreateNoteSchema),
|
||||
defaultValues: {
|
||||
title: '',
|
||||
content: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await createNoteAction(data);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Note created successfully!');
|
||||
form.reset();
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to create note');
|
||||
console.error('Create note error:', error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
{/* Form fields */}
|
||||
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? 'Creating...' : 'Create Note'}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Using Route Handlers with Fetch
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
async function createItem(data: CreateItemInput) {
|
||||
const response = await fetch('/api/items', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Failed to create item');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Usage in component
|
||||
const handleCreateItem = async (data) => {
|
||||
try {
|
||||
const result = await createItem(data);
|
||||
toast.success('Item created successfully!');
|
||||
return result;
|
||||
} catch (error) {
|
||||
toast.error('Failed to create item');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### Input Validation
|
||||
|
||||
Always use Zod schemas for input validation:
|
||||
|
||||
```typescript
|
||||
// Define strict schemas
|
||||
const UpdateUserSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
email: z.string().email(),
|
||||
age: z.number().int().min(18).max(120),
|
||||
});
|
||||
|
||||
// Server action with validation
|
||||
export const updateUserAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
// data is guaranteed to match the schema
|
||||
// Additional business logic validation can go here
|
||||
|
||||
if (data.email !== user.email) {
|
||||
// Check if email change is allowed
|
||||
const canChangeEmail = await checkEmailChangePermission(user);
|
||||
if (!canChangeEmail) {
|
||||
throw new Error('Email change not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
// Update user
|
||||
return await updateUser(user.id, data);
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: UpdateUserSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Authorization Checks
|
||||
|
||||
```typescript
|
||||
export const deleteAccountAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
// Verify user owns the account
|
||||
const { data: account, error } = await client
|
||||
.from('accounts')
|
||||
.select('id, primary_owner_user_id')
|
||||
.eq('id', data.accountId)
|
||||
.single();
|
||||
|
||||
if (error || !account) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
if (account.primary_owner_user_id !== user.id) {
|
||||
throw new Error('Only account owners can delete accounts');
|
||||
}
|
||||
|
||||
// Additional checks
|
||||
const hasActiveSubscription = await client
|
||||
.rpc('has_active_subscription', { account_id: data.accountId });
|
||||
|
||||
if (hasActiveSubscription) {
|
||||
throw new Error('Cannot delete account with active subscription');
|
||||
}
|
||||
|
||||
// Proceed with deletion
|
||||
await deleteAccount(data.accountId);
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: DeleteAccountSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Middleware Integration
|
||||
|
||||
The `enhanceAction` and `enhanceRouteHandler` utilities integrate with the application middleware for:
|
||||
|
||||
- CSRF protection
|
||||
- Authentication verification
|
||||
- Request logging
|
||||
- Error handling
|
||||
- Input validation
|
||||
|
||||
This ensures consistent security and monitoring across all server actions and API routes.
|
||||
@@ -20,8 +20,8 @@
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@supabase/supabase-js": "2.57.2",
|
||||
"next": "15.5.2",
|
||||
"@supabase/supabase-js": "2.57.4",
|
||||
"next": "15.5.3",
|
||||
"zod": "^3.25.74"
|
||||
},
|
||||
"typesVersions": {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"./components": "./src/components/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@kit/email-templates": "workspace:*",
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/mailers": "workspace:*",
|
||||
@@ -25,8 +25,8 @@
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@supabase/supabase-js": "2.57.2",
|
||||
"@types/react": "19.1.12",
|
||||
"@supabase/supabase-js": "2.57.4",
|
||||
"@types/react": "19.1.13",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1",
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@types/react": "19.1.12"
|
||||
"@types/react": "19.1.13"
|
||||
},
|
||||
"dependencies": {
|
||||
"pino": "^9.9.4"
|
||||
"pino": "^9.9.5"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
|
||||
312
packages/supabase/AGENTS.md
Normal file
312
packages/supabase/AGENTS.md
Normal file
@@ -0,0 +1,312 @@
|
||||
# Database & Authentication Instructions
|
||||
|
||||
This file contains instructions for working with Supabase, database security, and authentication.
|
||||
|
||||
## Database Security Guidelines ⚠️
|
||||
|
||||
**Critical Security Guidelines - Read Carefully!**
|
||||
|
||||
### Database Security Fundamentals
|
||||
|
||||
- **Always enable RLS** on new tables unless explicitly instructed otherwise
|
||||
- **NEVER use SECURITY DEFINER functions** without explicit access controls - they bypass RLS entirely
|
||||
- **Always use security_invoker=true for views** to maintain proper access control
|
||||
- **Storage buckets MUST validate access** using account_id in the path structure. See `apps/web/supabase/schemas/16-storage.sql` for proper implementation.
|
||||
- **Use locks if required**: Database locks prevent race conditions and timing attacks in concurrent operations. Make sure to take these into account for all database operations.
|
||||
|
||||
### Security Definer Function - Dangerous Pattern ❌
|
||||
|
||||
```sql
|
||||
-- NEVER DO THIS - Allows any authenticated user to call function
|
||||
CREATE OR REPLACE FUNCTION public.dangerous_function()
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER AS $
|
||||
BEGIN
|
||||
-- This bypasses all RLS policies!
|
||||
DELETE FROM sensitive_table; -- Anyone can call this!
|
||||
END;
|
||||
$;
|
||||
GRANT EXECUTE ON FUNCTION public.dangerous_function() TO authenticated;
|
||||
```
|
||||
|
||||
### Security Definer Function - Safe Pattern ✅
|
||||
|
||||
```sql
|
||||
-- ONLY use SECURITY DEFINER with explicit access validation
|
||||
CREATE OR REPLACE FUNCTION public.safe_admin_function(target_account_id uuid)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = '' AS $
|
||||
BEGIN
|
||||
-- MUST validate caller has permission FIRST
|
||||
IF NOT public.is_account_owner(target_account_id) THEN
|
||||
RAISE EXCEPTION 'Access denied: insufficient permissions';
|
||||
END IF;
|
||||
|
||||
-- Now safe to proceed with elevated privileges
|
||||
-- Your admin operation here
|
||||
END;
|
||||
$;
|
||||
```
|
||||
|
||||
Only grant critical functions to `service_role`:
|
||||
|
||||
```sql
|
||||
grant execute on public.dangerous_function to service_role;
|
||||
```
|
||||
|
||||
## Existing Helper Functions - Use These! 📚
|
||||
|
||||
**DO NOT recreate these functions - they already exist:**
|
||||
|
||||
```sql
|
||||
-- Account Access Control
|
||||
public.has_role_on_account(account_id, role?) -- Check team membership
|
||||
public.has_permission(user_id, account_id, permission) -- Check permissions
|
||||
public.is_account_owner(account_id) -- Verify ownership
|
||||
public.has_active_subscription(account_id) -- Subscription status
|
||||
public.is_team_member(account_id, user_id) -- Direct membership check
|
||||
public.can_action_account_member(target_account_id, target_user_id) -- Member action rights
|
||||
|
||||
-- Administrative Functions
|
||||
public.is_super_admin() -- Super admin check
|
||||
public.is_aal2() -- MFA verification
|
||||
public.is_mfa_compliant() -- MFA compliance
|
||||
|
||||
-- Configuration
|
||||
public.is_set(field_name) -- Feature flag checks
|
||||
```
|
||||
|
||||
Always check `apps/web/supabase/schemas/` before creating new functions!
|
||||
|
||||
## RLS Policy Best Practices ✅
|
||||
|
||||
```sql
|
||||
-- Proper RLS using existing helper functions
|
||||
CREATE POLICY "notes_read" ON public.notes FOR SELECT
|
||||
TO authenticated USING (
|
||||
account_id = (select auth.uid()) OR
|
||||
public.has_role_on_account(account_id)
|
||||
);
|
||||
|
||||
-- For operations requiring specific permissions
|
||||
CREATE POLICY "notes_manage" ON public.notes FOR ALL
|
||||
TO authenticated USING (
|
||||
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
|
||||
);
|
||||
```
|
||||
|
||||
## Schema Management Workflow
|
||||
|
||||
1. Create schemas in `apps/web/supabase/schemas/` as `<number>-<name>.sql`
|
||||
2. After changes: `pnpm supabase:web:stop`
|
||||
3. Run: `pnpm --filter web run supabase:db:diff -f <filename>`
|
||||
4. Restart: `pnpm supabase:web:start` and `pnpm supabase:web:reset`
|
||||
5. Generate types: `pnpm supabase:web:typegen`
|
||||
|
||||
- **Never modify database.types.ts**: Instead, use the Supabase CLI using our package.json scripts to re-generate the types after resetting the DB
|
||||
|
||||
### Key Schema Files
|
||||
|
||||
- Accounts: `apps/web/supabase/schemas/03-accounts.sql`
|
||||
- Memberships: `apps/web/supabase/schemas/05-memberships.sql`
|
||||
- Permissions: `apps/web/supabase/schemas/06-roles-permissions.sql`
|
||||
|
||||
## Type Generation
|
||||
|
||||
```typescript
|
||||
import { Tables } from '@kit/supabase/database';
|
||||
|
||||
type Account = Tables<'accounts'>;
|
||||
```
|
||||
|
||||
Always prefer inferring types from generated Database types.
|
||||
|
||||
## Client Usage Patterns
|
||||
|
||||
### Server Components (Preferred)
|
||||
|
||||
```typescript
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
async function NotesPage() {
|
||||
const client = getSupabaseServerClient();
|
||||
const { data, error } = await client.from('notes').select('*');
|
||||
|
||||
if (error) return <ErrorMessage error={error} />;
|
||||
|
||||
return <NotesList notes={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
### Client Components
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
|
||||
function InteractiveNotes() {
|
||||
const supabase = useSupabase();
|
||||
// Use with React Query for optimal data fetching
|
||||
}
|
||||
```
|
||||
|
||||
### Admin Client (Use with Extreme Caution) ⚠️
|
||||
|
||||
```typescript
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
|
||||
async function adminFunction() {
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
// CRITICAL: Manual authorization required - bypasses RLS!
|
||||
const currentUser = await getCurrentUser();
|
||||
|
||||
if (!(await isSuperAdmin(currentUser))) {
|
||||
throw new Error('Unauthorized: Admin access required');
|
||||
}
|
||||
|
||||
// Now safe to proceed with admin privileges
|
||||
const { data } = await adminClient.from('table').select('*');
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication Patterns
|
||||
|
||||
### Multi-Factor Authentication
|
||||
|
||||
```typescript
|
||||
import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa';
|
||||
|
||||
const requiresMultiFactorAuthentication =
|
||||
await checkRequiresMultiFactorAuthentication(supabase);
|
||||
|
||||
if (requiresMultiFactorAuthentication) {
|
||||
// Redirect to MFA page
|
||||
}
|
||||
```
|
||||
|
||||
### User Requirements
|
||||
|
||||
```typescript
|
||||
import { requireUser } from '@kit/supabase/require-user';
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
const user = await requireUser(client, { verifyMfa: false });
|
||||
```
|
||||
|
||||
## Storage Security
|
||||
|
||||
Storage buckets must validate access using account_id in the path structure:
|
||||
|
||||
```sql
|
||||
-- RLS policies for storage bucket account_image
|
||||
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'
|
||||
)
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
## Common Database Operations
|
||||
|
||||
### Creating Tables with RLS
|
||||
|
||||
```sql
|
||||
-- Create table
|
||||
create table if not exists public.notes (
|
||||
id uuid unique not null default extensions.uuid_generate_v4(),
|
||||
account_id uuid references public.accounts(id) on delete cascade not null,
|
||||
title varchar(255) not null,
|
||||
content text,
|
||||
created_at timestamp with time zone default now(),
|
||||
updated_at timestamp with time zone default now(),
|
||||
primary key (id)
|
||||
);
|
||||
|
||||
-- Enable RLS
|
||||
alter table "public"."notes" enable row level security;
|
||||
|
||||
-- Grant permissions
|
||||
grant select, insert, update, delete on table public.notes to authenticated;
|
||||
|
||||
-- Create RLS policies
|
||||
create policy "notes_read" on public.notes for select
|
||||
to authenticated using (
|
||||
account_id = (select auth.uid()) or
|
||||
public.has_role_on_account(account_id)
|
||||
);
|
||||
|
||||
create policy "notes_write" on public.notes for insert
|
||||
to authenticated with check (
|
||||
account_id = (select auth.uid()) or
|
||||
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
|
||||
);
|
||||
```
|
||||
|
||||
### Indexes for Performance
|
||||
|
||||
```sql
|
||||
-- Create indexes for common queries
|
||||
create index if not exists ix_notes_account_id on public.notes (account_id);
|
||||
create index if not exists ix_notes_created_at on public.notes (created_at);
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
|
||||
async function databaseOperation() {
|
||||
const logger = await getLogger();
|
||||
const ctx = { name: 'database-operation', accountId: 'account-123' };
|
||||
|
||||
try {
|
||||
logger.info(ctx, 'Starting database operation');
|
||||
const result = await client.from('table').select('*');
|
||||
|
||||
if (result.error) {
|
||||
logger.error({ ...ctx, error: result.error }, 'Database query failed');
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
logger.error({ ...ctx, error }, 'Database operation failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Best Practices
|
||||
|
||||
1. Always test migrations locally first
|
||||
2. Use transactions for complex migrations
|
||||
3. Add proper indexes for new columns
|
||||
4. Update RLS policies when adding new tables
|
||||
5. Generate TypeScript types after schema changes
|
||||
6. Take into account constraints
|
||||
7. Do not add breaking changes that would distrupt the DB to new migrations
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
1. **RLS bypass**: Admin client bypasses all RLS - validate manually
|
||||
2. **Missing indexes**: Always add indexes for foreign keys and commonly queried columns
|
||||
3. **Security definer functions**: Only use with explicit permission checks
|
||||
4. **Storage paths**: Must include account_id for proper access control
|
||||
5. **Type safety**: Always regenerate types after schema changes
|
||||
312
packages/supabase/CLAUDE.md
Normal file
312
packages/supabase/CLAUDE.md
Normal file
@@ -0,0 +1,312 @@
|
||||
# Database & Authentication Instructions
|
||||
|
||||
This file contains instructions for working with Supabase, database security, and authentication.
|
||||
|
||||
## Database Security Guidelines ⚠️
|
||||
|
||||
**Critical Security Guidelines - Read Carefully!**
|
||||
|
||||
### Database Security Fundamentals
|
||||
|
||||
- **Always enable RLS** on new tables unless explicitly instructed otherwise
|
||||
- **NEVER use SECURITY DEFINER functions** without explicit access controls - they bypass RLS entirely
|
||||
- **Always use security_invoker=true for views** to maintain proper access control
|
||||
- **Storage buckets MUST validate access** using account_id in the path structure. See `apps/web/supabase/schemas/16-storage.sql` for proper implementation.
|
||||
- **Use locks if required**: Database locks prevent race conditions and timing attacks in concurrent operations. Make sure to take these into account for all database operations.
|
||||
|
||||
### Security Definer Function - Dangerous Pattern ❌
|
||||
|
||||
```sql
|
||||
-- NEVER DO THIS - Allows any authenticated user to call function
|
||||
CREATE OR REPLACE FUNCTION public.dangerous_function()
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER AS $
|
||||
BEGIN
|
||||
-- This bypasses all RLS policies!
|
||||
DELETE FROM sensitive_table; -- Anyone can call this!
|
||||
END;
|
||||
$;
|
||||
GRANT EXECUTE ON FUNCTION public.dangerous_function() TO authenticated;
|
||||
```
|
||||
|
||||
### Security Definer Function - Safe Pattern ✅
|
||||
|
||||
```sql
|
||||
-- ONLY use SECURITY DEFINER with explicit access validation
|
||||
CREATE OR REPLACE FUNCTION public.safe_admin_function(target_account_id uuid)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = '' AS $
|
||||
BEGIN
|
||||
-- MUST validate caller has permission FIRST
|
||||
IF NOT public.is_account_owner(target_account_id) THEN
|
||||
RAISE EXCEPTION 'Access denied: insufficient permissions';
|
||||
END IF;
|
||||
|
||||
-- Now safe to proceed with elevated privileges
|
||||
-- Your admin operation here
|
||||
END;
|
||||
$;
|
||||
```
|
||||
|
||||
Only grant critical functions to `service_role`:
|
||||
|
||||
```sql
|
||||
grant execute on public.dangerous_function to service_role;
|
||||
```
|
||||
|
||||
## Existing Helper Functions - Use These! 📚
|
||||
|
||||
**DO NOT recreate these functions - they already exist:**
|
||||
|
||||
```sql
|
||||
-- Account Access Control
|
||||
public.has_role_on_account(account_id, role?) -- Check team membership
|
||||
public.has_permission(user_id, account_id, permission) -- Check permissions
|
||||
public.is_account_owner(account_id) -- Verify ownership
|
||||
public.has_active_subscription(account_id) -- Subscription status
|
||||
public.is_team_member(account_id, user_id) -- Direct membership check
|
||||
public.can_action_account_member(target_account_id, target_user_id) -- Member action rights
|
||||
|
||||
-- Administrative Functions
|
||||
public.is_super_admin() -- Super admin check
|
||||
public.is_aal2() -- MFA verification
|
||||
public.is_mfa_compliant() -- MFA compliance
|
||||
|
||||
-- Configuration
|
||||
public.is_set(field_name) -- Feature flag checks
|
||||
```
|
||||
|
||||
Always check `apps/web/supabase/schemas/` before creating new functions!
|
||||
|
||||
## RLS Policy Best Practices ✅
|
||||
|
||||
```sql
|
||||
-- Proper RLS using existing helper functions
|
||||
CREATE POLICY "notes_read" ON public.notes FOR SELECT
|
||||
TO authenticated USING (
|
||||
account_id = (select auth.uid()) OR
|
||||
public.has_role_on_account(account_id)
|
||||
);
|
||||
|
||||
-- For operations requiring specific permissions
|
||||
CREATE POLICY "notes_manage" ON public.notes FOR ALL
|
||||
TO authenticated USING (
|
||||
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
|
||||
);
|
||||
```
|
||||
|
||||
## Schema Management Workflow
|
||||
|
||||
1. Create schemas in `apps/web/supabase/schemas/` as `<number>-<name>.sql`
|
||||
2. After changes: `pnpm supabase:web:stop`
|
||||
3. Run: `pnpm --filter web run supabase:db:diff -f <filename>`
|
||||
4. Restart: `pnpm supabase:web:start` and `pnpm supabase:web:reset`
|
||||
5. Generate types: `pnpm supabase:web:typegen`
|
||||
|
||||
- **Never modify database.types.ts**: Instead, use the Supabase CLI using our package.json scripts to re-generate the types after resetting the DB
|
||||
|
||||
### Key Schema Files
|
||||
|
||||
- Accounts: `apps/web/supabase/schemas/03-accounts.sql`
|
||||
- Memberships: `apps/web/supabase/schemas/05-memberships.sql`
|
||||
- Permissions: `apps/web/supabase/schemas/06-roles-permissions.sql`
|
||||
|
||||
## Type Generation
|
||||
|
||||
```typescript
|
||||
import { Tables } from '@kit/supabase/database';
|
||||
|
||||
type Account = Tables<'accounts'>;
|
||||
```
|
||||
|
||||
Always prefer inferring types from generated Database types.
|
||||
|
||||
## Client Usage Patterns
|
||||
|
||||
### Server Components (Preferred)
|
||||
|
||||
```typescript
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
async function NotesPage() {
|
||||
const client = getSupabaseServerClient();
|
||||
const { data, error } = await client.from('notes').select('*');
|
||||
|
||||
if (error) return <ErrorMessage error={error} />;
|
||||
|
||||
return <NotesList notes={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
### Client Components
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
|
||||
function InteractiveNotes() {
|
||||
const supabase = useSupabase();
|
||||
// Use with React Query for optimal data fetching
|
||||
}
|
||||
```
|
||||
|
||||
### Admin Client (Use with Extreme Caution) ⚠️
|
||||
|
||||
```typescript
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
|
||||
async function adminFunction() {
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
// CRITICAL: Manual authorization required - bypasses RLS!
|
||||
const currentUser = await getCurrentUser();
|
||||
|
||||
if (!(await isSuperAdmin(currentUser))) {
|
||||
throw new Error('Unauthorized: Admin access required');
|
||||
}
|
||||
|
||||
// Now safe to proceed with admin privileges
|
||||
const { data } = await adminClient.from('table').select('*');
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication Patterns
|
||||
|
||||
### Multi-Factor Authentication
|
||||
|
||||
```typescript
|
||||
import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa';
|
||||
|
||||
const requiresMultiFactorAuthentication =
|
||||
await checkRequiresMultiFactorAuthentication(supabase);
|
||||
|
||||
if (requiresMultiFactorAuthentication) {
|
||||
// Redirect to MFA page
|
||||
}
|
||||
```
|
||||
|
||||
### User Requirements
|
||||
|
||||
```typescript
|
||||
import { requireUser } from '@kit/supabase/require-user';
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
const user = await requireUser(client, { verifyMfa: false });
|
||||
```
|
||||
|
||||
## Storage Security
|
||||
|
||||
Storage buckets must validate access using account_id in the path structure:
|
||||
|
||||
```sql
|
||||
-- RLS policies for storage bucket account_image
|
||||
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'
|
||||
)
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
## Common Database Operations
|
||||
|
||||
### Creating Tables with RLS
|
||||
|
||||
```sql
|
||||
-- Create table
|
||||
create table if not exists public.notes (
|
||||
id uuid unique not null default extensions.uuid_generate_v4(),
|
||||
account_id uuid references public.accounts(id) on delete cascade not null,
|
||||
title varchar(255) not null,
|
||||
content text,
|
||||
created_at timestamp with time zone default now(),
|
||||
updated_at timestamp with time zone default now(),
|
||||
primary key (id)
|
||||
);
|
||||
|
||||
-- Enable RLS
|
||||
alter table "public"."notes" enable row level security;
|
||||
|
||||
-- Grant permissions
|
||||
grant select, insert, update, delete on table public.notes to authenticated;
|
||||
|
||||
-- Create RLS policies
|
||||
create policy "notes_read" on public.notes for select
|
||||
to authenticated using (
|
||||
account_id = (select auth.uid()) or
|
||||
public.has_role_on_account(account_id)
|
||||
);
|
||||
|
||||
create policy "notes_write" on public.notes for insert
|
||||
to authenticated with check (
|
||||
account_id = (select auth.uid()) or
|
||||
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
|
||||
);
|
||||
```
|
||||
|
||||
### Indexes for Performance
|
||||
|
||||
```sql
|
||||
-- Create indexes for common queries
|
||||
create index if not exists ix_notes_account_id on public.notes (account_id);
|
||||
create index if not exists ix_notes_created_at on public.notes (created_at);
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
|
||||
async function databaseOperation() {
|
||||
const logger = await getLogger();
|
||||
const ctx = { name: 'database-operation', accountId: 'account-123' };
|
||||
|
||||
try {
|
||||
logger.info(ctx, 'Starting database operation');
|
||||
const result = await client.from('table').select('*');
|
||||
|
||||
if (result.error) {
|
||||
logger.error({ ...ctx, error: result.error }, 'Database query failed');
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
logger.error({ ...ctx, error }, 'Database operation failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Best Practices
|
||||
|
||||
1. Always test migrations locally first
|
||||
2. Use transactions for complex migrations
|
||||
3. Add proper indexes for new columns
|
||||
4. Update RLS policies when adding new tables
|
||||
5. Generate TypeScript types after schema changes
|
||||
6. Take into account constraints
|
||||
7. Do not add breaking changes that would distrupt the DB to new migrations
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
1. **RLS bypass**: Admin client bypasses all RLS - validate manually
|
||||
2. **Missing indexes**: Always add indexes for foreign keys and commonly queried columns
|
||||
3. **Security definer functions**: Only use with explicit permission checks
|
||||
4. **Storage paths**: Must include account_id for proper access control
|
||||
5. **Type safety**: Always regenerate types after schema changes
|
||||
@@ -26,10 +26,10 @@
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@supabase/ssr": "^0.7.0",
|
||||
"@supabase/supabase-js": "2.57.2",
|
||||
"@tanstack/react-query": "5.87.1",
|
||||
"@types/react": "19.1.12",
|
||||
"next": "15.5.2",
|
||||
"@supabase/supabase-js": "2.57.4",
|
||||
"@tanstack/react-query": "5.89.0",
|
||||
"@types/react": "19.1.13",
|
||||
"next": "15.5.3",
|
||||
"react": "19.1.1",
|
||||
"server-only": "^0.0.1",
|
||||
"zod": "^3.25.74"
|
||||
|
||||
304
packages/ui/AGENTS.md
Normal file
304
packages/ui/AGENTS.md
Normal file
@@ -0,0 +1,304 @@
|
||||
# UI Components & Styling Instructions
|
||||
|
||||
This file contains instructions for working with UI components, styling, and forms.
|
||||
|
||||
## Core UI Library
|
||||
|
||||
Import from `packages/ui/src/`:
|
||||
|
||||
```tsx
|
||||
// Shadcn components
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card } from '@kit/ui/card';
|
||||
|
||||
// Makerkit components
|
||||
import { If } from '@kit/ui/if';
|
||||
import { ProfileAvatar } from '@kit/ui/profile-avatar';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
```
|
||||
|
||||
NB: imports must follow the convention "@kit/ui/<name>", no matter the folder they're placed in
|
||||
|
||||
## Styling Guidelines
|
||||
|
||||
- Use **Tailwind CSS v4** with semantic classes
|
||||
- Prefer Shadcn-ui classes like `bg-background`, `text-muted-foreground`
|
||||
- Use `cn()` utility from `@kit/ui/cn` for class merging
|
||||
|
||||
```tsx
|
||||
import { cn } from '@kit/ui/cn';
|
||||
|
||||
function MyComponent({ className }) {
|
||||
return (
|
||||
<div className={cn('bg-background text-foreground', className)}>
|
||||
Content
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Conditional Rendering
|
||||
|
||||
Use the `If` component from `packages/ui/src/makerkit/if.tsx`:
|
||||
|
||||
```tsx
|
||||
import { If } from '@kit/ui/if';
|
||||
|
||||
<If condition={isLoading} fallback={<Content />}>
|
||||
<Spinner />
|
||||
</If>
|
||||
|
||||
// With type inference
|
||||
<If condition={error}>
|
||||
{(err) => <ErrorMessage error={err} />}
|
||||
</If>
|
||||
```
|
||||
|
||||
### Testing Attributes
|
||||
|
||||
```tsx
|
||||
<button data-test="submit-button">Submit</button>
|
||||
<div data-test="user-profile" data-user-id={user.id}>Profile</div>
|
||||
```
|
||||
|
||||
## Forms with React Hook Form & Zod
|
||||
|
||||
```typescript
|
||||
// 1. Schema in separate file
|
||||
export const CreateNoteSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
});
|
||||
|
||||
// 2. Client component with form
|
||||
'use client';
|
||||
const form = useForm({
|
||||
resolver: zodResolver(CreateNoteSchema),
|
||||
});
|
||||
|
||||
const onSubmit = (data) => {
|
||||
startTransition(async () => {
|
||||
await toast.promise(createNoteAction(data), {
|
||||
loading: 'Creating...',
|
||||
success: 'Created!',
|
||||
error: 'Failed!',
|
||||
}).unwrap();
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### Form Examples
|
||||
|
||||
- Contact form: `apps/web/app/(marketing)/contact/_components/contact-form.tsx`
|
||||
- Verify OTP form: `packages/otp/src/components/verify-otp-form.tsx`
|
||||
|
||||
### Guidelines
|
||||
|
||||
- Place Zod resolver outside so it can be reused with Server Actions
|
||||
- Never add generics to `useForm`, use Zod resolver to infer types instead
|
||||
- Never use `watch()` instead use hook `useWatch`
|
||||
- Add `FormDescription` (optionally) and always add `FormMessage` to display errors
|
||||
|
||||
## Internationalization
|
||||
|
||||
Always use `Trans` component from `packages/ui/src/makerkit/trans.tsx`:
|
||||
|
||||
```tsx
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
<Trans
|
||||
i18nKey="user:welcomeMessage"
|
||||
values={{ name: user.name }}
|
||||
/>
|
||||
|
||||
// With HTML elements
|
||||
<Trans
|
||||
i18nKey="terms:agreement"
|
||||
components={{
|
||||
TermsLink: <a href="/terms" className="underline" />,
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Toast Notifications
|
||||
|
||||
Use the `toast` utility from `@kit/ui/sonner`:
|
||||
|
||||
```tsx
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
|
||||
// Simple toast
|
||||
toast.success('Success message');
|
||||
toast.error('Error message');
|
||||
|
||||
// Promise-based toast
|
||||
await toast.promise(asyncFunction(), {
|
||||
loading: 'Processing...',
|
||||
success: 'Done!',
|
||||
error: 'Failed!',
|
||||
});
|
||||
```
|
||||
|
||||
## Common Component Patterns
|
||||
|
||||
### Loading States
|
||||
|
||||
```tsx
|
||||
import { Spinner } from '@kit/ui/spinner';
|
||||
|
||||
<If condition={isLoading} fallback={<Content />}>
|
||||
<Spinner className="h-4 w-4" />
|
||||
</If>
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```tsx
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
|
||||
<If condition={Boolean(error)}>
|
||||
<Alert variant="destructive">
|
||||
<ExclamationTriangleIcon className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
```
|
||||
|
||||
### Button Patterns
|
||||
|
||||
```tsx
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
// Loading button
|
||||
<Button disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Spinner className="mr-2 h-4 w-4" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Submit'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
// Variants
|
||||
<Button variant="default">Default</Button>
|
||||
<Button variant="destructive">Delete</Button>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
```
|
||||
|
||||
### Card Layouts
|
||||
|
||||
```tsx
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Card Title</CardTitle>
|
||||
<CardDescription>Card description</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
Card content goes here
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
## Form Components
|
||||
|
||||
### Input Fields
|
||||
|
||||
```tsx
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { FormField, FormItem, FormLabel, FormControl, FormMessage } from '@kit/ui/form';
|
||||
|
||||
<FormField
|
||||
name="title"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Title</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input placeholder="Enter title" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
The title of your task
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
### Select Components
|
||||
|
||||
```tsx
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kit/ui/select';
|
||||
|
||||
<FormField
|
||||
name="category"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Category</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="option1">Option 1</SelectItem>
|
||||
<SelectItem value="option2">Option 2</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<FormDescription>
|
||||
The category of your task
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
## Accessibility Guidelines
|
||||
|
||||
- Always include proper ARIA labels
|
||||
- Use semantic HTML elements
|
||||
- Ensure proper keyboard navigation
|
||||
|
||||
```tsx
|
||||
<button
|
||||
aria-label="Close modal"
|
||||
aria-describedby="modal-description"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
```
|
||||
|
||||
## Dark Mode Support
|
||||
|
||||
The UI components automatically support dark mode through CSS variables. Use semantic color classes:
|
||||
|
||||
```tsx
|
||||
// Good - semantic colors
|
||||
<div className="bg-background text-foreground border-border">
|
||||
<p className="text-muted-foreground">Secondary text</p>
|
||||
</div>
|
||||
|
||||
// Avoid - hardcoded colors
|
||||
<div className="bg-white text-black border-gray-200">
|
||||
<p className="text-gray-500">Secondary text</p>
|
||||
</div>
|
||||
```
|
||||
289
packages/ui/CLAUDE.md
Normal file
289
packages/ui/CLAUDE.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# UI Components & Styling Instructions
|
||||
|
||||
This file contains instructions for working with UI components, styling, and forms.
|
||||
|
||||
## Core UI Library
|
||||
|
||||
Import from `packages/ui/src/`:
|
||||
|
||||
```tsx
|
||||
// Shadcn components
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card } from '@kit/ui/card';
|
||||
// Makerkit components
|
||||
import { If } from '@kit/ui/if';
|
||||
import { ProfileAvatar } from '@kit/ui/profile-avatar';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
```
|
||||
|
||||
## Styling Guidelines
|
||||
|
||||
- Use **Tailwind CSS v4** with semantic classes
|
||||
- Prefer Shadcn-ui classes like `bg-background`, `text-muted-foreground`
|
||||
- Use `cn()` utility from `@kit/ui/cn` for class merging
|
||||
|
||||
```tsx
|
||||
import { cn } from '@kit/ui/cn';
|
||||
|
||||
function MyComponent({ className }) {
|
||||
return (
|
||||
<div className={cn('bg-background text-foreground', className)}>
|
||||
Content
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Conditional Rendering
|
||||
|
||||
Use the `If` component from `packages/ui/src/makerkit/if.tsx`:
|
||||
|
||||
```tsx
|
||||
import { If } from '@kit/ui/if';
|
||||
|
||||
<If condition={isLoading} fallback={<Content />}>
|
||||
<Spinner />
|
||||
</If>
|
||||
|
||||
// With type inference
|
||||
<If condition={error}>
|
||||
{(err) => <ErrorMessage error={err} />}
|
||||
</If>
|
||||
```
|
||||
|
||||
### Testing Attributes
|
||||
|
||||
```tsx
|
||||
<button data-test="submit-button">Submit</button>
|
||||
<div data-test="user-profile" data-user-id={user.id}>Profile</div>
|
||||
```
|
||||
|
||||
## Forms with React Hook Form & Zod
|
||||
|
||||
```typescript
|
||||
// 1. Schema in separate file
|
||||
export const CreateNoteSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
});
|
||||
|
||||
// 2. Client component with form
|
||||
'use client';
|
||||
const form = useForm({
|
||||
resolver: zodResolver(CreateNoteSchema),
|
||||
});
|
||||
|
||||
const onSubmit = (data) => {
|
||||
startTransition(async () => {
|
||||
await toast.promise(createNoteAction(data), {
|
||||
loading: 'Creating...',
|
||||
success: 'Created!',
|
||||
error: 'Failed!',
|
||||
}).unwrap();
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### Guidelines
|
||||
|
||||
- Place Zod resolver outside so it can be reused with Server Actions
|
||||
- Never add generics to `useForm`, use Zod resolver to infer types instead
|
||||
- Never use `watch()` instead use hook `useWatch`
|
||||
- Add `FormDescription` (optionally) and always add `FormMessage` to display errors
|
||||
|
||||
### Form Examples
|
||||
|
||||
- Contact form: `apps/web/app/(marketing)/contact/_components/contact-form.tsx`
|
||||
- Verify OTP form: `packages/otp/src/components/verify-otp-form.tsx`
|
||||
|
||||
## Internationalization
|
||||
|
||||
Always use `Trans` component from `packages/ui/src/makerkit/trans.tsx`:
|
||||
|
||||
```tsx
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
<Trans
|
||||
i18nKey="user:welcomeMessage"
|
||||
values={{ name: user.name }}
|
||||
/>
|
||||
|
||||
// With HTML elements
|
||||
<Trans
|
||||
i18nKey="terms:agreement"
|
||||
components={{
|
||||
TermsLink: <a href="/terms" className="underline" />,
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Toast Notifications
|
||||
|
||||
Use the `toast` utility from `@kit/ui/sonner`:
|
||||
|
||||
```tsx
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
|
||||
// Simple toast
|
||||
toast.success('Success message');
|
||||
toast.error('Error message');
|
||||
|
||||
// Promise-based toast
|
||||
await toast.promise(asyncFunction(), {
|
||||
loading: 'Processing...',
|
||||
success: 'Done!',
|
||||
error: 'Failed!',
|
||||
});
|
||||
```
|
||||
|
||||
## Common Component Patterns
|
||||
|
||||
### Loading States
|
||||
|
||||
```tsx
|
||||
import { Spinner } from '@kit/ui/spinner';
|
||||
|
||||
<If condition={isLoading} fallback={<Content />}>
|
||||
<Spinner className="h-4 w-4" />
|
||||
</If>
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```tsx
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
|
||||
<If condition={Boolean(error)}>
|
||||
<Alert variant="destructive">
|
||||
<ExclamationTriangleIcon className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
```
|
||||
|
||||
### Button Patterns
|
||||
|
||||
```tsx
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
// Loading button
|
||||
<Button disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Spinner className="mr-2 h-4 w-4" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Submit'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
// Variants
|
||||
<Button variant="default">Default</Button>
|
||||
<Button variant="destructive">Delete</Button>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
```
|
||||
|
||||
### Card Layouts
|
||||
|
||||
```tsx
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Card Title</CardTitle>
|
||||
<CardDescription>Card description</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
Card content goes here
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
## Form Components
|
||||
|
||||
### Input Fields
|
||||
|
||||
```tsx
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { FormField, FormItem, FormLabel, FormControl, FormMessage } from '@kit/ui/form';
|
||||
|
||||
<FormField
|
||||
name="title"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter title" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
### Select Components
|
||||
|
||||
```tsx
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kit/ui/select';
|
||||
|
||||
<FormField
|
||||
name="category"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Category</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="option1">Option 1</SelectItem>
|
||||
<SelectItem value="option2">Option 2</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
## Accessibility Guidelines
|
||||
|
||||
- Always include proper ARIA labels
|
||||
- Use semantic HTML elements
|
||||
- Ensure proper keyboard navigation
|
||||
|
||||
```tsx
|
||||
<button
|
||||
aria-label="Close modal"
|
||||
aria-describedby="modal-description"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
```
|
||||
|
||||
## Dark Mode Support
|
||||
|
||||
The UI components automatically support dark mode through CSS variables. Use semantic color classes:
|
||||
|
||||
```tsx
|
||||
// Good - semantic colors
|
||||
<div className="bg-background text-foreground border-border">
|
||||
<p className="text-muted-foreground">Secondary text</p>
|
||||
</div>
|
||||
|
||||
// Avoid - hardcoded colors
|
||||
<div className="bg-white text-black border-gray-200">
|
||||
<p className="text-gray-500">Secondary text</p>
|
||||
</div>
|
||||
```
|
||||
@@ -9,12 +9,12 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"input-otp": "1.4.2",
|
||||
"lucide-react": "^0.542.0",
|
||||
"lucide-react": "^0.544.0",
|
||||
"radix-ui": "1.4.3",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-top-loading-bar": "3.0.2",
|
||||
@@ -25,18 +25,18 @@
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@supabase/supabase-js": "2.57.2",
|
||||
"@tanstack/react-query": "5.87.1",
|
||||
"@supabase/supabase-js": "2.57.4",
|
||||
"@tanstack/react-query": "5.89.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react": "19.1.13",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"eslint": "^9.35.0",
|
||||
"next": "15.5.2",
|
||||
"next": "15.5.3",
|
||||
"next-themes": "0.4.6",
|
||||
"prettier": "^3.6.2",
|
||||
"react-day-picker": "^9.9.0",
|
||||
"react-day-picker": "^9.10.0",
|
||||
"react-hook-form": "^7.62.0",
|
||||
"react-i18next": "^15.7.3",
|
||||
"sonner": "^2.0.7",
|
||||
|
||||
4112
pnpm-lock.yaml
generated
4112
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -13,11 +13,11 @@
|
||||
"format": "prettier --check \"**/*.{js,json}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@next/eslint-plugin-next": "15.5.2",
|
||||
"@next/eslint-plugin-next": "15.5.3",
|
||||
"@types/eslint": "9.6.1",
|
||||
"eslint-config-next": "15.5.2",
|
||||
"eslint-config-next": "15.5.3",
|
||||
"eslint-config-turbo": "^2.5.6",
|
||||
"typescript-eslint": "8.42.0"
|
||||
"typescript-eslint": "8.44.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
|
||||
Reference in New Issue
Block a user