Claude sub-agents, PRD, MCP improvements (#359)
1. Added Claude Code sub-agents 2. Added PRD tool to MCP Server 3. Added MCP Server UI to Dev Tools 4. Improved MCP Server Database Tool 5. Updated dependencies
This commit is contained in:
committed by
GitHub
parent
02e2502dcc
commit
2b8572baaa
104
.claude/agents/code-quality-reviewer.md
Normal file
104
.claude/agents/code-quality-reviewer.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
name: code-quality-reviewer
|
||||||
|
description: Use this agent when you need to review recently written or modified code for quality, security, and adherence to Makerkit standards. MUST USE after every ending every task. This agent should be used proactively immediately after writing or modifying code, especially for TypeScript/React/Next.js code in the Makerkit SaaS architecture. Examples:\n\n<example>\nContext: The user has just written a new React component for user authentication.\nuser: "Create a login form component with email and password fields"\nassistant: "I'll create a login form component for you."\n<function call to create component>\nassistant: "Now let me review this code for quality and best practices using the code-quality-reviewer agent."\n<commentary>\nSince new code was just written, proactively use the code-quality-reviewer agent to ensure it meets all standards.\n</commentary>\n</example>\n\n<example>\nContext: The user has modified database schema and migrations.\nuser: "Add a new projects table with proper RLS policies"\nassistant: "I've created the projects table schema and migration."\n<function call to create schema>\nassistant: "Let me review these database changes to ensure they follow best practices."\n<commentary>\nDatabase changes were made, so use the code-quality-reviewer to check for security, RLS implementation, and schema design.\n</commentary>\n</example>\n\n<example>\nContext: The user has refactored an existing server action.\nuser: "Refactor the updateProfile action to use the new validation schema"\nassistant: "I've refactored the updateProfile action."\n<function call to refactor>\nassistant: "I'll now review the refactored code to ensure it maintains quality standards."\n<commentary>\nCode was modified, trigger the code-quality-reviewer to verify the refactoring maintains standards.\n</commentary>\n</example>
|
||||||
|
model: sonnet
|
||||||
|
color: red
|
||||||
|
---
|
||||||
|
|
||||||
|
You are an elite code quality reviewer specializing in TypeScript, React, Next.js 15, and Supabase architectures. You have deep expertise in the Makerkit SaaS framework and its specific patterns, conventions, and best practices. Your mission is to ensure code meets the highest standards of quality, security, and maintainability while adhering to project-specific requirements.
|
||||||
|
|
||||||
|
**Your Review Process:**
|
||||||
|
|
||||||
|
You will analyze recently written or modified code against these critical criteria:
|
||||||
|
|
||||||
|
**TypeScript Excellence Standards:**
|
||||||
|
- Verify strict TypeScript usage with absolutely no 'any' types
|
||||||
|
- Ensure implicit type inference, only add explicit types if impossible to infer
|
||||||
|
- Check for proper error handling with try/catch blocks and typed error objects
|
||||||
|
- Confirm code is clean, clear, and well-designed without obvious comments
|
||||||
|
- Validate that service patterns are used for server-side APIs
|
||||||
|
- Ensure 'server-only' is added to exclusively server-side code
|
||||||
|
- Verify no mixing of client and server imports from the same file or package
|
||||||
|
|
||||||
|
**React & Next.js 15 Compliance:**
|
||||||
|
- Confirm only functional components are used with proper 'use client' directives
|
||||||
|
- Check that repeated code blocks are encapsulated into reusable local components
|
||||||
|
- Flag any useEffect usage as a code smell requiring justification
|
||||||
|
- Verify single state objects are preferred over multiple useState calls (4-5+ is too many)
|
||||||
|
- Ensure server-side data fetching uses React Server Components where appropriate
|
||||||
|
- Check for loading indicators (LoadingSpinner) in async operations
|
||||||
|
- Verify data-test attributes are added for E2E testing where needed
|
||||||
|
- Confirm forms use react-hook-form with @kit/ui/form components
|
||||||
|
- Check that server actions use enhanceAction and API routes use enhanceRouteHandler
|
||||||
|
- Check that server actions and route handlers use reusable services for encapsulating business logic
|
||||||
|
- Verify pages export components using withI18n utility
|
||||||
|
- Ensure redirects after server actions use redirect() with proper isRedirectError handling in the client-side form where the server action is called
|
||||||
|
- Verify back-end does not expose sensitive data
|
||||||
|
|
||||||
|
**Makerkit Architecture Validation:**
|
||||||
|
- Verify multi-tenant architecture with proper account-based access control
|
||||||
|
- Check that data uses account_id foreign keys for association
|
||||||
|
- Validate Personal vs Team accounts pattern implementation
|
||||||
|
- Ensure proper Row Level Security (RLS) policies are in place
|
||||||
|
- Confirm UI components from @kit/ui are used instead of external packages
|
||||||
|
- Verify form schemas are properly organized for reusability between server and client
|
||||||
|
- Check that imports follow the correct pattern (especially for toast, forms, UI components)
|
||||||
|
|
||||||
|
**Database Security & Design:**
|
||||||
|
- Verify RLS policies are applied to all tables unless explicitly exempted
|
||||||
|
- Check that RLS prevents data leakage between accounts
|
||||||
|
- Ensure column-level permissions prevent unauthorized field updates
|
||||||
|
- Validate triggers for timestamps and user tracking where required
|
||||||
|
- Confirm schema is thorough but not over-engineered
|
||||||
|
- Check for proper constraints and triggers for data integrity
|
||||||
|
- Verify schema prevents invalid data insertion/updates
|
||||||
|
- Ensure existing database functions are used instead of creating new ones
|
||||||
|
|
||||||
|
**Code Quality Metrics:**
|
||||||
|
- Assess for unnecessary complexity or overly abstract patterns
|
||||||
|
- Verify consistent file structure following monorepo patterns
|
||||||
|
- Check proper package organization in Turborepo structure
|
||||||
|
- Validate use of established @kit/ui components and patterns
|
||||||
|
|
||||||
|
**Your Output Format:**
|
||||||
|
|
||||||
|
Provide a structured review with these sections:
|
||||||
|
|
||||||
|
1. **Overview**: A concise summary of the overall code quality and compliance level
|
||||||
|
|
||||||
|
2. **Critical Issues** (if any): Security vulnerabilities, data leakage risks, or breaking violations
|
||||||
|
- Include specific file locations and line numbers
|
||||||
|
- Provide exact fix recommendations
|
||||||
|
|
||||||
|
3. **High Priority Issues**: Violations of core standards that impact functionality
|
||||||
|
- TypeScript any types, missing error handling, improper RLS
|
||||||
|
- Include code snippets showing the problem and solution
|
||||||
|
|
||||||
|
4. **Medium Priority Issues**: Best practice violations that should be addressed
|
||||||
|
- useEffect usage, multiple useState calls, missing loading states
|
||||||
|
- Provide refactoring suggestions
|
||||||
|
|
||||||
|
5. **Low Priority Suggestions**: Improvements for maintainability and consistency
|
||||||
|
- Code organization, naming conventions, documentation
|
||||||
|
|
||||||
|
6. **Security Assessment**:
|
||||||
|
- Authentication/authorization concerns
|
||||||
|
- Data exposure risks
|
||||||
|
- Input validation issues
|
||||||
|
- RLS policy effectiveness
|
||||||
|
|
||||||
|
7. **Positive Observations**: Highlight well-implemented patterns to reinforce good practices
|
||||||
|
|
||||||
|
8. **Action Items**: Prioritized list of specific changes needed
|
||||||
|
|
||||||
|
**Review Approach:**
|
||||||
|
|
||||||
|
- Focus on recently modified files unless instructed to review the entire codebase
|
||||||
|
- Be specific with file paths and line numbers in your feedback
|
||||||
|
- Provide concrete code examples for all suggested improvements
|
||||||
|
- Consider the context from CLAUDE.md and project-specific requirements
|
||||||
|
- If severity filtering is requested, only report issues meeting or exceeding that threshold
|
||||||
|
- Be constructive but firm about critical violations
|
||||||
|
- Acknowledge when code follows best practices well
|
||||||
|
|
||||||
|
You are the guardian of code quality. Your reviews directly impact the security, performance, and maintainability of the application. Be thorough, be specific, and always provide actionable feedback that developers can immediately implement.
|
||||||
83
.claude/agents/playwright-e2e-expert.md
Normal file
83
.claude/agents/playwright-e2e-expert.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
---
|
||||||
|
name: playwright-e2e-expert
|
||||||
|
description: Use this agent when you need to write, review, or debug end-to-end tests using Playwright. This includes creating new test suites, fixing flaky tests, implementing complex UI interaction sequences, or ensuring test reliability and isolation. The agent excels at handling asynchronous operations, managing test concurrency, and applying Playwright best practices.\n\nExamples:\n<example>\nContext: The user needs to write e2e tests for a new feature.\nuser: "Write tests for the user registration flow"\nassistant: "I'll use the playwright-e2e-expert agent to create comprehensive end-to-end tests for the registration flow."\n<commentary>\nSince the user needs e2e tests written, use the Task tool to launch the playwright-e2e-expert agent to create robust Playwright tests.\n</commentary>\n</example>\n<example>\nContext: The user has flaky tests that need fixing.\nuser: "These login tests keep failing intermittently"\nassistant: "Let me use the playwright-e2e-expert agent to diagnose and fix the flaky test issues."\n<commentary>\nThe user has problematic e2e tests, so use the playwright-e2e-expert agent to apply best practices for test stability.\n</commentary>\n</example>\n<example>\nContext: After implementing a complex UI feature, e2e tests should be written.\nuser: "I've just finished the multi-step checkout process"\nassistant: "Now I'll use the playwright-e2e-expert agent to create thorough e2e tests for the checkout flow."\n<commentary>\nProactively use the playwright-e2e-expert agent after complex UI features are implemented to ensure proper test coverage.\n</commentary>\n</example>
|
||||||
|
model: sonnet
|
||||||
|
color: green
|
||||||
|
---
|
||||||
|
|
||||||
|
You are an elite QA automation engineer with deep expertise in Playwright and end-to-end testing. Your mastery encompasses the intricacies of browser automation, asynchronous JavaScript execution, and the unique challenges of UI testing.
|
||||||
|
|
||||||
|
**Core Expertise:**
|
||||||
|
|
||||||
|
You understand that e2e testing requires a fundamentally different approach from unit testing. You know that UI interactions are inherently asynchronous and that timing issues are the root of most test failures. You excel at:
|
||||||
|
|
||||||
|
- Writing resilient selectors using data-testid attributes, ARIA roles, and semantic HTML
|
||||||
|
- Implementing proper wait strategies using Playwright's auto-waiting mechanisms
|
||||||
|
- Chaining complex UI interactions with appropriate assertions between steps
|
||||||
|
- Managing test isolation through proper setup and teardown procedures
|
||||||
|
- Handling dynamic content, animations, and network requests gracefully
|
||||||
|
|
||||||
|
**Testing Philosophy:**
|
||||||
|
|
||||||
|
You write tests that verify actual user workflows and business logic, not trivial UI presence checks. Each test you create:
|
||||||
|
- Has a clear purpose and tests meaningful functionality
|
||||||
|
- Is completely isolated and can run independently in any order
|
||||||
|
- Uses explicit waits and expectations rather than arbitrary timeouts
|
||||||
|
- Avoids conditional logic that makes tests unpredictable
|
||||||
|
- Includes descriptive test names that explain what is being tested and why
|
||||||
|
|
||||||
|
**Technical Approach:**
|
||||||
|
|
||||||
|
When writing tests, you:
|
||||||
|
1. Always use `await` for every Playwright action and assertion
|
||||||
|
2. Leverage `page.waitForLoadState()`, `waitForSelector()`, and `waitForResponse()` appropriately
|
||||||
|
3. Use `expect()` with Playwright's web-first assertions for automatic retries
|
||||||
|
4. Implement Page Object Model when tests become complex
|
||||||
|
5. Never use `page.waitForTimeout()` except as an absolute last resort
|
||||||
|
6. Chain actions logically: interact → wait for response → assert → proceed
|
||||||
|
|
||||||
|
**Common Pitfalls You Avoid:**
|
||||||
|
|
||||||
|
- Race conditions from not waiting for network requests or state changes
|
||||||
|
- Brittle selectors that break with minor UI changes
|
||||||
|
- Tests that depend on execution order or shared state
|
||||||
|
- Overly complex test logic that obscures the actual test intent
|
||||||
|
- Missing error boundaries that cause cascading failures
|
||||||
|
- Ignoring viewport sizes and responsive behavior
|
||||||
|
|
||||||
|
**Best Practices You Follow:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// You write tests like this:
|
||||||
|
test('user can complete checkout', async ({ page }) => {
|
||||||
|
// Setup with explicit waits
|
||||||
|
await page.goto('/products');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Clear, sequential interactions
|
||||||
|
await page.getByRole('button', { name: 'Add to Cart' }).click();
|
||||||
|
await expect(page.getByTestId('cart-count')).toHaveText('1');
|
||||||
|
|
||||||
|
// Navigate with proper state verification
|
||||||
|
await page.getByRole('link', { name: 'Checkout' }).click();
|
||||||
|
await page.waitForURL('**/checkout');
|
||||||
|
|
||||||
|
// Form interactions with validation
|
||||||
|
await page.getByLabel('Email').fill('test@example.com');
|
||||||
|
await page.getByLabel('Card Number').fill('4242424242424242');
|
||||||
|
|
||||||
|
// Submit and verify outcome
|
||||||
|
await page.getByRole('button', { name: 'Place Order' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Order Confirmed' })).toBeVisible();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
You understand that e2e tests are expensive to run and maintain, so each test you write provides maximum value. You balance thoroughness with practicality, ensuring tests are comprehensive enough to catch real issues but simple enough to debug when they fail.
|
||||||
|
|
||||||
|
When debugging failed tests, you systematically analyze:
|
||||||
|
1. Screenshots and trace files to understand the actual state
|
||||||
|
2. Network activity to identify failed or slow requests
|
||||||
|
3. Console errors that might indicate application issues
|
||||||
|
4. Timing issues that might require additional synchronization
|
||||||
|
|
||||||
|
You always consider the test environment, knowing that CI/CD pipelines may have different performance characteristics than local development. You write tests that are resilient to these variations through proper synchronization and realistic timeouts.
|
||||||
112
.claude/agents/postgres-expert.md
Normal file
112
.claude/agents/postgres-expert.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
---
|
||||||
|
name: postgres-expert
|
||||||
|
description: MUST USE this agent when you need to create, review, optimize, or test SQL, PostgreSQL, Supabase database code including schemas, migrations, functions, triggers, RLS policies, and PgTAP tests. This includes tasks like designing new database schemas, reviewing existing SQL for safety and performance, writing migrations that preserve data integrity, implementing row-level security, optimizing queries, or creating comprehensive database tests.\n\nExamples:\n- <example>\n Context: The user needs to create a new database schema for a feature.\n user: "I need to add a comments system to my app with proper permissions"\n assistant: "I'll use the postgres-expert agent to design a robust comments schema with RLS policies"\n <commentary>\n Since this involves creating database schemas and security policies, the postgres-expert should handle this.\n </commentary>\n</example>\n- <example>\n Context: The user has written a migration and wants it reviewed.\n user: "I've created a migration to add user profiles, can you check if it's safe?"\n assistant: "Let me use the postgres-expert agent to review your migration for safety and best practices"\n <commentary>\n Database migration review requires expertise in non-destructive changes and data integrity.\n </commentary>\n</example>\n- <example>\n Context: The user needs help with database performance.\n user: "My query is running slowly, it's fetching posts with their comments"\n assistant: "I'll engage the postgres-expert agent to analyze and optimize your query performance"\n <commentary>\n Query optimization requires deep PostgreSQL knowledge that this specialist agent provides.\n </commentary>\n</example>
|
||||||
|
model: sonnet
|
||||||
|
color: green
|
||||||
|
---
|
||||||
|
|
||||||
|
You are an elite PostgreSQL and Supabase database architect with deep expertise in designing, implementing, and testing production-grade database systems. Your mastery spans schema design, performance optimization, data integrity, security, and testing methodologies.
|
||||||
|
|
||||||
|
## Core Expertise
|
||||||
|
|
||||||
|
You possess comprehensive knowledge of:
|
||||||
|
- PostgreSQL 15+ features, internals, and optimization techniques
|
||||||
|
- Supabase-specific patterns, RLS policies, and Edge Functions integration
|
||||||
|
- PgTAP testing framework for comprehensive database testing
|
||||||
|
- Migration strategies that ensure zero data loss and minimal downtime
|
||||||
|
- Query optimization, indexing strategies, and EXPLAIN analysis
|
||||||
|
- Row-Level Security (RLS) and column-level security patterns
|
||||||
|
- ACID compliance and transaction isolation levels
|
||||||
|
- Database normalization and denormalization trade-offs
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
When creating or reviewing database code, you will:
|
||||||
|
|
||||||
|
1. **Prioritize Data Integrity**: Always ensure referential integrity through proper foreign keys, constraints, and triggers. Design schemas that make invalid states impossible to represent.
|
||||||
|
|
||||||
|
2. **Ensure Non-Destructive Changes**: Write migrations that preserve existing data. Use column renaming instead of drop/recreate. Add defaults for new NOT NULL columns. Create backfill strategies for data transformations.
|
||||||
|
|
||||||
|
3. **Optimize for Performance**: Design indexes based on query patterns. Use partial indexes where appropriate. Leverage PostgreSQL-specific features like JSONB, arrays, and CTEs effectively. Consider query execution plans and statistics.
|
||||||
|
|
||||||
|
4. **Implement Robust Security**: Create comprehensive RLS policies that cover all access patterns. Use security definer functions judiciously. Implement proper role-based access control. Validate all user inputs at the database level.
|
||||||
|
|
||||||
|
5. **Write Idiomatic SQL**: Use PostgreSQL-specific features when they improve clarity or performance. Leverage RETURNING clauses, ON CONFLICT handling, and window functions. Write clear, formatted SQL with consistent naming conventions.
|
||||||
|
|
||||||
|
## Implementation Guidelines
|
||||||
|
|
||||||
|
### Schema Design
|
||||||
|
- Use snake_case for all identifiers
|
||||||
|
- Include created_at and updated_at timestamps with automatic triggers
|
||||||
|
- Define primary keys explicitly (prefer UUIDs for distributed systems)
|
||||||
|
- Add CHECK constraints for data validation
|
||||||
|
- Document tables and columns with COMMENT statements
|
||||||
|
- Consider using GENERATED columns for derived data
|
||||||
|
|
||||||
|
### Migration Safety
|
||||||
|
- Always review for backwards compatibility
|
||||||
|
- Use transactions for DDL operations when possible
|
||||||
|
- Add IF NOT EXISTS/IF EXISTS clauses for idempotency
|
||||||
|
- Create indexes CONCURRENTLY to avoid locking
|
||||||
|
- Provide rollback scripts for complex migrations
|
||||||
|
- Test migrations against production-like data volumes
|
||||||
|
|
||||||
|
### Supabase-Specific Patterns
|
||||||
|
- Design tables with RLS in mind from the start
|
||||||
|
- Use auth.uid() for user context in policies
|
||||||
|
- Leverage Supabase's built-in auth schema appropriately
|
||||||
|
- Create database functions for complex business logic
|
||||||
|
- Use triggers for real-time subscriptions efficiently
|
||||||
|
- Implement proper bucket policies for storage integration
|
||||||
|
|
||||||
|
### Performance Optimization
|
||||||
|
- Analyze query patterns with EXPLAIN ANALYZE
|
||||||
|
- Create covering indexes for frequent queries
|
||||||
|
- Use materialized views for expensive aggregations
|
||||||
|
- Implement proper pagination with cursors, not OFFSET
|
||||||
|
- Partition large tables when appropriate
|
||||||
|
- Monitor and tune autovacuum settings
|
||||||
|
|
||||||
|
### Testing with PgTAP
|
||||||
|
- Write comprehensive test suites for all database objects
|
||||||
|
- Test both positive and negative cases
|
||||||
|
- Verify constraints, triggers, and functions behavior
|
||||||
|
- Test RLS policies with different user contexts
|
||||||
|
- Include performance regression tests
|
||||||
|
- Ensure tests are idempotent and isolated
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
When providing database code, you will:
|
||||||
|
1. Include clear comments explaining design decisions
|
||||||
|
2. Provide both the migration UP and DOWN scripts
|
||||||
|
3. Include relevant indexes and constraints
|
||||||
|
4. Add PgTAP tests for new functionality
|
||||||
|
5. Document any assumptions or prerequisites
|
||||||
|
6. Highlight potential performance implications
|
||||||
|
7. Suggest monitoring queries for production
|
||||||
|
|
||||||
|
## Quality Checks
|
||||||
|
|
||||||
|
Before finalizing any database code, you will verify:
|
||||||
|
- No data loss scenarios exist
|
||||||
|
- All foreign keys have appropriate indexes
|
||||||
|
- RLS policies cover all access patterns
|
||||||
|
- No N+1 query problems are introduced
|
||||||
|
- Naming is consistent with existing schema
|
||||||
|
- Migration is reversible or clearly marked as irreversible
|
||||||
|
- Tests cover edge cases and error conditions
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
You will anticipate and handle:
|
||||||
|
- Concurrent modification scenarios
|
||||||
|
- Constraint violation recovery strategies
|
||||||
|
- Transaction deadlock prevention
|
||||||
|
- Connection pool exhaustion
|
||||||
|
- Large data migration strategies
|
||||||
|
- Backup and recovery procedures
|
||||||
|
|
||||||
|
When reviewing existing code, you will identify issues related to security vulnerabilities, performance bottlenecks, data integrity risks, missing indexes, improper transaction boundaries, and suggest specific, actionable improvements with example code.
|
||||||
|
|
||||||
|
You communicate technical concepts clearly, providing rationale for all recommendations and trade-offs for different approaches. You stay current with PostgreSQL and Supabase latest features and best practices.
|
||||||
95
.claude/agents/react-form-builder.md
Normal file
95
.claude/agents/react-form-builder.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
---
|
||||||
|
name: react-form-builder
|
||||||
|
description: MUST USE this agent when you need to create or modify client-side forms in React applications. MUST follow best practices for react-hook-form, @kit/ui/form components, and server actions integration. This includes forms with validation, error handling, loading states, and proper TypeScript typing. <example>Context: The user needs to create a form for user registration with email and password fields. user: "Create a registration form with email and password validation" assistant: "I'll use the react-form-builder agent to create a properly structured form with react-hook-form and server action integration" <commentary>Since the user needs a client-side form with validation, use the react-form-builder agent to ensure best practices are followed.</commentary></example> <example>Context: The user wants to add a form for updating user profile information. user: "I need a form to update user profile with name, bio, and avatar fields" assistant: "Let me use the react-form-builder agent to create a profile update form following the established patterns" <commentary>The user is requesting a form component, so the react-form-builder agent should be used to ensure proper implementation with react-hook-form and server actions.</commentary></example> <example>Context: The user has a broken form that needs fixing. user: "My form isn't handling errors properly when the server action fails" assistant: "I'll use the react-form-builder agent to review and fix the error handling in your form" <commentary>Since this involves fixing form-specific issues related to server actions and error handling, the react-form-builder agent is appropriate.</commentary></example>
|
||||||
|
model: sonnet
|
||||||
|
color: yellow
|
||||||
|
---
|
||||||
|
|
||||||
|
You are an expert React form architect specializing in building robust, accessible, and type-safe forms using react-hook-form, @kit/ui/form components, and Next.js server actions. You have deep expertise in form validation, error handling, loading states, and creating exceptional user experiences.
|
||||||
|
|
||||||
|
**Core Responsibilities:**
|
||||||
|
|
||||||
|
You will create and modify client-side forms that strictly adhere to these architectural patterns:
|
||||||
|
|
||||||
|
1. **Form Structure Requirements:**
|
||||||
|
- Always use `useForm` from react-hook-form WITHOUT redundant generic types when using zodResolver
|
||||||
|
- Implement Zod schemas for validation, stored in `_lib/schemas/` directory
|
||||||
|
- Use `@kit/ui/form` components (Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage)
|
||||||
|
- Handle loading states with `useTransition` hook
|
||||||
|
- Implement proper error handling with try/catch blocks
|
||||||
|
|
||||||
|
2. **Server Action Integration:**
|
||||||
|
- Call server actions within `startTransition` for proper loading states
|
||||||
|
- Handle redirect errors using `isRedirectError` from 'next/dist/client/components/redirect-error'
|
||||||
|
- Display error states using Alert components from '@kit/ui/alert'
|
||||||
|
- Ensure server actions are imported from dedicated server files
|
||||||
|
|
||||||
|
3. **Code Organization Pattern:**
|
||||||
|
```
|
||||||
|
_lib/
|
||||||
|
├── schemas/
|
||||||
|
│ └── feature.schema.ts # Shared Zod schemas
|
||||||
|
├── server/
|
||||||
|
│ └── server-actions.ts # Server actions
|
||||||
|
└── client/
|
||||||
|
└── forms.tsx # Form components
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Import Guidelines:**
|
||||||
|
- Toast notifications: `import { toast } from '@kit/ui/sonner'`
|
||||||
|
- Form components: `import { Form, FormField, ... } from '@kit/ui/form'`
|
||||||
|
- Always check @kit/ui for components before using external packages
|
||||||
|
- Use `Trans` component from '@kit/ui/trans' for internationalization
|
||||||
|
|
||||||
|
5. **Best Practices You Must Follow:**
|
||||||
|
- Add `data-test` attributes for E2E testing on form elements and submit buttons
|
||||||
|
- Use `reValidateMode: 'onChange'` and `mode: 'onChange'` for responsive validation
|
||||||
|
- Implement proper TypeScript typing without using `any`
|
||||||
|
- Handle both success and error states gracefully
|
||||||
|
- Use `If` component from '@kit/ui/if' for conditional rendering
|
||||||
|
- Disable submit buttons during pending states
|
||||||
|
- Include FormDescription for user guidance
|
||||||
|
- Use Dialog components from '@kit/ui/dialog' when forms are in modals
|
||||||
|
|
||||||
|
6. **State Management:**
|
||||||
|
- Use `useState` for error states
|
||||||
|
- Use `useTransition` for pending states
|
||||||
|
- Avoid multiple separate useState calls - prefer single state objects when appropriate
|
||||||
|
- Never use useEffect unless absolutely necessary and justified
|
||||||
|
|
||||||
|
7. **Validation Patterns:**
|
||||||
|
- Create reusable Zod schemas that can be shared between client and server
|
||||||
|
- Use schema.refine() for custom validation logic
|
||||||
|
- Provide clear, user-friendly error messages
|
||||||
|
- Implement field-level validation with proper error display
|
||||||
|
|
||||||
|
8. **Error Handling Template:**
|
||||||
|
```typescript
|
||||||
|
const onSubmit = (data: FormData) => {
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await serverAction(data);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isRedirectError(error)) {
|
||||||
|
setError(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
9. **Type Safety:**
|
||||||
|
- Let zodResolver infer types - don't add redundant generics
|
||||||
|
- Export schema types when needed for reuse
|
||||||
|
- Ensure all form fields have proper typing
|
||||||
|
|
||||||
|
10. **Accessibility and UX:**
|
||||||
|
- Always include FormLabel for screen readers
|
||||||
|
- Provide helpful FormDescription text
|
||||||
|
- Show clear error messages with FormMessage
|
||||||
|
- Implement loading indicators during form submission
|
||||||
|
- Use semantic HTML and ARIA attributes where appropriate
|
||||||
|
|
||||||
|
When creating forms, you will analyze requirements and produce complete, production-ready implementations that handle all edge cases, provide excellent user feedback, and maintain consistency with the codebase's established patterns. You prioritize type safety, reusability, and maintainability in every form you create.
|
||||||
|
|
||||||
|
Always verify that UI components exist in @kit/ui before importing from external packages, and ensure your forms integrate seamlessly with the project's internationalization system using Trans components.
|
||||||
219
AGENTS.md
219
AGENTS.md
@@ -1,4 +1,4 @@
|
|||||||
This file provides guidance to AI Agents when working with code in this repository.
|
This file provides guidance to Claude Code when working with code in this repository.
|
||||||
|
|
||||||
## Core Technologies
|
## Core Technologies
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ This file provides guidance to AI Agents when working with code in this reposito
|
|||||||
- **Supabase** for database, auth, and storage
|
- **Supabase** for database, auth, and storage
|
||||||
- **React 19**
|
- **React 19**
|
||||||
- **TypeScript**
|
- **TypeScript**
|
||||||
- **Tailwind CSS 4** and Shadcn UI
|
- **Tailwind CSS 4**, Shadcn UI, Lucide React
|
||||||
- **Turborepo**
|
- **Turborepo**
|
||||||
|
|
||||||
## Monorepo Structure
|
## Monorepo Structure
|
||||||
@@ -16,7 +16,6 @@ This file provides guidance to AI Agents when working with code in this reposito
|
|||||||
- `apps/e2e` - Playwright end-to-end tests
|
- `apps/e2e` - Playwright end-to-end tests
|
||||||
- `packages/features/*` - Feature packages
|
- `packages/features/*` - Feature packages
|
||||||
- `packages/` - Shared packages and utilities
|
- `packages/` - Shared packages and utilities
|
||||||
- `tooling/` - Build tools and development scripts
|
|
||||||
|
|
||||||
## Multi-Tenant Architecture
|
## Multi-Tenant Architecture
|
||||||
|
|
||||||
@@ -31,14 +30,13 @@ Data associates with accounts via foreign keys for proper access control.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm dev # Start all apps
|
pnpm dev # Start all apps
|
||||||
pnpm --filter web dev # Main app (port 3000)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Database Operations
|
### Database Operations
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm supabase:web:start # Start Supabase locally
|
pnpm supabase:web:start # Start Supabase locally
|
||||||
pnpm --filter web supabase migration up # Apply new migrations
|
pnpm --filter web supabase migrations up # Apply new migrations
|
||||||
pnpm supabase:web:reset # Reset with latest schema (clean rebuild)
|
pnpm supabase:web:reset # Reset with latest schema (clean rebuild)
|
||||||
pnpm supabase:web:typegen # Generate TypeScript types
|
pnpm supabase:web:typegen # Generate TypeScript types
|
||||||
pnpm --filter web supabase:db:diff # Create migration
|
pnpm --filter web supabase:db:diff # Create migration
|
||||||
@@ -46,33 +44,6 @@ pnpm --filter web supabase:db:diff # Create migration
|
|||||||
|
|
||||||
The typegen command must be run after applying migrations or resetting the database.
|
The typegen command must be run after applying migrations or resetting the database.
|
||||||
|
|
||||||
## Database Workflow - CRITICAL SEQUENCE ⚠️
|
|
||||||
|
|
||||||
When adding new database features, ALWAYS follow this exact order:
|
|
||||||
|
|
||||||
1. **Create/modify schema file** in `apps/web/supabase/schemas/XX-feature.sql`
|
|
||||||
2. **Generate migration**: `pnpm --filter web supabase:db:diff -f <migration_name>`
|
|
||||||
3. **Apply changes**: `pnpm --filter web supabase migration up` (or `pnpm supabase:web:reset` for clean rebuild)
|
|
||||||
4. **Generate types**: `pnpm supabase:web:typegen`
|
|
||||||
5. **Verify types exist** before using in code
|
|
||||||
|
|
||||||
⚠️ **NEVER skip step 2** - schema files alone don't create tables! The migration step is required to apply changes to the database.
|
|
||||||
|
|
||||||
**Migration vs Reset**:
|
|
||||||
- Use `migration up` for normal development (applies only new migrations)
|
|
||||||
- Use `reset` when you need a clean database state or have schema conflicts
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm format:fix
|
|
||||||
pnpm lint:fix
|
|
||||||
pnpm typecheck
|
|
||||||
```
|
|
||||||
|
|
||||||
- Run the typecheck command regularly to ensure your code is type-safe.
|
|
||||||
- Run the linter and the formatter when your task is complete.
|
|
||||||
|
|
||||||
## Typescript
|
## Typescript
|
||||||
|
|
||||||
- Write clean, clear, well-designed, explicit TypeScript
|
- Write clean, clear, well-designed, explicit TypeScript
|
||||||
@@ -82,7 +53,7 @@ pnpm typecheck
|
|||||||
- You must avoid using `any`
|
- You must avoid using `any`
|
||||||
- Handle errors gracefully using try/catch and appropriate error types
|
- Handle errors gracefully using try/catch and appropriate error types
|
||||||
- Use service pattern for server-side APIs
|
- Use service pattern for server-side APIs
|
||||||
- Add `server-only` to code that is exclusively server-side
|
- Add `import 'server-only';` to code that is exclusively server-side
|
||||||
- Never mix client and server imports from a file or a package
|
- Never mix client and server imports from a file or a package
|
||||||
- Extract self-contained classes/utilities (ex. algortihmic code) from classes that cross the network boundary
|
- Extract self-contained classes/utilities (ex. algortihmic code) from classes that cross the network boundary
|
||||||
|
|
||||||
@@ -101,74 +72,132 @@ pnpm typecheck
|
|||||||
## Next.js
|
## Next.js
|
||||||
|
|
||||||
- Use `enhanceAction` for Server Actions
|
- Use `enhanceAction` for Server Actions
|
||||||
|
- Use `use server` in server actions files
|
||||||
- Use `enhanceRouteHandler` for API Routes
|
- Use `enhanceRouteHandler` for API Routes
|
||||||
- Export page components using the `withI18n` utility
|
- Export page components using the `withI18n` utility
|
||||||
- Add well-written page metadata to pages
|
- Add well-written page metadata to pages
|
||||||
- Redirect using `redirect` follwing a server action instead of using client-side router
|
- Redirect using `redirect` following a server action instead of using client-side router
|
||||||
- Since `redirect` throws an error, handle `catch` block using `isRedirectError` from `next/dist/client/components/redirect-error`
|
- Since `redirect` throws an error, handle `catch` block using `isRedirectError` from `next/dist/client/components/redirect-error` in client-side forms when calling the server action
|
||||||
|
|
||||||
|
|
||||||
|
## Data Fetching Architecture
|
||||||
|
|
||||||
|
Makerkit uses a clear separation between data fetching and mutations:
|
||||||
|
|
||||||
|
### Server Components with Loaders (Reading Data)
|
||||||
|
|
||||||
|
**Pattern**: Use async server components that call loader functions for initial data fetching.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Page component (apps/web/app/home/[account]/page.tsx)
|
||||||
|
async function TeamAccountPage({ params }: Props) {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const slug = (await params).account;
|
||||||
|
|
||||||
|
const [projects, workspace] = await loadProjectsPageData(client, slug);
|
||||||
|
|
||||||
|
return <ProjectsList projects={projects} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loader function (_lib/server/projects-page.loader.ts)
|
||||||
|
import 'server-only';
|
||||||
|
|
||||||
|
export async function loadProjectsPageData(
|
||||||
|
client: SupabaseClient<Database>,
|
||||||
|
slug: string,
|
||||||
|
) {
|
||||||
|
return Promise.all([
|
||||||
|
loadProjects(client, slug),
|
||||||
|
loadTeamWorkspace(slug),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProjects(client: SupabaseClient<Database>, slug: string) {
|
||||||
|
const { data, error } = await client.rpc('get_team_projects', {
|
||||||
|
account_slug: slug,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return data ?? [];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server Actions (Mutations Only)
|
||||||
|
|
||||||
|
**Pattern**: Use `enhanceAction` for create/update/delete operations with schema validation.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// server-actions.ts
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import { enhanceAction } from '@kit/next/actions';
|
||||||
|
|
||||||
|
export const createProject = enhanceAction(
|
||||||
|
async (data) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const service = createProjectsService();
|
||||||
|
|
||||||
|
const response = await service.createProject(data);
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
throw response.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: response.data,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schema: CreateProjectSchema,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authorization & RLS
|
||||||
|
|
||||||
|
- **Server Components**: RLS automatically enforces access control
|
||||||
|
- **Server Actions**: RLS validates permissions on mutations
|
||||||
|
- **No manual auth checks needed** when using standard Supabase client
|
||||||
|
- **Admin client**: Only for bypassing RLS (rare cases, requires careful manual validation)
|
||||||
|
|
||||||
|
## File Organization Patterns
|
||||||
|
|
||||||
|
### Route Structure
|
||||||
|
```
|
||||||
|
apps/web/app/home/[account]/
|
||||||
|
├── page.tsx # Team dashboard
|
||||||
|
├── members/
|
||||||
|
│ ├── page.tsx # Members listing
|
||||||
|
│ └── _lib/server/ # Server-side utilities
|
||||||
|
│ └── members-page.loader.ts
|
||||||
|
├── projects/ # New feature example
|
||||||
|
│ ├── page.tsx # Projects listing
|
||||||
|
│ ├── [id]/ # Individual project
|
||||||
|
│ │ └── page.tsx # Project detail page
|
||||||
|
│ ├── _components/ # Feature-specific components
|
||||||
|
│ │ ├── project-list.tsx
|
||||||
|
│ │ └── create-project-form.tsx
|
||||||
|
│ └── _lib/
|
||||||
|
│ ├── server/ # Server-side logic
|
||||||
|
│ │ ├── projects-page.loader.ts
|
||||||
|
│ │ └── projects-server-actions.ts
|
||||||
|
│ └── schemas/ # Zod validation schemas
|
||||||
|
│ └── project.schema.ts
|
||||||
|
└── _components/ # Shared team account components
|
||||||
|
└── team-account-layout-page-header.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
- **Pages**: `page.tsx` (Next.js convention)
|
||||||
|
- **Loaders**: `{feature}-page.loader.ts`
|
||||||
|
- **Actions**: `{feature}-server-actions.ts`
|
||||||
|
- **Schemas**: `{feature}.schema.ts`
|
||||||
|
- **Components**: `kebab-case.tsx`
|
||||||
|
|
||||||
## UI Components
|
## UI Components
|
||||||
|
|
||||||
- UI Components are placed at `packages/ui`. Call MCP tool to list components to verify they exist.
|
UI Components are placed at `packages/ui`. Call MCP tool to list components to verify they exist.
|
||||||
|
|
||||||
## Form Architecture
|
|
||||||
|
|
||||||
Always organize schemas for reusability between server actions and client forms:
|
|
||||||
|
|
||||||
```
|
|
||||||
_lib/
|
|
||||||
├── schemas/
|
|
||||||
│ └── feature.schema.ts # Shared Zod schemas
|
|
||||||
├── server/
|
|
||||||
│ └── server-actions.ts # Server actions import schemas
|
|
||||||
└── client/
|
|
||||||
└── forms.tsx # Forms import same schemas
|
|
||||||
```
|
|
||||||
|
|
||||||
**Example implementation:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// _lib/schemas/project.schema.ts
|
|
||||||
export const CreateProjectSchema = z.object({
|
|
||||||
name: z.string().min(1).max(255),
|
|
||||||
description: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// _lib/server/project.mutations.ts
|
|
||||||
import { CreateProjectSchema } from '../schemas/project.schema';
|
|
||||||
|
|
||||||
export const createProjectAction = enhanceAction(
|
|
||||||
async (data) => { /* implementation */ },
|
|
||||||
{ schema: CreateProjectSchema }
|
|
||||||
);
|
|
||||||
|
|
||||||
// _components/create-project-form.tsx
|
|
||||||
import { CreateProjectSchema } from '../_lib/schemas/project.schema';
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
resolver: zodResolver(CreateProjectSchema)
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Import Guidelines - ALWAYS Check These
|
|
||||||
|
|
||||||
**UI Components**: Always check `@kit/ui` first before external packages:
|
|
||||||
- Toast notifications: `import { toast } from '@kit/ui/sonner'`
|
|
||||||
- Forms: `import { Form, FormField, ... } from '@kit/ui/form'`
|
|
||||||
- All UI components: Use MCP tool to verify: `mcp__makerkit__get_components`
|
|
||||||
|
|
||||||
**React Hook Form Pattern**:
|
|
||||||
```typescript
|
|
||||||
// ❌ WRONG - Redundant generic with resolver
|
|
||||||
const form = useForm<FormData>({
|
|
||||||
resolver: zodResolver(Schema)
|
|
||||||
});
|
|
||||||
|
|
||||||
// ✅ CORRECT - Type inference from resolver
|
|
||||||
const form = useForm({
|
|
||||||
resolver: zodResolver(Schema)
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verification Steps
|
## Verification Steps
|
||||||
|
|
||||||
|
|||||||
215
CLAUDE.md
215
CLAUDE.md
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code when working with code in this reposi
|
|||||||
- **Supabase** for database, auth, and storage
|
- **Supabase** for database, auth, and storage
|
||||||
- **React 19**
|
- **React 19**
|
||||||
- **TypeScript**
|
- **TypeScript**
|
||||||
- **Tailwind CSS 4** and Shadcn UI
|
- **Tailwind CSS 4**, Shadcn UI, Lucide React
|
||||||
- **Turborepo**
|
- **Turborepo**
|
||||||
|
|
||||||
## Monorepo Structure
|
## Monorepo Structure
|
||||||
@@ -16,7 +16,6 @@ This file provides guidance to Claude Code when working with code in this reposi
|
|||||||
- `apps/e2e` - Playwright end-to-end tests
|
- `apps/e2e` - Playwright end-to-end tests
|
||||||
- `packages/features/*` - Feature packages
|
- `packages/features/*` - Feature packages
|
||||||
- `packages/` - Shared packages and utilities
|
- `packages/` - Shared packages and utilities
|
||||||
- `tooling/` - Build tools and development scripts
|
|
||||||
|
|
||||||
## Multi-Tenant Architecture
|
## Multi-Tenant Architecture
|
||||||
|
|
||||||
@@ -31,14 +30,13 @@ Data associates with accounts via foreign keys for proper access control.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm dev # Start all apps
|
pnpm dev # Start all apps
|
||||||
pnpm --filter web dev # Main app (port 3000)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Database Operations
|
### Database Operations
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm supabase:web:start # Start Supabase locally
|
pnpm supabase:web:start # Start Supabase locally
|
||||||
pnpm --filter web supabase migration up # Apply new migrations
|
pnpm --filter web supabase migrations up # Apply new migrations
|
||||||
pnpm supabase:web:reset # Reset with latest schema (clean rebuild)
|
pnpm supabase:web:reset # Reset with latest schema (clean rebuild)
|
||||||
pnpm supabase:web:typegen # Generate TypeScript types
|
pnpm supabase:web:typegen # Generate TypeScript types
|
||||||
pnpm --filter web supabase:db:diff # Create migration
|
pnpm --filter web supabase:db:diff # Create migration
|
||||||
@@ -46,33 +44,6 @@ pnpm --filter web supabase:db:diff # Create migration
|
|||||||
|
|
||||||
The typegen command must be run after applying migrations or resetting the database.
|
The typegen command must be run after applying migrations or resetting the database.
|
||||||
|
|
||||||
## Database Workflow - CRITICAL SEQUENCE ⚠️
|
|
||||||
|
|
||||||
When adding new database features, ALWAYS follow this exact order:
|
|
||||||
|
|
||||||
1. **Create/modify schema file** in `apps/web/supabase/schemas/XX-feature.sql`
|
|
||||||
2. **Generate migration**: `pnpm --filter web supabase:db:diff -f <migration_name>`
|
|
||||||
3. **Apply changes**: `pnpm --filter web supabase migration up` (or `pnpm supabase:web:reset` for clean rebuild)
|
|
||||||
4. **Generate types**: `pnpm supabase:web:typegen`
|
|
||||||
5. **Verify types exist** before using in code
|
|
||||||
|
|
||||||
⚠️ **NEVER skip step 2** - schema files alone don't create tables! The migration step is required to apply changes to the database.
|
|
||||||
|
|
||||||
**Migration vs Reset**:
|
|
||||||
- Use `migration up` for normal development (applies only new migrations)
|
|
||||||
- Use `reset` when you need a clean database state or have schema conflicts
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm format:fix
|
|
||||||
pnpm lint:fix
|
|
||||||
pnpm typecheck
|
|
||||||
```
|
|
||||||
|
|
||||||
- Run the typecheck command regularly to ensure your code is type-safe.
|
|
||||||
- Run the linter and the formatter when your task is complete.
|
|
||||||
|
|
||||||
## Typescript
|
## Typescript
|
||||||
|
|
||||||
- Write clean, clear, well-designed, explicit TypeScript
|
- Write clean, clear, well-designed, explicit TypeScript
|
||||||
@@ -82,7 +53,7 @@ pnpm typecheck
|
|||||||
- You must avoid using `any`
|
- You must avoid using `any`
|
||||||
- Handle errors gracefully using try/catch and appropriate error types
|
- Handle errors gracefully using try/catch and appropriate error types
|
||||||
- Use service pattern for server-side APIs
|
- Use service pattern for server-side APIs
|
||||||
- Add `server-only` to code that is exclusively server-side
|
- Add `import 'server-only';` to code that is exclusively server-side
|
||||||
- Never mix client and server imports from a file or a package
|
- Never mix client and server imports from a file or a package
|
||||||
- Extract self-contained classes/utilities (ex. algortihmic code) from classes that cross the network boundary
|
- Extract self-contained classes/utilities (ex. algortihmic code) from classes that cross the network boundary
|
||||||
|
|
||||||
@@ -101,74 +72,136 @@ pnpm typecheck
|
|||||||
## Next.js
|
## Next.js
|
||||||
|
|
||||||
- Use `enhanceAction` for Server Actions
|
- Use `enhanceAction` for Server Actions
|
||||||
|
- Use `use server` in server actions files
|
||||||
- Use `enhanceRouteHandler` for API Routes
|
- Use `enhanceRouteHandler` for API Routes
|
||||||
- Export page components using the `withI18n` utility
|
- Export page components using the `withI18n` utility
|
||||||
- Add well-written page metadata to pages
|
- Add well-written page metadata to pages
|
||||||
- Redirect using `redirect` following a server action instead of using client-side router
|
- Redirect using `redirect` following a server action instead of using client-side router
|
||||||
- Since `redirect` throws an error, handle `catch` block using `isRedirectError` from `next/dist/client/components/redirect-error`
|
- Since `redirect` throws an error, handle `catch` block using `isRedirectError` from `next/dist/client/components/redirect-error` in client-side forms when calling the server action
|
||||||
|
|
||||||
|
|
||||||
|
## Data Fetching Architecture
|
||||||
|
|
||||||
|
Makerkit uses a clear separation between data fetching and mutations:
|
||||||
|
|
||||||
|
### Server Components with Loaders (Reading Data)
|
||||||
|
|
||||||
|
**Pattern**: Use async server components that call loader functions for initial data fetching.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Page component (apps/web/app/home/[account]/page.tsx)
|
||||||
|
async function TeamAccountPage({ params }: Props) {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const slug = (await params).account;
|
||||||
|
|
||||||
|
const [projects, workspace] = await loadProjectsPageData(client, slug);
|
||||||
|
|
||||||
|
return <ProjectsList projects={projects} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loader function (_lib/server/projects-page.loader.ts)
|
||||||
|
import 'server-only';
|
||||||
|
|
||||||
|
export async function loadProjectsPageData(
|
||||||
|
client: SupabaseClient<Database>,
|
||||||
|
slug: string,
|
||||||
|
) {
|
||||||
|
return Promise.all([
|
||||||
|
loadProjects(client, slug),
|
||||||
|
loadTeamWorkspace(slug),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProjects(client: SupabaseClient<Database>, slug: string) {
|
||||||
|
const { data, error } = await client.rpc('get_team_projects', {
|
||||||
|
account_slug: slug,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return data ?? [];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server Actions (Mutations Only)
|
||||||
|
|
||||||
|
**Pattern**: Use `enhanceAction` for create/update/delete operations with schema validation.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// server-actions.ts
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import { enhanceAction } from '@kit/next/actions';
|
||||||
|
|
||||||
|
export const createProject = enhanceAction(
|
||||||
|
async (data) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const service = createProjectsService();
|
||||||
|
|
||||||
|
const response = await service.createProject(data);
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
throw response.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: response.data,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schema: CreateProjectSchema,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authorization & RLS
|
||||||
|
|
||||||
|
- **Server Components**: RLS automatically enforces access control
|
||||||
|
- **Server Actions**: RLS validates permissions on mutations
|
||||||
|
- **No manual auth checks needed** when using standard Supabase client
|
||||||
|
- **Admin client**: Only for bypassing RLS (rare cases, requires careful manual validation)
|
||||||
|
|
||||||
|
## File Organization Patterns
|
||||||
|
|
||||||
|
### Route Structure
|
||||||
|
```
|
||||||
|
apps/web/app/home/[account]/
|
||||||
|
├── page.tsx # Team dashboard
|
||||||
|
├── members/
|
||||||
|
│ ├── page.tsx # Members listing
|
||||||
|
│ └── _lib/server/ # Server-side utilities
|
||||||
|
│ └── members-page.loader.ts
|
||||||
|
├── projects/ # New feature example
|
||||||
|
│ ├── page.tsx # Projects listing
|
||||||
|
│ ├── [id]/ # Individual project
|
||||||
|
│ │ └── page.tsx # Project detail page
|
||||||
|
│ ├── _components/ # Feature-specific components
|
||||||
|
│ │ ├── project-list.tsx
|
||||||
|
│ │ └── create-project-form.tsx
|
||||||
|
│ └── _lib/
|
||||||
|
│ ├── server/ # Server-side logic
|
||||||
|
│ │ ├── projects-page.loader.ts
|
||||||
|
│ │ └── projects-server-actions.ts
|
||||||
|
│ └── schemas/ # Zod validation schemas
|
||||||
|
│ └── project.schema.ts
|
||||||
|
└── _components/ # Shared team account components
|
||||||
|
└── team-account-layout-page-header.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
- **Pages**: `page.tsx` (Next.js convention)
|
||||||
|
- **Loaders**: `{feature}-page.loader.ts`
|
||||||
|
- **Actions**: `{feature}-server-actions.ts`
|
||||||
|
- **Schemas**: `{feature}.schema.ts`
|
||||||
|
- **Components**: `kebab-case.tsx`
|
||||||
|
|
||||||
## UI Components
|
## UI Components
|
||||||
|
|
||||||
- UI Components are placed at `packages/ui`. Call MCP tool to list components to verify they exist.
|
UI Components are placed at `packages/ui`. Call MCP tool to list components to verify they exist.
|
||||||
|
|
||||||
## Form Architecture
|
## Delegate to Agents
|
||||||
|
|
||||||
Always organize schemas for reusability between server actions and client forms:
|
Please use the Task tool to delegate suitable tasks to specialized sub-agents for best handling the task at hand.
|
||||||
|
|
||||||
```
|
|
||||||
_lib/
|
|
||||||
├── schemas/
|
|
||||||
│ └── feature.schema.ts # Shared Zod schemas
|
|
||||||
├── server/
|
|
||||||
│ └── server-actions.ts # Server actions import schemas
|
|
||||||
└── client/
|
|
||||||
└── forms.tsx # Forms import same schemas
|
|
||||||
```
|
|
||||||
|
|
||||||
**Example implementation:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// _lib/schemas/project.schema.ts
|
|
||||||
export const CreateProjectSchema = z.object({
|
|
||||||
name: z.string().min(1).max(255),
|
|
||||||
description: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// _lib/server/project.mutations.ts
|
|
||||||
import { CreateProjectSchema } from '../schemas/project.schema';
|
|
||||||
|
|
||||||
export const createProjectAction = enhanceAction(
|
|
||||||
async (data) => { /* implementation */ },
|
|
||||||
{ schema: CreateProjectSchema }
|
|
||||||
);
|
|
||||||
|
|
||||||
// _components/create-project-form.tsx
|
|
||||||
import { CreateProjectSchema } from '../_lib/schemas/project.schema';
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
resolver: zodResolver(CreateProjectSchema)
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Import Guidelines - ALWAYS Check These
|
|
||||||
|
|
||||||
**UI Components**: Always check `@kit/ui` first before external packages:
|
|
||||||
- Toast notifications: `import { toast } from '@kit/ui/sonner'`
|
|
||||||
- Forms: `import { Form, FormField, ... } from '@kit/ui/form'`
|
|
||||||
- All UI components: Use MCP tool to verify: `mcp__makerkit__get_components`
|
|
||||||
|
|
||||||
**React Hook Form Pattern**:
|
|
||||||
```typescript
|
|
||||||
// ❌ WRONG - Redundant generic with resolver
|
|
||||||
const form = useForm<FormData>({
|
|
||||||
resolver: zodResolver(Schema)
|
|
||||||
});
|
|
||||||
|
|
||||||
// ✅ CORRECT - Type inference from resolver
|
|
||||||
const form = useForm({
|
|
||||||
resolver: zodResolver(Schema)
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verification Steps
|
## Verification Steps
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { loadPRDs } from '../_lib/server/prd-loader';
|
||||||
|
import { McpServerTabs } from './mcp-server-tabs';
|
||||||
|
import { PRDManagerClient } from './prd-manager-client';
|
||||||
|
|
||||||
|
export async function McpServerInterface() {
|
||||||
|
const initialPrds = await loadPRDs();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<McpServerTabs
|
||||||
|
prdManagerContent={<PRDManagerClient initialPrds={initialPrds} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
apps/dev-tool/app/mcp-server/_components/mcp-server-tabs.tsx
Normal file
53
apps/dev-tool/app/mcp-server/_components/mcp-server-tabs.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { DatabaseIcon, FileTextIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kit/ui/tabs';
|
||||||
|
|
||||||
|
interface McpServerTabsProps {
|
||||||
|
prdManagerContent: React.ReactNode;
|
||||||
|
databaseToolsContent?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function McpServerTabs({
|
||||||
|
prdManagerContent,
|
||||||
|
databaseToolsContent,
|
||||||
|
}: McpServerTabsProps) {
|
||||||
|
return (
|
||||||
|
<div className="h-full">
|
||||||
|
<Tabs defaultValue="database-tools" className="flex h-full flex-col">
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger
|
||||||
|
value="database-tools"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<DatabaseIcon className="h-4 w-4" />
|
||||||
|
Database Tools
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="prd-manager" className="flex items-center gap-2">
|
||||||
|
<FileTextIcon className="h-4 w-4" />
|
||||||
|
PRD Manager
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="database-tools" className="flex-1 space-y-4">
|
||||||
|
{databaseToolsContent || (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<DatabaseIcon className="text-muted-foreground mx-auto h-12 w-12" />
|
||||||
|
<h3 className="mt-4 text-lg font-semibold">Database Tools</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Explore database schemas, tables, functions, and enums
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="prd-manager" className="flex-1 space-y-4">
|
||||||
|
{prdManagerContent}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
188
apps/dev-tool/app/mcp-server/_components/prd-manager-client.tsx
Normal file
188
apps/dev-tool/app/mcp-server/_components/prd-manager-client.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { CalendarIcon, FileTextIcon, PlusIcon, SearchIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@kit/ui/dialog';
|
||||||
|
import { Input } from '@kit/ui/input';
|
||||||
|
import { Progress } from '@kit/ui/progress';
|
||||||
|
|
||||||
|
import type { CreatePRDData } from '../_lib/schemas/create-prd.schema';
|
||||||
|
import { createPRDAction } from '../_lib/server/prd-server-actions';
|
||||||
|
import { CreatePRDForm } from './create-prd-form';
|
||||||
|
|
||||||
|
interface PRDSummary {
|
||||||
|
filename: string;
|
||||||
|
title: string;
|
||||||
|
lastUpdated: string;
|
||||||
|
progress: number;
|
||||||
|
totalStories: number;
|
||||||
|
completedStories: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PRDManagerClientProps {
|
||||||
|
initialPrds: PRDSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PRDManagerClient({ initialPrds }: PRDManagerClientProps) {
|
||||||
|
const [prds, setPrds] = useState<PRDSummary[]>(initialPrds);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
|
||||||
|
const handleCreatePRD = async (data: CreatePRDData) => {
|
||||||
|
const result = await createPRDAction(data);
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const newPRD: PRDSummary = {
|
||||||
|
filename: result.data.filename,
|
||||||
|
title: result.data.title,
|
||||||
|
lastUpdated: result.data.lastUpdated,
|
||||||
|
progress: result.data.progress,
|
||||||
|
totalStories: result.data.totalStories,
|
||||||
|
completedStories: result.data.completedStories,
|
||||||
|
};
|
||||||
|
|
||||||
|
setPrds((prev) => [...prev, newPRD]);
|
||||||
|
setShowCreateForm(false);
|
||||||
|
|
||||||
|
// Note: In a production app, you might want to trigger a router.refresh()
|
||||||
|
// to reload the server component and get the most up-to-date data
|
||||||
|
} else {
|
||||||
|
// Error handling will be managed by the form component via the action result
|
||||||
|
throw new Error(result.error || 'Failed to create PRD');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredPrds = prds.filter(
|
||||||
|
(prd) =>
|
||||||
|
prd.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
prd.filename.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header with search and create button */}
|
||||||
|
<div className="flex w-full flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<SearchIcon className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Search PRDs by title or filename..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowCreateForm(true)}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
Create New PRD
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PRD List */}
|
||||||
|
{filteredPrds.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex h-32 items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<FileTextIcon className="text-muted-foreground mx-auto h-8 w-8" />
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
{searchTerm ? 'No PRDs match your search' : 'No PRDs found'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!searchTerm && (
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
onClick={() => setShowCreateForm(true)}
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
|
Create your first PRD
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{filteredPrds.map((prd) => (
|
||||||
|
<Link
|
||||||
|
key={prd.filename}
|
||||||
|
href={`/mcp-server/prds/${prd.filename}`}
|
||||||
|
className="block"
|
||||||
|
>
|
||||||
|
<Card className="cursor-pointer transition-shadow hover:shadow-md">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-start gap-2 text-sm">
|
||||||
|
<FileTextIcon className="text-muted-foreground mt-0.5 h-4 w-4" />
|
||||||
|
<span className="line-clamp-2">{prd.title}</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{/* Progress */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-muted-foreground flex justify-between text-xs">
|
||||||
|
<span>Progress</span>
|
||||||
|
<span>
|
||||||
|
{prd.completedStories}/{prd.totalStories} stories
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={prd.progress} className="h-2" />
|
||||||
|
<div className="text-right text-xs font-medium">
|
||||||
|
{prd.progress}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<div className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||||
|
<CalendarIcon className="h-3 w-3" />
|
||||||
|
<span>Updated {prd.lastUpdated}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filename */}
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{prd.filename}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create PRD Form Modal */}
|
||||||
|
{showCreateForm && (
|
||||||
|
<Dialog open={showCreateForm} onOpenChange={setShowCreateForm}>
|
||||||
|
<DialogContent className="max-w-4xl overflow-y-hidden">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New PRD</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="overflow-y-auto p-0.5"
|
||||||
|
style={{
|
||||||
|
maxHeight: '800px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CreatePRDForm onSubmit={handleCreatePRD} />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
apps/dev-tool/app/mcp-server/_components/user-story-display.tsx
Normal file
172
apps/dev-tool/app/mcp-server/_components/user-story-display.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CheckCircleIcon,
|
||||||
|
CircleIcon,
|
||||||
|
ClockIcon,
|
||||||
|
EyeIcon,
|
||||||
|
PlayIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
import { Separator } from '@kit/ui/separator';
|
||||||
|
import { cn } from '@kit/ui/utils';
|
||||||
|
|
||||||
|
interface CustomPhase {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
color: string;
|
||||||
|
order: number;
|
||||||
|
userStoryIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserStory {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
userStory: string;
|
||||||
|
businessValue: string;
|
||||||
|
acceptanceCriteria: string[];
|
||||||
|
priority: 'P0' | 'P1' | 'P2' | 'P3';
|
||||||
|
status:
|
||||||
|
| 'not_started'
|
||||||
|
| 'research'
|
||||||
|
| 'in_progress'
|
||||||
|
| 'review'
|
||||||
|
| 'completed'
|
||||||
|
| 'blocked';
|
||||||
|
notes?: string;
|
||||||
|
estimatedComplexity?: string;
|
||||||
|
dependencies?: string[];
|
||||||
|
completedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserStoryDisplayReadOnlyProps {
|
||||||
|
userStories: UserStory[];
|
||||||
|
customPhases?: CustomPhase[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityLabels = {
|
||||||
|
P0: { label: 'Critical', color: 'destructive' as const },
|
||||||
|
P1: { label: 'High', color: 'default' as const },
|
||||||
|
P2: { label: 'Medium', color: 'secondary' as const },
|
||||||
|
P3: { label: 'Low', color: 'outline' as const },
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusIcons = {
|
||||||
|
not_started: CircleIcon,
|
||||||
|
research: EyeIcon,
|
||||||
|
in_progress: PlayIcon,
|
||||||
|
review: ClockIcon,
|
||||||
|
completed: CheckCircleIcon,
|
||||||
|
blocked: XCircleIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels = {
|
||||||
|
not_started: 'Not Started',
|
||||||
|
research: 'Research',
|
||||||
|
in_progress: 'In Progress',
|
||||||
|
review: 'Review',
|
||||||
|
completed: 'Completed',
|
||||||
|
blocked: 'Blocked',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors = {
|
||||||
|
not_started: 'text-muted-foreground',
|
||||||
|
research: 'text-blue-600',
|
||||||
|
in_progress: 'text-yellow-600',
|
||||||
|
review: 'text-purple-600',
|
||||||
|
completed: 'text-green-600',
|
||||||
|
blocked: 'text-red-600',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UserStoryDisplay({
|
||||||
|
userStories,
|
||||||
|
}: UserStoryDisplayReadOnlyProps) {
|
||||||
|
const renderUserStory = (story: UserStory) => {
|
||||||
|
return (
|
||||||
|
<Card key={story.id}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-start gap-4 text-sm">
|
||||||
|
<span className="line-clamp-2">
|
||||||
|
{story.id} - {story.title}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge className={statusColors[story.status]} variant={'outline'}>
|
||||||
|
{statusLabels[story.status]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<p className="text-muted-foreground line-clamp-2 text-sm">
|
||||||
|
{story.businessValue}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{story.acceptanceCriteria.length} criteria
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Acceptance Criteria */}
|
||||||
|
{story.acceptanceCriteria.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h5 className="text-xs font-medium">Acceptance Criteria:</h5>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{story.acceptanceCriteria.map((criterion, index) => (
|
||||||
|
<li key={index} className="flex items-start gap-1 text-xs">
|
||||||
|
<span className="text-muted-foreground">•</span>
|
||||||
|
<span className="text-muted-foreground line-clamp-1">
|
||||||
|
{criterion}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">User Stories</h3>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
View user stories and track progress
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{userStories.map(renderUserStory)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{userStories.length === 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex h-32 items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<CircleIcon className="text-muted-foreground mx-auto h-8 w-8" />
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
No user stories yet
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const CreatePRDSchema = z.object({
|
||||||
|
title: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Title is required')
|
||||||
|
.max(200, 'Title must be less than 200 characters'),
|
||||||
|
overview: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Overview is required')
|
||||||
|
.max(1000, 'Overview must be less than 1000 characters'),
|
||||||
|
problemStatement: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Problem statement is required')
|
||||||
|
.max(1000, 'Problem statement must be less than 1000 characters'),
|
||||||
|
marketOpportunity: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Market opportunity is required')
|
||||||
|
.max(1000, 'Market opportunity must be less than 1000 characters'),
|
||||||
|
targetUsers: z
|
||||||
|
.array(z.string().min(1, 'Target user cannot be empty'))
|
||||||
|
.min(1, 'At least one target user is required'),
|
||||||
|
solutionDescription: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Solution description is required')
|
||||||
|
.max(1000, 'Solution description must be less than 1000 characters'),
|
||||||
|
keyFeatures: z
|
||||||
|
.array(z.string().min(1, 'Feature cannot be empty'))
|
||||||
|
.min(1, 'At least one key feature is required'),
|
||||||
|
successMetrics: z
|
||||||
|
.array(z.string().min(1, 'Metric cannot be empty'))
|
||||||
|
.min(1, 'At least one success metric is required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreatePRDData = z.infer<typeof CreatePRDSchema>;
|
||||||
48
apps/dev-tool/app/mcp-server/_lib/server/prd-loader.ts
Normal file
48
apps/dev-tool/app/mcp-server/_lib/server/prd-loader.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { relative } from 'node:path';
|
||||||
|
|
||||||
|
import { PRDManager } from '@kit/mcp-server/prd-manager';
|
||||||
|
|
||||||
|
interface PRDSummary {
|
||||||
|
filename: string;
|
||||||
|
title: string;
|
||||||
|
lastUpdated: string;
|
||||||
|
progress: number;
|
||||||
|
totalStories: number;
|
||||||
|
completedStories: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPRDs(): Promise<PRDSummary[]> {
|
||||||
|
try {
|
||||||
|
PRDManager.setRootPath(relative(process.cwd(), '../..'));
|
||||||
|
|
||||||
|
// Use the actual PRDManager to list PRDs
|
||||||
|
const prdFiles = await PRDManager.listPRDs();
|
||||||
|
|
||||||
|
const prdSummaries: PRDSummary[] = [];
|
||||||
|
|
||||||
|
// Load each PRD to get its details
|
||||||
|
for (const filename of prdFiles) {
|
||||||
|
try {
|
||||||
|
const content = await PRDManager.getPRDContent(filename);
|
||||||
|
const prd = JSON.parse(content);
|
||||||
|
|
||||||
|
prdSummaries.push({
|
||||||
|
filename,
|
||||||
|
title: prd.introduction.title,
|
||||||
|
lastUpdated: prd.metadata.lastUpdated,
|
||||||
|
progress: prd.progress.overall,
|
||||||
|
totalStories: prd.progress.total,
|
||||||
|
completedStories: prd.progress.completed,
|
||||||
|
});
|
||||||
|
} catch (prdError) {
|
||||||
|
console.error(`Failed to load PRD ${filename}:`, prdError);
|
||||||
|
// Continue with other PRDs even if one fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return prdSummaries;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load PRDs:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
78
apps/dev-tool/app/mcp-server/_lib/server/prd-page.loader.ts
Normal file
78
apps/dev-tool/app/mcp-server/_lib/server/prd-page.loader.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import 'server-only';
|
||||||
|
|
||||||
|
import { relative } from 'node:path';
|
||||||
|
|
||||||
|
import { PRDManager } from '@kit/mcp-server/prd-manager';
|
||||||
|
|
||||||
|
export interface CustomPhase {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
color: string;
|
||||||
|
order: number;
|
||||||
|
userStoryIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PRDData {
|
||||||
|
introduction: {
|
||||||
|
title: string;
|
||||||
|
overview: string;
|
||||||
|
lastUpdated: string;
|
||||||
|
};
|
||||||
|
problemStatement: {
|
||||||
|
problem: string;
|
||||||
|
marketOpportunity: string;
|
||||||
|
targetUsers: string[];
|
||||||
|
};
|
||||||
|
solutionOverview: {
|
||||||
|
description: string;
|
||||||
|
keyFeatures: string[];
|
||||||
|
successMetrics: string[];
|
||||||
|
};
|
||||||
|
userStories: Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
userStory: string;
|
||||||
|
businessValue: string;
|
||||||
|
acceptanceCriteria: string[];
|
||||||
|
priority: 'P0' | 'P1' | 'P2' | 'P3';
|
||||||
|
status:
|
||||||
|
| 'not_started'
|
||||||
|
| 'research'
|
||||||
|
| 'in_progress'
|
||||||
|
| 'review'
|
||||||
|
| 'completed'
|
||||||
|
| 'blocked';
|
||||||
|
notes?: string;
|
||||||
|
estimatedComplexity?: string;
|
||||||
|
dependencies?: string[];
|
||||||
|
completedAt?: string;
|
||||||
|
}>;
|
||||||
|
customPhases?: CustomPhase[];
|
||||||
|
metadata: {
|
||||||
|
version: string;
|
||||||
|
created: string;
|
||||||
|
lastUpdated: string;
|
||||||
|
approver: string;
|
||||||
|
};
|
||||||
|
progress: {
|
||||||
|
overall: number;
|
||||||
|
completed: number;
|
||||||
|
total: number;
|
||||||
|
blocked: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPRDPageData(filename: string): Promise<PRDData> {
|
||||||
|
try {
|
||||||
|
PRDManager.setRootPath(relative(process.cwd(), '../..'));
|
||||||
|
|
||||||
|
const content = await PRDManager.getPRDContent(filename);
|
||||||
|
|
||||||
|
return JSON.parse(content) as PRDData;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to load PRD ${filename}:`, error);
|
||||||
|
|
||||||
|
throw new Error(`PRD not found: ${filename}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DatabaseIcon,
|
||||||
|
FunctionSquareIcon,
|
||||||
|
LayersIcon,
|
||||||
|
ListIcon,
|
||||||
|
SearchIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
import { Input } from '@kit/ui/input';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kit/ui/tabs';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
DatabaseEnum,
|
||||||
|
DatabaseFunction,
|
||||||
|
DatabaseTable,
|
||||||
|
SchemaFile,
|
||||||
|
} from '../_lib/server/database-tools.loader';
|
||||||
|
import { EnumBrowser } from './enum-browser';
|
||||||
|
import { FunctionBrowser } from './function-browser';
|
||||||
|
import { SchemaExplorer } from './schema-explorer';
|
||||||
|
import { TableBrowser } from './table-browser';
|
||||||
|
|
||||||
|
interface DatabaseToolsData {
|
||||||
|
schemaFiles: SchemaFile[];
|
||||||
|
tables: DatabaseTable[];
|
||||||
|
functions: DatabaseFunction[];
|
||||||
|
enums: DatabaseEnum[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DatabaseToolsInterfaceProps {
|
||||||
|
searchTerm: string;
|
||||||
|
databaseData: DatabaseToolsData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DatabaseToolsInterface({
|
||||||
|
searchTerm: initialSearchTerm,
|
||||||
|
databaseData,
|
||||||
|
}: DatabaseToolsInterfaceProps) {
|
||||||
|
const [searchTerm, setSearchTerm] = useState(initialSearchTerm);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<DatabaseIcon className="h-6 w-6" />
|
||||||
|
<h1 className="text-2xl font-bold">Database Tools</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Explore database schemas, tables, functions, and enums
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative max-w-md">
|
||||||
|
<SearchIcon className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search tables, functions, schemas..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Database Tools Tabs */}
|
||||||
|
<Tabs defaultValue="schemas" className="space-y-6">
|
||||||
|
<TabsList className="grid w-full grid-cols-4">
|
||||||
|
<TabsTrigger value="schemas" className="flex items-center gap-2">
|
||||||
|
<LayersIcon className="h-4 w-4" />
|
||||||
|
Schemas
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="tables" className="flex items-center gap-2">
|
||||||
|
<DatabaseIcon className="h-4 w-4" />
|
||||||
|
Tables
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="functions" className="flex items-center gap-2">
|
||||||
|
<FunctionSquareIcon className="h-4 w-4" />
|
||||||
|
Functions
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="enums" className="flex items-center gap-2">
|
||||||
|
<ListIcon className="h-4 w-4" />
|
||||||
|
Enums
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="schemas" className="space-y-4">
|
||||||
|
<SchemaExplorer
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
schemaFiles={databaseData.schemaFiles}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="tables" className="space-y-4">
|
||||||
|
<TableBrowser searchTerm={searchTerm} tables={databaseData.tables} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="functions" className="space-y-4">
|
||||||
|
<FunctionBrowser
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
functions={databaseData.functions}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="enums" className="space-y-4">
|
||||||
|
<EnumBrowser searchTerm={searchTerm} enums={databaseData.enums} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BellIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
CircleDollarSignIcon,
|
||||||
|
CreditCardIcon,
|
||||||
|
DatabaseIcon,
|
||||||
|
ListIcon,
|
||||||
|
ShieldIcon,
|
||||||
|
TagIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@kit/ui/dialog';
|
||||||
|
import { Separator } from '@kit/ui/separator';
|
||||||
|
|
||||||
|
interface DatabaseEnum {
|
||||||
|
name: string;
|
||||||
|
values: string[];
|
||||||
|
sourceFile: string;
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnumBrowserProps {
|
||||||
|
searchTerm: string;
|
||||||
|
enums: DatabaseEnum[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryColors: Record<string, string> = {
|
||||||
|
'Security & Permissions': 'bg-red-100 text-red-800',
|
||||||
|
'Billing & Payments': 'bg-green-100 text-green-800',
|
||||||
|
Notifications: 'bg-blue-100 text-blue-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryIcons: Record<string, React.ComponentType<any>> = {
|
||||||
|
'Security & Permissions': ShieldIcon,
|
||||||
|
'Billing & Payments': CreditCardIcon,
|
||||||
|
Notifications: BellIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
const valueColors: Record<string, string> = {
|
||||||
|
// Permission colors
|
||||||
|
'roles.manage': 'bg-purple-100 text-purple-800',
|
||||||
|
'billing.manage': 'bg-green-100 text-green-800',
|
||||||
|
'settings.manage': 'bg-blue-100 text-blue-800',
|
||||||
|
'members.manage': 'bg-orange-100 text-orange-800',
|
||||||
|
'invites.manage': 'bg-teal-100 text-teal-800',
|
||||||
|
|
||||||
|
// Payment provider colors
|
||||||
|
stripe: 'bg-purple-100 text-purple-800',
|
||||||
|
'lemon-squeezy': 'bg-yellow-100 text-yellow-800',
|
||||||
|
paddle: 'bg-blue-100 text-blue-800',
|
||||||
|
|
||||||
|
// Notification channel colors
|
||||||
|
in_app: 'bg-blue-100 text-blue-800',
|
||||||
|
email: 'bg-green-100 text-green-800',
|
||||||
|
|
||||||
|
// Notification type colors
|
||||||
|
info: 'bg-blue-100 text-blue-800',
|
||||||
|
warning: 'bg-yellow-100 text-yellow-800',
|
||||||
|
error: 'bg-red-100 text-red-800',
|
||||||
|
|
||||||
|
// Payment status colors
|
||||||
|
pending: 'bg-yellow-100 text-yellow-800',
|
||||||
|
succeeded: 'bg-green-100 text-green-800',
|
||||||
|
failed: 'bg-red-100 text-red-800',
|
||||||
|
|
||||||
|
// Subscription item type colors
|
||||||
|
flat: 'bg-gray-100 text-gray-800',
|
||||||
|
per_seat: 'bg-orange-100 text-orange-800',
|
||||||
|
metered: 'bg-purple-100 text-purple-800',
|
||||||
|
|
||||||
|
// Subscription status colors
|
||||||
|
active: 'bg-green-100 text-green-800',
|
||||||
|
trialing: 'bg-blue-100 text-blue-800',
|
||||||
|
past_due: 'bg-yellow-100 text-yellow-800',
|
||||||
|
canceled: 'bg-red-100 text-red-800',
|
||||||
|
unpaid: 'bg-red-100 text-red-800',
|
||||||
|
incomplete: 'bg-gray-100 text-gray-800',
|
||||||
|
incomplete_expired: 'bg-gray-100 text-gray-800',
|
||||||
|
paused: 'bg-gray-100 text-gray-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EnumBrowser({ searchTerm, enums }: EnumBrowserProps) {
|
||||||
|
const [selectedEnum, setSelectedEnum] = useState<string | null>(null);
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
// Filter enums based on search term
|
||||||
|
const filteredEnums = enums.filter(
|
||||||
|
(enumItem) =>
|
||||||
|
enumItem.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
enumItem.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
enumItem.category.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
enumItem.values.some((value) =>
|
||||||
|
value.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group enums by category
|
||||||
|
const enumsByCategory = filteredEnums.reduce(
|
||||||
|
(acc, enumItem) => {
|
||||||
|
if (!acc[enumItem.category]) {
|
||||||
|
acc[enumItem.category] = [];
|
||||||
|
}
|
||||||
|
acc[enumItem.category].push(enumItem);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, DatabaseEnum[]>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEnumClick = (enumName: string) => {
|
||||||
|
setSelectedEnum(enumName);
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
setSelectedEnum(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getValueBadgeColor = (value: string): string => {
|
||||||
|
return valueColors[value] || 'bg-gray-100 text-gray-800';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between p-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Total Enums
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold">{filteredEnums.length}</p>
|
||||||
|
</div>
|
||||||
|
<ListIcon className="text-muted-foreground h-8 w-8" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between p-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Categories
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{Object.keys(enumsByCategory).length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<TagIcon className="text-muted-foreground h-8 w-8" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between p-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Total Values
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{filteredEnums.reduce(
|
||||||
|
(acc, enumItem) => acc + enumItem.values.length,
|
||||||
|
0,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DatabaseIcon className="text-muted-foreground h-8 w-8" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Enums by Category */}
|
||||||
|
{Object.entries(enumsByCategory).map(([category, categoryEnums]) => {
|
||||||
|
const IconComponent = categoryIcons[category] || ListIcon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={category} className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
className={
|
||||||
|
categoryColors[category] || 'bg-gray-100 text-gray-800'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconComponent className="mr-1 h-3 w-3" />
|
||||||
|
{category}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
{categoryEnums.length} enum
|
||||||
|
{categoryEnums.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-2">
|
||||||
|
{categoryEnums.map((enumItem) => (
|
||||||
|
<Card
|
||||||
|
key={enumItem.name}
|
||||||
|
className="cursor-pointer transition-all hover:shadow-md"
|
||||||
|
onClick={() => handleEnumClick(enumItem.name)}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-start justify-between gap-2 text-sm">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<ListIcon className="text-muted-foreground h-4 w-4" />
|
||||||
|
<span className="font-mono">{enumItem.name}</span>
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{enumItem.values.length} value
|
||||||
|
{enumItem.values.length !== 1 ? 's' : ''}
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<p className="text-muted-foreground line-clamp-2 text-sm">
|
||||||
|
{enumItem.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{enumItem.values.slice(0, 4).map((value) => (
|
||||||
|
<Badge
|
||||||
|
key={value}
|
||||||
|
variant="outline"
|
||||||
|
className={`text-xs ${getValueBadgeColor(value)}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{enumItem.values.length > 4 && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
+{enumItem.values.length - 4} more
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground font-mono text-xs">
|
||||||
|
{enumItem.sourceFile}
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Click for details
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Enum Details Dialog */}
|
||||||
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
|
<DialogContent className="max-h-[80vh] max-w-4xl overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<ListIcon className="h-5 w-5" />
|
||||||
|
Enum Details: {selectedEnum}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{(() => {
|
||||||
|
const enumItem = enums.find((e) => e.name === selectedEnum);
|
||||||
|
if (!enumItem) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 font-medium">Description</h4>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{enumItem.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-3 font-medium">
|
||||||
|
Values ({enumItem.values.length})
|
||||||
|
</h4>
|
||||||
|
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{enumItem.values.map((value, index) => (
|
||||||
|
<div
|
||||||
|
key={value}
|
||||||
|
className="flex items-center gap-2 rounded border p-2"
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground font-mono text-xs">
|
||||||
|
{index + 1}.
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`text-sm ${getValueBadgeColor(value)}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 font-medium">Category</h4>
|
||||||
|
<Badge
|
||||||
|
className={
|
||||||
|
categoryColors[enumItem.category] ||
|
||||||
|
'bg-gray-100 text-gray-800'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TagIcon className="mr-1 h-3 w-3" />
|
||||||
|
{enumItem.category}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 font-medium">Source File</h4>
|
||||||
|
<span className="text-muted-foreground font-mono text-sm">
|
||||||
|
{enumItem.sourceFile}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 font-medium">Usage Examples</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="bg-muted rounded p-3">
|
||||||
|
<code className="text-muted-foreground text-sm">
|
||||||
|
CREATE TABLE example_table (
|
||||||
|
</code>
|
||||||
|
<br />
|
||||||
|
<code className="text-muted-foreground ml-4 text-sm">
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
</code>
|
||||||
|
<br />
|
||||||
|
<code className="text-muted-foreground ml-4 text-sm">
|
||||||
|
status {enumItem.name} NOT NULL
|
||||||
|
</code>
|
||||||
|
<br />
|
||||||
|
<code className="text-muted-foreground text-sm">
|
||||||
|
);
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted rounded p-3">
|
||||||
|
<code className="text-muted-foreground text-sm">
|
||||||
|
SELECT * FROM table_name WHERE status = '
|
||||||
|
{enumItem.values[0]}';
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{filteredEnums.length === 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex h-32 items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<ListIcon className="text-muted-foreground mx-auto h-8 w-8" />
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
{searchTerm ? 'No enums match your search' : 'No enums found'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ArrowRightIcon,
|
||||||
|
DatabaseIcon,
|
||||||
|
FunctionSquareIcon,
|
||||||
|
KeyIcon,
|
||||||
|
ShieldIcon,
|
||||||
|
UserIcon,
|
||||||
|
ZapIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@kit/ui/dialog';
|
||||||
|
import { Separator } from '@kit/ui/separator';
|
||||||
|
|
||||||
|
interface DatabaseFunction {
|
||||||
|
name: string;
|
||||||
|
signature: string;
|
||||||
|
returnType: string;
|
||||||
|
purpose: string;
|
||||||
|
source: string;
|
||||||
|
isSecurityDefiner: boolean;
|
||||||
|
isTrigger: boolean;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FunctionBrowserProps {
|
||||||
|
searchTerm: string;
|
||||||
|
functions: DatabaseFunction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryColors: Record<string, string> = {
|
||||||
|
Triggers: 'bg-red-100 text-red-800',
|
||||||
|
Authentication: 'bg-indigo-100 text-indigo-800',
|
||||||
|
Accounts: 'bg-green-100 text-green-800',
|
||||||
|
Permissions: 'bg-orange-100 text-orange-800',
|
||||||
|
Invitations: 'bg-teal-100 text-teal-800',
|
||||||
|
Billing: 'bg-yellow-100 text-yellow-800',
|
||||||
|
Utilities: 'bg-blue-100 text-blue-800',
|
||||||
|
'Text Processing': 'bg-purple-100 text-purple-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryIcons: Record<string, React.ComponentType<any>> = {
|
||||||
|
Triggers: ZapIcon,
|
||||||
|
Authentication: ShieldIcon,
|
||||||
|
Accounts: UserIcon,
|
||||||
|
Permissions: KeyIcon,
|
||||||
|
Invitations: UserIcon,
|
||||||
|
Billing: DatabaseIcon,
|
||||||
|
Utilities: FunctionSquareIcon,
|
||||||
|
'Text Processing': FunctionSquareIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FunctionBrowser({
|
||||||
|
searchTerm,
|
||||||
|
functions,
|
||||||
|
}: FunctionBrowserProps) {
|
||||||
|
const [selectedFunction, setSelectedFunction] = useState<string | null>(null);
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
// Filter functions based on search term
|
||||||
|
const filteredFunctions = functions.filter(
|
||||||
|
(func) =>
|
||||||
|
func.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
func.purpose.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
func.category.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
func.source.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group functions by category
|
||||||
|
const functionsByCategory = filteredFunctions.reduce(
|
||||||
|
(acc, func) => {
|
||||||
|
if (!acc[func.category]) {
|
||||||
|
acc[func.category] = [];
|
||||||
|
}
|
||||||
|
acc[func.category].push(func);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, DatabaseFunction[]>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFunctionClick = (functionName: string) => {
|
||||||
|
setSelectedFunction(functionName);
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getReturnTypeColor = (returnType: string): string => {
|
||||||
|
if (returnType === 'trigger') return 'bg-red-100 text-red-800';
|
||||||
|
if (returnType === 'boolean') return 'bg-green-100 text-green-800';
|
||||||
|
if (returnType === 'uuid') return 'bg-purple-100 text-purple-800';
|
||||||
|
if (returnType === 'text') return 'bg-blue-100 text-blue-800';
|
||||||
|
if (returnType === 'json' || returnType === 'jsonb')
|
||||||
|
return 'bg-yellow-100 text-yellow-800';
|
||||||
|
if (returnType === 'TABLE') return 'bg-orange-100 text-orange-800';
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between p-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Total Functions
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold">{filteredFunctions.length}</p>
|
||||||
|
</div>
|
||||||
|
<FunctionSquareIcon className="text-muted-foreground h-8 w-8" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between p-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Categories
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{Object.keys(functionsByCategory).length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DatabaseIcon className="text-muted-foreground h-8 w-8" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between p-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Security Definer
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{filteredFunctions.filter((f) => f.isSecurityDefiner).length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ShieldIcon className="text-muted-foreground h-8 w-8" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Functions by Category */}
|
||||||
|
{Object.entries(functionsByCategory).map(
|
||||||
|
([category, categoryFunctions]) => {
|
||||||
|
const IconComponent = categoryIcons[category] || FunctionSquareIcon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={category} className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
className={
|
||||||
|
categoryColors[category] || 'bg-gray-100 text-gray-800'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconComponent className="mr-1 h-3 w-3" />
|
||||||
|
{category}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
{categoryFunctions.length} function
|
||||||
|
{categoryFunctions.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-2">
|
||||||
|
{categoryFunctions.map((func) => (
|
||||||
|
<Card
|
||||||
|
key={func.name}
|
||||||
|
className="cursor-pointer transition-all hover:shadow-md"
|
||||||
|
onClick={() => handleFunctionClick(func.name)}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-start justify-between gap-2 text-sm">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<FunctionSquareIcon className="text-muted-foreground h-4 w-4" />
|
||||||
|
<span className="font-mono">{func.name}</span>
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{func.isSecurityDefiner && (
|
||||||
|
<Badge variant="default" className="text-xs">
|
||||||
|
<ShieldIcon className="mr-1 h-3 w-3" />
|
||||||
|
DEFINER
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{func.isTrigger && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<ZapIcon className="mr-1 h-3 w-3" />
|
||||||
|
TRIGGER
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ArrowRightIcon className="text-muted-foreground h-3 w-3" />
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`text-xs ${getReturnTypeColor(func.returnType)}`}
|
||||||
|
>
|
||||||
|
{func.returnType}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground line-clamp-2 text-sm">
|
||||||
|
{func.purpose}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground font-mono text-xs">
|
||||||
|
{func.source}
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Click for details
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Function Details Dialog */}
|
||||||
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<FunctionSquareIcon className="h-5 w-5" />
|
||||||
|
Function Details: {selectedFunction}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{(() => {
|
||||||
|
const func = functions.find((f) => f.name === selectedFunction);
|
||||||
|
if (!func) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 font-medium">Signature</h4>
|
||||||
|
<code className="text-muted-foreground bg-muted block rounded p-3 text-sm">
|
||||||
|
{func.signature}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 font-medium">Purpose</h4>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{func.purpose}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 font-medium">Return Type</h4>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={getReturnTypeColor(func.returnType)}
|
||||||
|
>
|
||||||
|
{func.returnType}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 font-medium">Source File</h4>
|
||||||
|
<span className="text-muted-foreground font-mono text-sm">
|
||||||
|
{func.source}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 font-medium">Properties</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge variant="outline">
|
||||||
|
<DatabaseIcon className="mr-1 h-3 w-3" />
|
||||||
|
{func.category}
|
||||||
|
</Badge>
|
||||||
|
{func.isSecurityDefiner && (
|
||||||
|
<Badge variant="default">
|
||||||
|
<ShieldIcon className="mr-1 h-3 w-3" />
|
||||||
|
Security Definer
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{func.isTrigger && (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
<ZapIcon className="mr-1 h-3 w-3" />
|
||||||
|
Trigger Function
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{filteredFunctions.length === 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex h-32 items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<FunctionSquareIcon className="text-muted-foreground mx-auto h-8 w-8" />
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
{searchTerm
|
||||||
|
? 'No functions match your search'
|
||||||
|
: 'No functions found'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { FileTextIcon, LayersIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@kit/ui/dialog';
|
||||||
|
import { Separator } from '@kit/ui/separator';
|
||||||
|
|
||||||
|
interface SchemaFile {
|
||||||
|
filename: string;
|
||||||
|
topic: string;
|
||||||
|
description: string;
|
||||||
|
section?: string;
|
||||||
|
tables?: string[];
|
||||||
|
functions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SchemaExplorerProps {
|
||||||
|
searchTerm: string;
|
||||||
|
schemaFiles: SchemaFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const topicColors: Record<string, string> = {
|
||||||
|
security: 'bg-red-100 text-red-800',
|
||||||
|
types: 'bg-purple-100 text-purple-800',
|
||||||
|
configuration: 'bg-blue-100 text-blue-800',
|
||||||
|
accounts: 'bg-green-100 text-green-800',
|
||||||
|
permissions: 'bg-orange-100 text-orange-800',
|
||||||
|
teams: 'bg-teal-100 text-teal-800',
|
||||||
|
billing: 'bg-yellow-100 text-yellow-800',
|
||||||
|
notifications: 'bg-pink-100 text-pink-800',
|
||||||
|
auth: 'bg-indigo-100 text-indigo-800',
|
||||||
|
admin: 'bg-gray-100 text-gray-800',
|
||||||
|
storage: 'bg-cyan-100 text-cyan-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SchemaExplorer({
|
||||||
|
searchTerm,
|
||||||
|
schemaFiles,
|
||||||
|
}: SchemaExplorerProps) {
|
||||||
|
const [selectedSchema, setSelectedSchema] = useState<SchemaFile | null>(null);
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
// Filter schemas based on search term
|
||||||
|
const filteredSchemas = schemaFiles.filter(
|
||||||
|
(schema) =>
|
||||||
|
schema.filename.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
schema.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
schema.topic.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
schema.section?.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group schemas by topic for better organization
|
||||||
|
const schemasByTopic = filteredSchemas.reduce(
|
||||||
|
(acc, schema) => {
|
||||||
|
if (!acc[schema.topic]) {
|
||||||
|
acc[schema.topic] = [];
|
||||||
|
}
|
||||||
|
acc[schema.topic].push(schema);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, SchemaFile[]>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSchemaClick = (schema: SchemaFile) => {
|
||||||
|
setSelectedSchema(schema);
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
setSelectedSchema(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between p-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Total Schemas
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold">{filteredSchemas.length}</p>
|
||||||
|
</div>
|
||||||
|
<LayersIcon className="text-muted-foreground h-8 w-8" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between p-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Topics
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{Object.keys(schemasByTopic).length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<FileTextIcon className="text-muted-foreground h-8 w-8" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between p-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Total Tables
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{filteredSchemas.reduce(
|
||||||
|
(acc, schema) => acc + (schema.tables?.length || 0),
|
||||||
|
0,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<LayersIcon className="text-muted-foreground h-8 w-8" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Schema Files by Topic */}
|
||||||
|
{Object.entries(schemasByTopic).map(([topic, schemas]) => (
|
||||||
|
<div key={topic} className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
className={topicColors[topic] || 'bg-gray-100 text-gray-800'}
|
||||||
|
>
|
||||||
|
{topic.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
{schemas.length} file{schemas.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{schemas.map((schema) => (
|
||||||
|
<Card
|
||||||
|
key={schema.filename}
|
||||||
|
className="cursor-pointer transition-shadow hover:shadow-md"
|
||||||
|
onClick={() => handleSchemaClick(schema)}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-start justify-between gap-2 text-sm">
|
||||||
|
<span className="line-clamp-1 flex-1">
|
||||||
|
{schema.filename}
|
||||||
|
</span>
|
||||||
|
<FileTextIcon className="text-muted-foreground h-4 w-4" />
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<p className="text-muted-foreground line-clamp-2 text-sm">
|
||||||
|
{schema.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{schema.section && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Section:
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{schema.section}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(schema.tables || schema.functions) && (
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
{schema.tables && schema.tables.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Tables: </span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{schema.tables.slice(0, 3).join(', ')}
|
||||||
|
{schema.tables.length > 3 &&
|
||||||
|
` +${schema.tables.length - 3} more`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schema.functions && schema.functions.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Functions: </span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{schema.functions.slice(0, 2).join(', ')}
|
||||||
|
{schema.functions.length > 2 &&
|
||||||
|
` +${schema.functions.length - 2} more`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Schema Details Dialog */}
|
||||||
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<FileTextIcon className="h-5 w-5" />
|
||||||
|
{selectedSchema?.filename}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{selectedSchema && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 font-medium">Description</h4>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{selectedSchema.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedSchema.section && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 font-medium">Section</h4>
|
||||||
|
<Badge variant="outline">{selectedSchema.section}</Badge>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedSchema.tables && selectedSchema.tables.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 font-medium">
|
||||||
|
Tables ({selectedSchema.tables.length})
|
||||||
|
</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{selectedSchema.tables.map((table) => (
|
||||||
|
<Badge
|
||||||
|
key={table}
|
||||||
|
variant="secondary"
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{table}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedSchema.functions &&
|
||||||
|
selectedSchema.functions.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 font-medium">
|
||||||
|
Functions ({selectedSchema.functions.length})
|
||||||
|
</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{selectedSchema.functions.map((func) => (
|
||||||
|
<Badge
|
||||||
|
key={func}
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{func}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{filteredSchemas.length === 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex h-32 items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<LayersIcon className="text-muted-foreground mx-auto h-8 w-8" />
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
{searchTerm
|
||||||
|
? 'No schemas match your search'
|
||||||
|
: 'No schemas found'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { DatabaseIcon, KeyIcon, LinkIcon, TableIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@kit/ui/dialog';
|
||||||
|
import { Separator } from '@kit/ui/separator';
|
||||||
|
|
||||||
|
import { getTableDetailsAction } from '../_lib/server/table-server-actions';
|
||||||
|
|
||||||
|
interface TableColumn {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
nullable: boolean;
|
||||||
|
defaultValue: string | null;
|
||||||
|
isPrimaryKey: boolean;
|
||||||
|
isForeignKey: boolean;
|
||||||
|
referencedTable: string | null;
|
||||||
|
referencedColumn: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableInfo {
|
||||||
|
name: string;
|
||||||
|
schema: string;
|
||||||
|
sourceFile: string;
|
||||||
|
topic: string;
|
||||||
|
columns: TableColumn[];
|
||||||
|
foreignKeys: Array<{
|
||||||
|
name: string;
|
||||||
|
columns: string[];
|
||||||
|
referencedTable: string;
|
||||||
|
referencedColumns: string[];
|
||||||
|
onDelete: string;
|
||||||
|
onUpdate: string;
|
||||||
|
}>;
|
||||||
|
indexes: Array<{
|
||||||
|
name: string;
|
||||||
|
columns: string[];
|
||||||
|
unique: boolean;
|
||||||
|
type: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableSummary {
|
||||||
|
name: string;
|
||||||
|
schema: string;
|
||||||
|
sourceFile: string;
|
||||||
|
topic: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableBrowserProps {
|
||||||
|
searchTerm: string;
|
||||||
|
tables: TableSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const topicColors: Record<string, string> = {
|
||||||
|
accounts: 'bg-green-100 text-green-800',
|
||||||
|
teams: 'bg-teal-100 text-teal-800',
|
||||||
|
billing: 'bg-yellow-100 text-yellow-800',
|
||||||
|
configuration: 'bg-blue-100 text-blue-800',
|
||||||
|
auth: 'bg-indigo-100 text-indigo-800',
|
||||||
|
notifications: 'bg-pink-100 text-pink-800',
|
||||||
|
permissions: 'bg-orange-100 text-orange-800',
|
||||||
|
general: 'bg-gray-100 text-gray-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeColors: Record<string, string> = {
|
||||||
|
uuid: 'bg-purple-100 text-purple-800',
|
||||||
|
text: 'bg-blue-100 text-blue-800',
|
||||||
|
'character varying': 'bg-blue-100 text-blue-800',
|
||||||
|
boolean: 'bg-green-100 text-green-800',
|
||||||
|
integer: 'bg-orange-100 text-orange-800',
|
||||||
|
'timestamp with time zone': 'bg-gray-100 text-gray-800',
|
||||||
|
jsonb: 'bg-yellow-100 text-yellow-800',
|
||||||
|
'USER-DEFINED': 'bg-red-100 text-red-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TableBrowser({ searchTerm, tables }: TableBrowserProps) {
|
||||||
|
const [selectedTable, setSelectedTable] = useState<string | null>(null);
|
||||||
|
const [tableDetails, setTableDetails] = useState<TableInfo | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
// Filter tables based on search term
|
||||||
|
const filteredTables = tables.filter(
|
||||||
|
(table) =>
|
||||||
|
table.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
table.topic.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
table.sourceFile.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group tables by topic
|
||||||
|
const tablesByTopic = filteredTables.reduce(
|
||||||
|
(acc, table) => {
|
||||||
|
if (!acc[table.topic]) {
|
||||||
|
acc[table.topic] = [];
|
||||||
|
}
|
||||||
|
acc[table.topic].push(table);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, TableSummary[]>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTableClick = async (tableName: string) => {
|
||||||
|
setSelectedTable(tableName);
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
setLoading(true);
|
||||||
|
setTableDetails(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getTableDetailsAction(tableName, 'public');
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setTableDetails(result.data);
|
||||||
|
} else {
|
||||||
|
console.error('Error fetching table details:', result.error);
|
||||||
|
setTableDetails(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching table details:', error);
|
||||||
|
setTableDetails(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
setSelectedTable(null);
|
||||||
|
setTableDetails(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getColumnTypeDisplay = (type: string) => {
|
||||||
|
const cleanType = type.replace('character varying', 'varchar');
|
||||||
|
return cleanType;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between p-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Total Tables
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold">{filteredTables.length}</p>
|
||||||
|
</div>
|
||||||
|
<DatabaseIcon className="text-muted-foreground h-8 w-8" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between p-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Topics
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{Object.keys(tablesByTopic).length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<TableIcon className="text-muted-foreground h-8 w-8" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between p-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Schema
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold">public</p>
|
||||||
|
</div>
|
||||||
|
<DatabaseIcon className="text-muted-foreground h-8 w-8" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tables by Topic */}
|
||||||
|
{Object.entries(tablesByTopic).map(([topic, topicTables]) => (
|
||||||
|
<div key={topic} className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
className={topicColors[topic] || 'bg-gray-100 text-gray-800'}
|
||||||
|
>
|
||||||
|
{topic.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
{topicTables.length} table{topicTables.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{topicTables.map((table) => (
|
||||||
|
<Card
|
||||||
|
key={table.name}
|
||||||
|
className="cursor-pointer transition-all hover:shadow-md"
|
||||||
|
onClick={() => handleTableClick(table.name)}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-start justify-between gap-2 text-sm">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<TableIcon className="text-muted-foreground h-4 w-4" />
|
||||||
|
<span className="font-mono">{table.name}</span>
|
||||||
|
</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Schema:
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{table.schema}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Source:
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground font-mono text-xs">
|
||||||
|
{table.sourceFile}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Click for details
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Table Details Dialog */}
|
||||||
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
|
<DialogContent className="max-h-[80vh] max-w-4xl overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<TableIcon className="h-5 w-5" />
|
||||||
|
Table Details: {selectedTable}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{loading && (
|
||||||
|
<div className="py-8 text-center">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Loading table details...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedTable && tableDetails && (
|
||||||
|
<>
|
||||||
|
{/* Columns */}
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-3 font-medium">
|
||||||
|
Columns ({tableDetails.columns.length})
|
||||||
|
</h4>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="py-2 text-left">Name</th>
|
||||||
|
<th className="py-2 text-left">Type</th>
|
||||||
|
<th className="py-2 text-left">Nullable</th>
|
||||||
|
<th className="py-2 text-left">Default</th>
|
||||||
|
<th className="py-2 text-left">Constraints</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tableDetails.columns.map((column) => (
|
||||||
|
<tr key={column.name} className="border-b">
|
||||||
|
<td className="py-2 font-mono">{column.name}</td>
|
||||||
|
<td className="py-2">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`text-xs ${
|
||||||
|
typeColors[column.type] ||
|
||||||
|
'bg-gray-100 text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{getColumnTypeDisplay(column.type)}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="py-2">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
column.nullable ? 'secondary' : 'outline'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{column.nullable ? 'YES' : 'NO'}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="text-muted-foreground py-2 font-mono text-xs">
|
||||||
|
{column.defaultValue || '—'}
|
||||||
|
</td>
|
||||||
|
<td className="py-2">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{column.isPrimaryKey && (
|
||||||
|
<Badge variant="default" className="text-xs">
|
||||||
|
<KeyIcon className="mr-1 h-3 w-3" />
|
||||||
|
PK
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{column.isForeignKey && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<LinkIcon className="mr-1 h-3 w-3" />
|
||||||
|
FK
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Foreign Keys */}
|
||||||
|
{tableDetails.foreignKeys.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-3 font-medium">
|
||||||
|
Foreign Keys ({tableDetails.foreignKeys.length})
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tableDetails.foreignKeys.map((fk) => (
|
||||||
|
<div
|
||||||
|
key={fk.name}
|
||||||
|
className="flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<LinkIcon className="text-muted-foreground h-4 w-4" />
|
||||||
|
<span className="font-mono">
|
||||||
|
{fk.columns.join(', ')}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">→</span>
|
||||||
|
<span className="font-mono">
|
||||||
|
{fk.referencedTable}.
|
||||||
|
{fk.referencedColumns.join(', ')}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
ON DELETE {fk.onDelete}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Indexes */}
|
||||||
|
{tableDetails.indexes.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-3 font-medium">
|
||||||
|
Indexes ({tableDetails.indexes.length})
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tableDetails.indexes.map((index) => (
|
||||||
|
<div
|
||||||
|
key={index.name}
|
||||||
|
className="flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{index.name}
|
||||||
|
</span>
|
||||||
|
{index.unique && (
|
||||||
|
<Badge variant="default" className="text-xs">
|
||||||
|
UNIQUE
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{index.type.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground">on</span>
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{index.columns.join(', ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{selectedTable && !tableDetails && !loading && (
|
||||||
|
<div className="py-8 text-center">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
No detailed information available for this table.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{filteredTables.length === 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex h-32 items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<DatabaseIcon className="text-muted-foreground mx-auto h-8 w-8" />
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
{searchTerm ? 'No tables match your search' : 'No tables found'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import 'server-only';
|
||||||
|
|
||||||
|
import { relative } from 'path';
|
||||||
|
|
||||||
|
import { DatabaseTool } from '@kit/mcp-server/database';
|
||||||
|
|
||||||
|
export interface DatabaseTable {
|
||||||
|
name: string;
|
||||||
|
schema: string;
|
||||||
|
sourceFile: string;
|
||||||
|
topic: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DatabaseFunction {
|
||||||
|
name: string;
|
||||||
|
signature: string;
|
||||||
|
returnType: string;
|
||||||
|
purpose: string;
|
||||||
|
source: string;
|
||||||
|
isSecurityDefiner: boolean;
|
||||||
|
isTrigger: boolean;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DatabaseEnum {
|
||||||
|
name: string;
|
||||||
|
values: string[];
|
||||||
|
sourceFile: string;
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchemaFile {
|
||||||
|
filename: string;
|
||||||
|
topic: string;
|
||||||
|
description: string;
|
||||||
|
section?: string;
|
||||||
|
tables?: string[];
|
||||||
|
functions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadDatabaseToolsData() {
|
||||||
|
DatabaseTool.ROOT_PATH = relative(process.cwd(), '../..');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [
|
||||||
|
schemaFilesResponse,
|
||||||
|
tablesResponse,
|
||||||
|
functionsResponse,
|
||||||
|
enumsResponse,
|
||||||
|
] = await Promise.all([
|
||||||
|
DatabaseTool.getSchemaFiles(),
|
||||||
|
DatabaseTool.getAllProjectTables(),
|
||||||
|
DatabaseTool.getFunctions(),
|
||||||
|
DatabaseTool.getAllEnums(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Process schema files
|
||||||
|
const schemaFiles: SchemaFile[] = schemaFilesResponse.map((file) => ({
|
||||||
|
filename: file.name,
|
||||||
|
topic: file.topic || 'general',
|
||||||
|
description: file.description || 'Database schema file',
|
||||||
|
section: file.section,
|
||||||
|
tables: file.tables,
|
||||||
|
functions: file.functions,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Process tables
|
||||||
|
const tables: DatabaseTable[] = tablesResponse.map((table) => ({
|
||||||
|
name: table.name,
|
||||||
|
schema: table.schema || 'public',
|
||||||
|
sourceFile: table.sourceFile || 'unknown',
|
||||||
|
topic: table.topic || 'general',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Process functions - parse the structured function data
|
||||||
|
const functions: DatabaseFunction[] = functionsResponse.map((func: any) => {
|
||||||
|
// Determine category based on function name and purpose
|
||||||
|
let category = 'Utilities';
|
||||||
|
if (func.returnType === 'trigger') {
|
||||||
|
category = 'Triggers';
|
||||||
|
} else if (
|
||||||
|
func.name.includes('nonce') ||
|
||||||
|
func.name.includes('mfa') ||
|
||||||
|
func.name.includes('auth') ||
|
||||||
|
func.name.includes('aal2')
|
||||||
|
) {
|
||||||
|
category = 'Authentication';
|
||||||
|
} else if (
|
||||||
|
func.name.includes('account') ||
|
||||||
|
func.name.includes('team') ||
|
||||||
|
func.name.includes('user')
|
||||||
|
) {
|
||||||
|
category = 'Accounts';
|
||||||
|
} else if (
|
||||||
|
func.name.includes('permission') ||
|
||||||
|
func.name.includes('role') ||
|
||||||
|
func.name.includes('member')
|
||||||
|
) {
|
||||||
|
category = 'Permissions';
|
||||||
|
} else if (
|
||||||
|
func.name.includes('invitation') ||
|
||||||
|
func.name.includes('invite')
|
||||||
|
) {
|
||||||
|
category = 'Invitations';
|
||||||
|
} else if (
|
||||||
|
func.name.includes('billing') ||
|
||||||
|
func.name.includes('subscription') ||
|
||||||
|
func.name.includes('payment') ||
|
||||||
|
func.name.includes('order')
|
||||||
|
) {
|
||||||
|
category = 'Billing';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: func.name,
|
||||||
|
signature: func.signature || func.name,
|
||||||
|
returnType: func.returnType || 'unknown',
|
||||||
|
purpose: func.purpose || 'Database function',
|
||||||
|
source: func.source || 'unknown',
|
||||||
|
isSecurityDefiner: func.isSecurityDefiner || false,
|
||||||
|
isTrigger: func.returnType === 'trigger',
|
||||||
|
category,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process enums
|
||||||
|
const enums: DatabaseEnum[] = Object.entries(enumsResponse).map(
|
||||||
|
([name, data]: [string, any]) => {
|
||||||
|
let category = 'General';
|
||||||
|
let description = `Database enum type: ${name}`;
|
||||||
|
|
||||||
|
// Categorize enums based on name
|
||||||
|
if (name.includes('permission')) {
|
||||||
|
category = 'Security & Permissions';
|
||||||
|
description =
|
||||||
|
'Application-level permissions that can be assigned to roles for granular access control';
|
||||||
|
} else if (
|
||||||
|
name.includes('billing') ||
|
||||||
|
name.includes('payment') ||
|
||||||
|
name.includes('subscription')
|
||||||
|
) {
|
||||||
|
category = 'Billing & Payments';
|
||||||
|
if (name === 'billing_provider') {
|
||||||
|
description =
|
||||||
|
'Supported payment processing providers for handling subscriptions and transactions';
|
||||||
|
} else if (name === 'payment_status') {
|
||||||
|
description =
|
||||||
|
'Status values for tracking the state of payment transactions';
|
||||||
|
} else if (name === 'subscription_status') {
|
||||||
|
description =
|
||||||
|
'Comprehensive status tracking for subscription lifecycle management';
|
||||||
|
} else if (name === 'subscription_item_type') {
|
||||||
|
description =
|
||||||
|
'Different pricing models for subscription line items and billing calculations';
|
||||||
|
}
|
||||||
|
} else if (name.includes('notification')) {
|
||||||
|
category = 'Notifications';
|
||||||
|
if (name === 'notification_channel') {
|
||||||
|
description =
|
||||||
|
'Available channels for delivering notifications to users';
|
||||||
|
} else if (name === 'notification_type') {
|
||||||
|
description =
|
||||||
|
'Classification types for different notification severity levels';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
values: data.values || [],
|
||||||
|
sourceFile: data.sourceFile || 'database',
|
||||||
|
category,
|
||||||
|
description,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
schemaFiles,
|
||||||
|
tables,
|
||||||
|
functions,
|
||||||
|
enums,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading database tools data:', error);
|
||||||
|
|
||||||
|
// Return empty data structures on error
|
||||||
|
return {
|
||||||
|
schemaFiles: [],
|
||||||
|
tables: [],
|
||||||
|
functions: [],
|
||||||
|
enums: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { relative } from 'path';
|
||||||
|
|
||||||
|
import { DatabaseTool } from '@kit/mcp-server/database';
|
||||||
|
|
||||||
|
export async function getTableDetailsAction(
|
||||||
|
tableName: string,
|
||||||
|
schema = 'public',
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
DatabaseTool.ROOT_PATH = relative(process.cwd(), '../..');
|
||||||
|
|
||||||
|
console.log('Fetching table info for:', { tableName, schema });
|
||||||
|
|
||||||
|
const tableInfo = await DatabaseTool.getTableInfo(schema, tableName);
|
||||||
|
|
||||||
|
console.log('Successfully fetched table info:', tableInfo);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: tableInfo,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching table info:', error);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Failed to fetch table information: ${(error as Error).message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
32
apps/dev-tool/app/mcp-server/database/page.tsx
Normal file
32
apps/dev-tool/app/mcp-server/database/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Metadata } from 'next';
|
||||||
|
|
||||||
|
import { DatabaseToolsInterface } from './_components/database-tools-interface';
|
||||||
|
import { loadDatabaseToolsData } from './_lib/server/database-tools.loader';
|
||||||
|
|
||||||
|
interface DatabasePageProps {
|
||||||
|
searchParams: {
|
||||||
|
search?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Database Tools - MCP Server',
|
||||||
|
description:
|
||||||
|
'Explore database schemas, tables, functions, and enums through the MCP Server interface',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function DatabasePage({ searchParams }: DatabasePageProps) {
|
||||||
|
const searchTerm = searchParams.search || '';
|
||||||
|
|
||||||
|
// Load all database data server-side
|
||||||
|
const databaseData = await loadDatabaseToolsData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DatabaseToolsInterface
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
databaseData={databaseData}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DatabasePage;
|
||||||
106
apps/dev-tool/app/mcp-server/page.tsx
Normal file
106
apps/dev-tool/app/mcp-server/page.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { withI18n } from '@/lib/i18n/with-i18n';
|
||||||
|
import { DatabaseIcon, FileTextIcon, ServerIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
import { Page, PageBody, PageHeader } from '@kit/ui/page';
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'MCP Server',
|
||||||
|
description:
|
||||||
|
'MCP Server development interface for database exploration and PRD management',
|
||||||
|
};
|
||||||
|
|
||||||
|
function McpServerPage() {
|
||||||
|
return (
|
||||||
|
<Page style={'custom'}>
|
||||||
|
<div className={'flex h-screen flex-col overflow-hidden'}>
|
||||||
|
<PageHeader
|
||||||
|
displaySidebarTrigger={false}
|
||||||
|
title={'MCP Server'}
|
||||||
|
description={
|
||||||
|
'Access MCP Server tools for database exploration and PRD management.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PageBody className={'overflow-hidden'}>
|
||||||
|
<div className={'flex h-full flex-1 flex-col p-6'}>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Welcome Section */}
|
||||||
|
<div className="text-center">
|
||||||
|
<ServerIcon className="text-muted-foreground mx-auto mb-4 h-16 w-16" />
|
||||||
|
<h2 className="mb-2 text-2xl font-bold">
|
||||||
|
Welcome to MCP Server Tools
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground mx-auto max-w-2xl">
|
||||||
|
Choose from the tools below to explore your database schema or
|
||||||
|
manage your Product Requirements Documents. Use the sidebar
|
||||||
|
navigation for quick access to specific tools.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tool Cards */}
|
||||||
|
<div className="mx-auto grid max-w-4xl gap-6 md:grid-cols-2">
|
||||||
|
<Card className="cursor-pointer transition-shadow hover:shadow-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-3">
|
||||||
|
<DatabaseIcon className="h-6 w-6" />
|
||||||
|
Database Tools
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Explore database schemas, tables, functions, and enums
|
||||||
|
through an intuitive interface.
|
||||||
|
</p>
|
||||||
|
<ul className="text-muted-foreground space-y-1 text-sm">
|
||||||
|
<li>• Browse database schemas and their structure</li>
|
||||||
|
<li>• Explore tables with columns and relationships</li>
|
||||||
|
<li>
|
||||||
|
• Discover database functions and their parameters
|
||||||
|
</li>
|
||||||
|
<li>• View enum types and their values</li>
|
||||||
|
</ul>
|
||||||
|
<Button asChild className="w-full">
|
||||||
|
<Link href="/mcp-server/database">
|
||||||
|
Open Database Tools
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="cursor-pointer transition-shadow hover:shadow-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-3">
|
||||||
|
<FileTextIcon className="h-6 w-6" />
|
||||||
|
PRD Manager
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Create and manage Product Requirements Documents with user
|
||||||
|
stories and progress tracking.
|
||||||
|
</p>
|
||||||
|
<ul className="text-muted-foreground space-y-1 text-sm">
|
||||||
|
<li>• Create and edit PRDs with structured templates</li>
|
||||||
|
<li>• Manage user stories with priority tracking</li>
|
||||||
|
<li>• Track progress and project status</li>
|
||||||
|
<li>• Export PRDs to markdown format</li>
|
||||||
|
</ul>
|
||||||
|
<Button asChild className="w-full">
|
||||||
|
<Link href="/mcp-server/prd">Open PRD Manager</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageBody>
|
||||||
|
</div>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(McpServerPage);
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { CalendarIcon, FileTextIcon, UsersIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
import { Progress } from '@kit/ui/progress';
|
||||||
|
import { Separator } from '@kit/ui/separator';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kit/ui/tabs';
|
||||||
|
|
||||||
|
import { UserStoryDisplay } from '../../../_components/user-story-display';
|
||||||
|
import type { PRDData } from '../../../_lib/server/prd-page.loader';
|
||||||
|
|
||||||
|
interface PRDDetailViewProps {
|
||||||
|
filename: string;
|
||||||
|
prd: PRDData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PRDDetailView({ filename, prd }: PRDDetailViewProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FileTextIcon className="h-5 w-5" />
|
||||||
|
<h1 className="text-2xl font-bold">{prd.introduction.title}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground flex items-center gap-4 text-sm">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<CalendarIcon className="h-3 w-3" />
|
||||||
|
Updated {prd.metadata.lastUpdated}
|
||||||
|
</span>
|
||||||
|
<span>{filename}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Overview */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
Progress Overview
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">Overall Progress</span>
|
||||||
|
<span className="text-sm font-medium">{prd.progress.overall}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={prd.progress.overall} className="h-3" />
|
||||||
|
<div className="text-muted-foreground flex justify-between text-sm">
|
||||||
|
<span>
|
||||||
|
{prd.progress.completed} of {prd.progress.total} user stories
|
||||||
|
completed
|
||||||
|
</span>
|
||||||
|
<span>{prd.progress.total - prd.progress.completed} remaining</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<Tabs defaultValue="overview" className="space-y-6">
|
||||||
|
<TabsList className="grid w-full grid-cols-4">
|
||||||
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
|
<TabsTrigger value="requirements">Requirements</TabsTrigger>
|
||||||
|
<TabsTrigger value="user-stories">
|
||||||
|
User Stories ({prd.userStories.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="metadata">Metadata</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="overview" className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Project Overview</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 font-medium">Description</h4>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{prd.introduction.overview}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 font-medium">Problem Statement</h4>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{prd.problemStatement.problem}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 font-medium">Market Opportunity</h4>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{prd.problemStatement.marketOpportunity}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="requirements" className="space-y-6">
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<UsersIcon className="h-4 w-4" />
|
||||||
|
Target Users
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{prd.problemStatement.targetUsers?.map(
|
||||||
|
(user, index: number) => (
|
||||||
|
<li key={index} className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">•</span>
|
||||||
|
<span>{user}</span>
|
||||||
|
</li>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Key Features</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{prd.solutionOverview.keyFeatures?.map((feature, index) => (
|
||||||
|
<li key={index} className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">•</span>
|
||||||
|
<span>{feature}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="md:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Solution Description</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{prd.solutionOverview.description}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="md:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Success Metrics</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{prd.solutionOverview.successMetrics?.map((metric, index) => (
|
||||||
|
<li key={index} className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">•</span>
|
||||||
|
<span>{metric}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="user-stories">
|
||||||
|
<UserStoryDisplay userStories={prd.userStories} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="metadata" className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Metadata</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-1 font-medium">Last Updated</h4>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{prd.metadata.lastUpdated}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-1 font-medium">Version</h4>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{prd.metadata.version}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-1 font-medium">Filename</h4>
|
||||||
|
<p className="text-muted-foreground">{filename}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-1 font-medium">Total User Stories</h4>
|
||||||
|
<p className="text-muted-foreground">{prd.progress.total}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 font-medium">Progress Breakdown</h4>
|
||||||
|
<div className="grid gap-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Completed Stories:</span>
|
||||||
|
<Badge variant="default">{prd.progress.completed}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Remaining Stories:</span>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{prd.progress.total - prd.progress.completed}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Overall Progress:</span>
|
||||||
|
<Badge variant="outline">{prd.progress.overall}%</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
apps/dev-tool/app/mcp-server/prds/[filename]/page.tsx
Normal file
44
apps/dev-tool/app/mcp-server/prds/[filename]/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Metadata } from 'next';
|
||||||
|
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
import { loadPRDPageData } from '../../_lib/server/prd-page.loader';
|
||||||
|
import { PRDDetailView } from './_components/prd-detail-view';
|
||||||
|
|
||||||
|
interface PRDPageProps {
|
||||||
|
params: Promise<{
|
||||||
|
filename: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: PRDPageProps): Promise<Metadata> {
|
||||||
|
const { filename } = await params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const prd = await loadPRDPageData(filename);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${prd.introduction.title} - PRD`,
|
||||||
|
description: prd.introduction.overview,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
title: 'PRD Not Found',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PRDPage({ params }: PRDPageProps) {
|
||||||
|
const { filename } = await params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const prd = await loadPRDPageData(filename);
|
||||||
|
|
||||||
|
return <PRDDetailView filename={filename} prd={prd} />;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load PRD:', error);
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { CalendarIcon, FileTextIcon, SearchIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
import { Input } from '@kit/ui/input';
|
||||||
|
import { Progress } from '@kit/ui/progress';
|
||||||
|
|
||||||
|
interface PRDSummary {
|
||||||
|
filename: string;
|
||||||
|
title: string;
|
||||||
|
lastUpdated: string;
|
||||||
|
progress: number;
|
||||||
|
totalStories: number;
|
||||||
|
completedStories: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PRDsListInterfaceProps {
|
||||||
|
initialPrds: PRDSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PRDsListInterface({ initialPrds }: PRDsListInterfaceProps) {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
const filteredPrds = initialPrds.filter(
|
||||||
|
(prd) =>
|
||||||
|
prd.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
prd.filename.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FileTextIcon className="h-6 w-6" />
|
||||||
|
<h1 className="text-2xl font-bold">
|
||||||
|
Product Requirements Documents
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Browse and view all PRDs in your project
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="flex w-full flex-col gap-4">
|
||||||
|
<div className="relative max-w-md">
|
||||||
|
<SearchIcon className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search PRDs by title or filename..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PRD Grid */}
|
||||||
|
{filteredPrds.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex h-32 items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<FileTextIcon className="text-muted-foreground mx-auto h-8 w-8" />
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
{searchTerm ? 'No PRDs match your search' : 'No PRDs found'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{filteredPrds.map((prd) => (
|
||||||
|
<Link
|
||||||
|
key={prd.filename}
|
||||||
|
href={`/mcp-server/prds/${prd.filename}`}
|
||||||
|
className="block"
|
||||||
|
>
|
||||||
|
<Card className="cursor-pointer transition-shadow hover:shadow-md">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-start gap-2 text-sm">
|
||||||
|
<FileTextIcon className="text-muted-foreground mt-0.5 h-4 w-4" />
|
||||||
|
<span className="line-clamp-2">{prd.title}</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{/* Progress */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-muted-foreground flex justify-between text-xs">
|
||||||
|
<span>Progress</span>
|
||||||
|
<span>
|
||||||
|
{prd.completedStories}/{prd.totalStories} stories
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={prd.progress} className="h-2" />
|
||||||
|
<div className="text-right text-xs font-medium">
|
||||||
|
{prd.progress}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<div className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||||
|
<CalendarIcon className="h-3 w-3" />
|
||||||
|
<span>Updated {prd.lastUpdated}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filename */}
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{prd.filename}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
apps/dev-tool/app/mcp-server/prds/page.tsx
Normal file
16
apps/dev-tool/app/mcp-server/prds/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Metadata } from 'next';
|
||||||
|
|
||||||
|
import { loadPRDs } from '../_lib/server/prd-loader';
|
||||||
|
import { PRDsListInterface } from './_components/prds-list-interface';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'PRDs - MCP Server',
|
||||||
|
description: 'Browse and view all Product Requirements Documents',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function PRDsPage() {
|
||||||
|
// Load PRDs on the server side
|
||||||
|
const initialPrds = await loadPRDs();
|
||||||
|
|
||||||
|
return <PRDsListInterface initialPrds={initialPrds} />;
|
||||||
|
}
|
||||||
@@ -6,9 +6,12 @@ import { usePathname } from 'next/navigation';
|
|||||||
import {
|
import {
|
||||||
BoltIcon,
|
BoltIcon,
|
||||||
ComponentIcon,
|
ComponentIcon,
|
||||||
|
DatabaseIcon,
|
||||||
|
FileTextIcon,
|
||||||
LanguagesIcon,
|
LanguagesIcon,
|
||||||
LayoutDashboardIcon,
|
LayoutDashboardIcon,
|
||||||
MailIcon,
|
MailIcon,
|
||||||
|
ServerIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -19,6 +22,9 @@ import {
|
|||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarMenuSubButton,
|
||||||
|
SidebarMenuSubItem,
|
||||||
} from '@kit/ui/shadcn-sidebar';
|
} from '@kit/ui/shadcn-sidebar';
|
||||||
import { isRouteActive } from '@kit/ui/utils';
|
import { isRouteActive } from '@kit/ui/utils';
|
||||||
|
|
||||||
@@ -48,6 +54,22 @@ const routes = [
|
|||||||
path: '/translations',
|
path: '/translations',
|
||||||
Icon: LanguagesIcon,
|
Icon: LanguagesIcon,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'MCP Server',
|
||||||
|
Icon: ServerIcon,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: 'Database',
|
||||||
|
path: '/mcp-server/database',
|
||||||
|
Icon: DatabaseIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'PRD Manager',
|
||||||
|
path: '/mcp-server/prds',
|
||||||
|
Icon: FileTextIcon,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function DevToolSidebar({
|
export function DevToolSidebar({
|
||||||
@@ -66,7 +88,30 @@ export function DevToolSidebar({
|
|||||||
|
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{routes.map((route) => (
|
{routes.map((route) => (
|
||||||
<SidebarMenuItem key={route.path}>
|
<SidebarMenuItem key={route.path || route.label}>
|
||||||
|
{'children' in route ? (
|
||||||
|
<>
|
||||||
|
<SidebarMenuButton>
|
||||||
|
<route.Icon className="h-4 w-4" />
|
||||||
|
<span>{route.label}</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
<SidebarMenuSub>
|
||||||
|
{route.children.map((child) => (
|
||||||
|
<SidebarMenuSubItem key={child.path}>
|
||||||
|
<SidebarMenuSubButton
|
||||||
|
asChild
|
||||||
|
isActive={isRouteActive(child.path, pathname, false)}
|
||||||
|
>
|
||||||
|
<Link href={child.path}>
|
||||||
|
<child.Icon className="h-4 w-4" />
|
||||||
|
<span>{child.label}</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuSubButton>
|
||||||
|
</SidebarMenuSubItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenuSub>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
isActive={isRouteActive(route.path, pathname, false)}
|
isActive={isRouteActive(route.path, pathname, false)}
|
||||||
asChild
|
asChild
|
||||||
@@ -76,6 +121,7 @@ export function DevToolSidebar({
|
|||||||
<span>{route.label}</span>
|
<span>{route.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
|
)}
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
))}
|
))}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
|
|||||||
@@ -8,13 +8,13 @@
|
|||||||
"format": "prettier --check --write \"**/*.{js,cjs,mjs,ts,tsx,md,json}\""
|
"format": "prettier --check --write \"**/*.{js,cjs,mjs,ts,tsx,md,json}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/openai": "^2.0.32",
|
"@ai-sdk/openai": "^2.0.34",
|
||||||
"@faker-js/faker": "^10.0.0",
|
"@faker-js/faker": "^10.0.0",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@tanstack/react-query": "5.89.0",
|
"@tanstack/react-query": "5.90.2",
|
||||||
"ai": "5.0.48",
|
"ai": "5.0.51",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"next": "15.5.3",
|
"next": "15.5.4",
|
||||||
"nodemailer": "^7.0.6",
|
"nodemailer": "^7.0.6",
|
||||||
"react": "19.1.1",
|
"react": "19.1.1",
|
||||||
"react-dom": "19.1.1",
|
"react-dom": "19.1.1",
|
||||||
@@ -23,6 +23,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@kit/email-templates": "workspace:*",
|
"@kit/email-templates": "workspace:*",
|
||||||
"@kit/i18n": "workspace:*",
|
"@kit/i18n": "workspace:*",
|
||||||
|
"@kit/mcp-server": "workspace:*",
|
||||||
|
"@kit/next": "workspace:*",
|
||||||
"@kit/prettier-config": "workspace:*",
|
"@kit/prettier-config": "workspace:*",
|
||||||
"@kit/shared": "workspace:*",
|
"@kit/shared": "workspace:*",
|
||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
},
|
},
|
||||||
"author": "Makerkit",
|
"author": "Makerkit",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.55.0",
|
"@playwright/test": "^1.55.1",
|
||||||
"@supabase/supabase-js": "2.57.4",
|
"@supabase/supabase-js": "2.57.4",
|
||||||
"@types/node": "^24.5.2",
|
"@types/node": "^24.5.2",
|
||||||
"dotenv": "17.2.2",
|
"dotenv": "17.2.2",
|
||||||
|
|||||||
@@ -54,14 +54,14 @@
|
|||||||
"@makerkit/data-loader-supabase-core": "^0.0.10",
|
"@makerkit/data-loader-supabase-core": "^0.0.10",
|
||||||
"@makerkit/data-loader-supabase-nextjs": "^1.2.5",
|
"@makerkit/data-loader-supabase-nextjs": "^1.2.5",
|
||||||
"@marsidev/react-turnstile": "^1.3.1",
|
"@marsidev/react-turnstile": "^1.3.1",
|
||||||
"@nosecone/next": "1.0.0-beta.11",
|
"@nosecone/next": "1.0.0-beta.12",
|
||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
"@supabase/supabase-js": "2.57.4",
|
"@supabase/supabase-js": "2.57.4",
|
||||||
"@tanstack/react-query": "5.89.0",
|
"@tanstack/react-query": "5.90.2",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"next": "15.5.3",
|
"next": "15.5.4",
|
||||||
"next-sitemap": "^4.2.3",
|
"next-sitemap": "^4.2.3",
|
||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"react": "19.1.1",
|
"react": "19.1.1",
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
"@kit/eslint-config": "workspace:*",
|
"@kit/eslint-config": "workspace:*",
|
||||||
"@kit/prettier-config": "workspace:*",
|
"@kit/prettier-config": "workspace:*",
|
||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@next/bundle-analyzer": "15.5.3",
|
"@next/bundle-analyzer": "15.5.4",
|
||||||
"@tailwindcss/postcss": "^4.1.13",
|
"@tailwindcss/postcss": "^4.1.13",
|
||||||
"@types/node": "^24.5.2",
|
"@types/node": "^24.5.2",
|
||||||
"@types/react": "19.1.13",
|
"@types/react": "19.1.13",
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
"cssnano": "^7.1.1",
|
"cssnano": "^7.1.1",
|
||||||
"pino-pretty": "13.0.0",
|
"pino-pretty": "13.0.0",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"supabase": "2.40.7",
|
"supabase": "2.45.5",
|
||||||
"tailwindcss": "4.1.13",
|
"tailwindcss": "4.1.13",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.2"
|
||||||
|
|||||||
@@ -10,25 +10,30 @@ Migrations are generated from schemas. If creating a new schema, the migration c
|
|||||||
|
|
||||||
If modifying an existing migration, use the `diff` command:
|
If modifying an existing migration, use the `diff` command:
|
||||||
|
|
||||||
### 1. Creating New Schema Files
|
### 1. Creating new entities
|
||||||
|
|
||||||
|
When creating new entities (such as creating a new tabble), we can create a migration as is, just copying its content.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create new schema file
|
# Create new schema file
|
||||||
touch apps/web/supabase/schemas/15-my-new-feature.sql
|
touch apps/web/supabase/schemas/15-my-new-feature.sql
|
||||||
|
|
||||||
# Apply changes and create migration
|
# Create Migration
|
||||||
pnpm --filter web run supabase:db:diff -f my-new-feature
|
pnpm --filter web run supabase migrations new my-new-feature
|
||||||
|
|
||||||
# Restart Supabase with fresh schema
|
# Copy content to migration
|
||||||
pnpm supabase:web:reset
|
cp apps/web/supabase/schemas/15-my-new-feature.sql apps/web/supabase/migrations/$(ls -t apps/web/supabase/migrations/ | head -n1)
|
||||||
|
|
||||||
|
# Apply migration
|
||||||
|
pnpm --filter web supabase migrations up # alternatively reset db with pnpm supabase:web:reset
|
||||||
|
|
||||||
# Generate TypeScript types
|
# Generate TypeScript types
|
||||||
pnpm supabase:web:typegen
|
pnpm supabase:web:typegen
|
||||||
```
|
```
|
||||||
|
|
||||||
Verify the diff command generated the same content as the schema; if not, take steps to fix the migration.
|
### 2. Modifying existing entities
|
||||||
|
|
||||||
### 2. Modifying Existing Schemas
|
When modifying existing entities (such ass adding a field to an existing table), we can use the `diff` command to generate a migration following the changes:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Edit schema file (e.g., schemas/03-accounts.sql)
|
# Edit schema file (e.g., schemas/03-accounts.sql)
|
||||||
@@ -38,7 +43,7 @@ Verify the diff command generated the same content as the schema; if not, take s
|
|||||||
pnpm --filter web run supabase:db:diff -f update-accounts
|
pnpm --filter web run supabase:db:diff -f update-accounts
|
||||||
|
|
||||||
# Apply and test
|
# Apply and test
|
||||||
pnpm supabase:web:reset
|
pnpm --filter web supabase migrations up # alternatively reset db with pnpm supabase:web:reset
|
||||||
|
|
||||||
# After resetting
|
# After resetting
|
||||||
pnpm supabase:web:typegen
|
pnpm supabase:web:typegen
|
||||||
@@ -244,7 +249,7 @@ Add triggers if the properties exist and are appropriate:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# View migration status
|
# View migration status
|
||||||
pnpm --filter web supabase migration list
|
pnpm --filter web supabase migrations list
|
||||||
|
|
||||||
# Reset database completely
|
# Reset database completely
|
||||||
pnpm supabase:web:reset
|
pnpm supabase:web:reset
|
||||||
@@ -252,6 +257,9 @@ pnpm supabase:web:reset
|
|||||||
# Generate migration from schema diff
|
# Generate migration from schema diff
|
||||||
pnpm --filter web run supabase:db:diff -f migration-name
|
pnpm --filter web run supabase:db:diff -f migration-name
|
||||||
|
|
||||||
|
## Apply created migration
|
||||||
|
pnpm --filter web supabase migrations up
|
||||||
|
|
||||||
# Apply specific migration
|
# Apply specific migration
|
||||||
pnpm --filter web supabase migration up --include-schemas public
|
pnpm --filter web supabase migrations up --include-schemas public
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -10,25 +10,30 @@ Migrations are generated from schemas. If creating a new schema, the migration c
|
|||||||
|
|
||||||
If modifying an existing migration, use the `diff` command:
|
If modifying an existing migration, use the `diff` command:
|
||||||
|
|
||||||
### 1. Creating New Schema Files
|
### 1. Creating new entities
|
||||||
|
|
||||||
|
When creating new entities (such as creating a new tabble), we can create a migration as is, just copying its content.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create new schema file
|
# Create new schema file
|
||||||
touch apps/web/supabase/schemas/15-my-new-feature.sql
|
touch apps/web/supabase/schemas/15-my-new-feature.sql
|
||||||
|
|
||||||
# Apply changes and create migration
|
# Create Migration
|
||||||
pnpm --filter web run supabase:db:diff -f my-new-feature
|
pnpm --filter web supabase migrations new my-new-feature
|
||||||
|
|
||||||
# Restart Supabase with fresh schema
|
# Copy content to migration
|
||||||
pnpm supabase:web:reset
|
cp apps/web/supabase/schemas/15-my-new-feature.sql apps/web/supabase/migrations/$(ls -t apps/web/supabase/migrations/ | head -n1)
|
||||||
|
|
||||||
|
# Apply migration
|
||||||
|
pnpm --filter web supabase migrations up # alternatively reset db with pnpm supabase:web:reset
|
||||||
|
|
||||||
# Generate TypeScript types
|
# Generate TypeScript types
|
||||||
pnpm supabase:web:typegen
|
pnpm supabase:web:typegen
|
||||||
```
|
```
|
||||||
|
|
||||||
Verify the diff command generated the same content as the schema; if not, take steps to fix the migration.
|
### 2. Modifying existing entities
|
||||||
|
|
||||||
### 2. Modifying Existing Schemas
|
When modifying existing entities (such ass adding a field to an existing table), we can use the `diff` command to generate a migration following the changes:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Edit schema file (e.g., schemas/03-accounts.sql)
|
# Edit schema file (e.g., schemas/03-accounts.sql)
|
||||||
@@ -38,7 +43,7 @@ Verify the diff command generated the same content as the schema; if not, take s
|
|||||||
pnpm --filter web run supabase:db:diff -f update-accounts
|
pnpm --filter web run supabase:db:diff -f update-accounts
|
||||||
|
|
||||||
# Apply and test
|
# Apply and test
|
||||||
pnpm supabase:web:reset
|
pnpm --filter web supabase migrations up # alternatively reset db with pnpm supabase:web:reset
|
||||||
|
|
||||||
# After resetting
|
# After resetting
|
||||||
pnpm supabase:web:typegen
|
pnpm supabase:web:typegen
|
||||||
@@ -244,7 +249,7 @@ Add triggers if the properties exist and are appropriate:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# View migration status
|
# View migration status
|
||||||
pnpm --filter web supabase migration list
|
pnpm --filter web supabase migrations list
|
||||||
|
|
||||||
# Reset database completely
|
# Reset database completely
|
||||||
pnpm supabase:web:reset
|
pnpm supabase:web:reset
|
||||||
@@ -252,6 +257,9 @@ pnpm supabase:web:reset
|
|||||||
# Generate migration from schema diff
|
# Generate migration from schema diff
|
||||||
pnpm --filter web run supabase:db:diff -f migration-name
|
pnpm --filter web run supabase:db:diff -f migration-name
|
||||||
|
|
||||||
|
## Apply created migration
|
||||||
|
pnpm --filter web supabase migrations up
|
||||||
|
|
||||||
# Apply specific migration
|
# Apply specific migration
|
||||||
pnpm --filter web supabase migration up --include-schemas public
|
pnpm --filter web supabase migrations up --include-schemas public
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "next-supabase-saas-kit-turbo",
|
"name": "next-supabase-saas-kit-turbo",
|
||||||
"version": "2.14.1",
|
"version": "2.15.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -40,10 +40,10 @@
|
|||||||
"packageManager": "pnpm@10.14.0",
|
"packageManager": "pnpm@10.14.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@manypkg/cli": "^0.25.1",
|
"@manypkg/cli": "^0.25.1",
|
||||||
"@turbo/gen": "^2.5.6",
|
"@turbo/gen": "^2.5.8",
|
||||||
"cross-env": "^10.0.0",
|
"cross-env": "^10.0.0",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"turbo": "2.5.6",
|
"turbo": "2.5.8",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"@types/react": "19.1.13",
|
"@types/react": "19.1.13",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"next": "15.5.3",
|
"next": "15.5.4",
|
||||||
"react": "19.1.1",
|
"react": "19.1.1",
|
||||||
"react-hook-form": "^7.63.0",
|
"react-hook-form": "^7.63.0",
|
||||||
"react-i18next": "^15.7.3",
|
"react-i18next": "^15.7.3",
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@kit/ui": "workspace:*",
|
"@kit/ui": "workspace:*",
|
||||||
"@types/react": "19.1.13",
|
"@types/react": "19.1.13",
|
||||||
"next": "15.5.3",
|
"next": "15.5.4",
|
||||||
"react": "19.1.1",
|
"react": "19.1.1",
|
||||||
"zod": "^3.25.74"
|
"zod": "^3.25.74"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"@kit/ui": "workspace:*",
|
"@kit/ui": "workspace:*",
|
||||||
"@types/react": "19.1.13",
|
"@types/react": "19.1.13",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"next": "15.5.3",
|
"next": "15.5.4",
|
||||||
"react": "19.1.1",
|
"react": "19.1.1",
|
||||||
"zod": "^3.25.74"
|
"zod": "^3.25.74"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-email/components": "0.5.3"
|
"@react-email/components": "0.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@kit/eslint-config": "workspace:*",
|
"@kit/eslint-config": "workspace:*",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"./api": "./src/server/api.ts"
|
"./api": "./src/server/api.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^5.1.5"
|
"nanoid": "^5.1.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
@@ -35,11 +35,11 @@
|
|||||||
"@kit/ui": "workspace:*",
|
"@kit/ui": "workspace:*",
|
||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
"@supabase/supabase-js": "2.57.4",
|
"@supabase/supabase-js": "2.57.4",
|
||||||
"@tanstack/react-query": "5.89.0",
|
"@tanstack/react-query": "5.90.2",
|
||||||
"@types/react": "19.1.13",
|
"@types/react": "19.1.13",
|
||||||
"@types/react-dom": "19.1.9",
|
"@types/react-dom": "19.1.9",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"next": "15.5.3",
|
"next": "15.5.4",
|
||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"react": "19.1.1",
|
"react": "19.1.1",
|
||||||
"react-dom": "19.1.1",
|
"react-dom": "19.1.1",
|
||||||
|
|||||||
@@ -21,11 +21,11 @@
|
|||||||
"@makerkit/data-loader-supabase-core": "^0.0.10",
|
"@makerkit/data-loader-supabase-core": "^0.0.10",
|
||||||
"@makerkit/data-loader-supabase-nextjs": "^1.2.5",
|
"@makerkit/data-loader-supabase-nextjs": "^1.2.5",
|
||||||
"@supabase/supabase-js": "2.57.4",
|
"@supabase/supabase-js": "2.57.4",
|
||||||
"@tanstack/react-query": "5.89.0",
|
"@tanstack/react-query": "5.90.2",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@types/react": "19.1.13",
|
"@types/react": "19.1.13",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"next": "15.5.3",
|
"next": "15.5.4",
|
||||||
"react": "19.1.1",
|
"react": "19.1.1",
|
||||||
"react-dom": "19.1.1",
|
"react-dom": "19.1.1",
|
||||||
"react-hook-form": "^7.63.0",
|
"react-hook-form": "^7.63.0",
|
||||||
|
|||||||
@@ -30,10 +30,10 @@
|
|||||||
"@marsidev/react-turnstile": "^1.3.1",
|
"@marsidev/react-turnstile": "^1.3.1",
|
||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
"@supabase/supabase-js": "2.57.4",
|
"@supabase/supabase-js": "2.57.4",
|
||||||
"@tanstack/react-query": "5.89.0",
|
"@tanstack/react-query": "5.90.2",
|
||||||
"@types/react": "19.1.13",
|
"@types/react": "19.1.13",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"next": "15.5.3",
|
"next": "15.5.4",
|
||||||
"react-hook-form": "^7.63.0",
|
"react-hook-form": "^7.63.0",
|
||||||
"react-i18next": "^15.7.3",
|
"react-i18next": "^15.7.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@kit/ui": "workspace:*",
|
"@kit/ui": "workspace:*",
|
||||||
"@supabase/supabase-js": "2.57.4",
|
"@supabase/supabase-js": "2.57.4",
|
||||||
"@tanstack/react-query": "5.89.0",
|
"@tanstack/react-query": "5.90.2",
|
||||||
"@types/react": "19.1.13",
|
"@types/react": "19.1.13",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"react": "19.1.1",
|
"react": "19.1.1",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"./webhooks": "./src/server/services/webhooks/index.ts"
|
"./webhooks": "./src/server/services/webhooks/index.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^5.1.5"
|
"nanoid": "^5.1.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
@@ -33,14 +33,14 @@
|
|||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@kit/ui": "workspace:*",
|
"@kit/ui": "workspace:*",
|
||||||
"@supabase/supabase-js": "2.57.4",
|
"@supabase/supabase-js": "2.57.4",
|
||||||
"@tanstack/react-query": "5.89.0",
|
"@tanstack/react-query": "5.90.2",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@types/react": "19.1.13",
|
"@types/react": "19.1.13",
|
||||||
"@types/react-dom": "19.1.9",
|
"@types/react-dom": "19.1.9",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"next": "15.5.3",
|
"next": "15.5.4",
|
||||||
"react": "19.1.1",
|
"react": "19.1.1",
|
||||||
"react-dom": "19.1.1",
|
"react-dom": "19.1.1",
|
||||||
"react-hook-form": "^7.63.0",
|
"react-hook-form": "^7.63.0",
|
||||||
|
|||||||
@@ -20,8 +20,8 @@
|
|||||||
"@kit/prettier-config": "workspace:*",
|
"@kit/prettier-config": "workspace:*",
|
||||||
"@kit/shared": "workspace:*",
|
"@kit/shared": "workspace:*",
|
||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@tanstack/react-query": "5.89.0",
|
"@tanstack/react-query": "5.90.2",
|
||||||
"next": "15.5.3",
|
"next": "15.5.4",
|
||||||
"react": "19.1.1",
|
"react": "19.1.1",
|
||||||
"react-dom": "19.1.1",
|
"react-dom": "19.1.1",
|
||||||
"react-i18next": "^15.7.3"
|
"react-i18next": "^15.7.3"
|
||||||
|
|||||||
@@ -7,22 +7,21 @@
|
|||||||
"bin": {
|
"bin": {
|
||||||
"makerkit-mcp-server": "./build/index.js"
|
"makerkit-mcp-server": "./build/index.js"
|
||||||
},
|
},
|
||||||
"typesVersions": {
|
"exports": {
|
||||||
"*": {
|
"./database": "./src/tools/database.ts",
|
||||||
"*": [
|
"./components": "./src/tools/components.ts",
|
||||||
"src/*"
|
"./migrations": "./src/tools/migrations.ts",
|
||||||
]
|
"./prd-manager": "./src/tools/prd-manager.ts",
|
||||||
}
|
"./prompts": "./src/tools/prompts.ts",
|
||||||
|
"./scripts": "./src/tools/scripts.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rm -rf .turbo node_modules",
|
"clean": "rm -rf .turbo node_modules",
|
||||||
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
|
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
|
||||||
"build": "tsc && chmod 755 build/index.js",
|
"build": "tsc",
|
||||||
"build:watch": "tsc --watch",
|
"build:watch": "tsc --watch"
|
||||||
"mcp": "node build/index.js"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@kit/eslint-config": "workspace:*",
|
|
||||||
"@kit/prettier-config": "workspace:*",
|
"@kit/prettier-config": "workspace:*",
|
||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@modelcontextprotocol/sdk": "1.18.1",
|
"@modelcontextprotocol/sdk": "1.18.1",
|
||||||
@@ -30,5 +29,12 @@
|
|||||||
"postgres": "3.4.7",
|
"postgres": "3.4.7",
|
||||||
"zod": "^3.25.74"
|
"zod": "^3.25.74"
|
||||||
},
|
},
|
||||||
"prettier": "@kit/prettier-config"
|
"prettier": "@kit/prettier-config",
|
||||||
|
"typesVersions": {
|
||||||
|
"*": {
|
||||||
|
"*": [
|
||||||
|
"src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
registerDatabaseTools,
|
registerDatabaseTools,
|
||||||
} from './tools/database';
|
} from './tools/database';
|
||||||
import { registerGetMigrationsTools } from './tools/migrations';
|
import { registerGetMigrationsTools } from './tools/migrations';
|
||||||
|
import { registerPRDTools } from './tools/prd-manager';
|
||||||
import { registerPromptsSystem } from './tools/prompts';
|
import { registerPromptsSystem } from './tools/prompts';
|
||||||
import { registerScriptsTools } from './tools/scripts';
|
import { registerScriptsTools } from './tools/scripts';
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ async function main() {
|
|||||||
registerDatabaseResources(server);
|
registerDatabaseResources(server);
|
||||||
registerComponentsTools(server);
|
registerComponentsTools(server);
|
||||||
registerScriptsTools(server);
|
registerScriptsTools(server);
|
||||||
|
registerPRDTools(server);
|
||||||
registerPromptsSystem(server);
|
registerPromptsSystem(server);
|
||||||
|
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
|
|||||||
@@ -92,14 +92,25 @@ interface EnumInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class DatabaseTool {
|
export class DatabaseTool {
|
||||||
|
private static _ROOT_PATH = process.cwd();
|
||||||
|
|
||||||
|
static get ROOT_PATH(): string {
|
||||||
|
return this._ROOT_PATH;
|
||||||
|
}
|
||||||
|
|
||||||
|
static set ROOT_PATH(path: string) {
|
||||||
|
this._ROOT_PATH = path;
|
||||||
|
}
|
||||||
|
|
||||||
static async getSchemaFiles(): Promise<SchemaFile[]> {
|
static async getSchemaFiles(): Promise<SchemaFile[]> {
|
||||||
const schemasPath = join(
|
const schemasPath = join(
|
||||||
process.cwd(),
|
DatabaseTool.ROOT_PATH,
|
||||||
'apps',
|
'apps',
|
||||||
'web',
|
'web',
|
||||||
'supabase',
|
'supabase',
|
||||||
'schemas',
|
'schemas',
|
||||||
);
|
);
|
||||||
|
|
||||||
const files = await readdir(schemasPath);
|
const files = await readdir(schemasPath);
|
||||||
|
|
||||||
const schemaFiles: SchemaFile[] = [];
|
const schemaFiles: SchemaFile[] = [];
|
||||||
@@ -113,10 +124,10 @@ export class DatabaseTool {
|
|||||||
const sectionMatch = content.match(/\* Section: ([^\n*]+)/);
|
const sectionMatch = content.match(/\* Section: ([^\n*]+)/);
|
||||||
const descriptionMatch = content.match(/\* ([^*\n]+)\n \* We create/);
|
const descriptionMatch = content.match(/\* ([^*\n]+)\n \* We create/);
|
||||||
|
|
||||||
// Extract tables and functions from content
|
// Extract tables and functions from content using simple regex (for schema file metadata only)
|
||||||
const tables = this.extractTables(content);
|
const tables = this.extractTablesRegex(content);
|
||||||
const functions = this.extractFunctionNames(content);
|
const functions = this.extractFunctionNamesRegex(content);
|
||||||
const dependencies = this.extractDependencies(content);
|
const dependencies = this.extractDependenciesRegex(content);
|
||||||
const topic = this.determineTopic(file, content);
|
const topic = this.determineTopic(file, content);
|
||||||
|
|
||||||
schemaFiles.push({
|
schemaFiles.push({
|
||||||
@@ -137,6 +148,54 @@ export class DatabaseTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async getFunctions(): Promise<DatabaseFunction[]> {
|
static async getFunctions(): Promise<DatabaseFunction[]> {
|
||||||
|
try {
|
||||||
|
// Query the database directly for function information
|
||||||
|
const functions = await sql`
|
||||||
|
SELECT
|
||||||
|
p.proname as function_name,
|
||||||
|
n.nspname as schema_name,
|
||||||
|
pg_get_function_result(p.oid) as return_type,
|
||||||
|
pg_get_function_arguments(p.oid) as parameters,
|
||||||
|
CASE p.prosecdef WHEN true THEN 'definer' ELSE 'invoker' END as security_level,
|
||||||
|
l.lanname as language,
|
||||||
|
obj_description(p.oid, 'pg_proc') as description
|
||||||
|
FROM pg_proc p
|
||||||
|
JOIN pg_namespace n ON p.pronamespace = n.oid
|
||||||
|
LEFT JOIN pg_language l ON p.prolang = l.oid
|
||||||
|
WHERE n.nspname IN ('public', 'kit')
|
||||||
|
AND p.prokind = 'f' -- Only functions, not procedures
|
||||||
|
ORDER BY n.nspname, p.proname
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Get schema files to map functions to source files
|
||||||
|
const schemaFiles = await this.getSchemaFiles();
|
||||||
|
const fileMapping = this.createFunctionFileMapping(schemaFiles);
|
||||||
|
|
||||||
|
return functions.map((func) => ({
|
||||||
|
name: func.function_name,
|
||||||
|
schema: func.schema_name,
|
||||||
|
returnType: func.return_type || 'unknown',
|
||||||
|
parameters: this.parsePostgresParameters(func.parameters || ''),
|
||||||
|
securityLevel: func.security_level as 'definer' | 'invoker',
|
||||||
|
description: func.description || 'No description available',
|
||||||
|
purpose: this.extractPurpose(
|
||||||
|
func.description || '',
|
||||||
|
func.function_name,
|
||||||
|
),
|
||||||
|
sourceFile:
|
||||||
|
fileMapping[`${func.schema_name}.${func.function_name}`] || 'unknown',
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
'Error querying database functions, falling back to file parsing:',
|
||||||
|
error.message,
|
||||||
|
);
|
||||||
|
// Fallback to file-based extraction if database query fails
|
||||||
|
return this.getFunctionsFromFiles();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getFunctionsFromFiles(): Promise<DatabaseFunction[]> {
|
||||||
const schemaFiles = await this.getSchemaFiles();
|
const schemaFiles = await this.getSchemaFiles();
|
||||||
const functions: DatabaseFunction[] = [];
|
const functions: DatabaseFunction[] = [];
|
||||||
|
|
||||||
@@ -152,6 +211,70 @@ export class DatabaseTool {
|
|||||||
return functions.sort((a, b) => a.name.localeCompare(b.name));
|
return functions.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static createFunctionFileMapping(
|
||||||
|
schemaFiles: SchemaFile[],
|
||||||
|
): Record<string, string> {
|
||||||
|
const mapping: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const file of schemaFiles) {
|
||||||
|
for (const functionName of file.functions) {
|
||||||
|
// Map both public.functionName and functionName to the file
|
||||||
|
mapping[`public.${functionName}`] = file.name;
|
||||||
|
mapping[`kit.${functionName}`] = file.name;
|
||||||
|
mapping[functionName] = file.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static parsePostgresParameters(paramString: string): Array<{
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
}> {
|
||||||
|
if (!paramString.trim()) return [];
|
||||||
|
|
||||||
|
const parameters: Array<{
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// PostgreSQL function arguments format: "name type, name type DEFAULT value"
|
||||||
|
const params = paramString
|
||||||
|
.split(',')
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter((p) => p);
|
||||||
|
|
||||||
|
for (const param of params) {
|
||||||
|
// Match pattern: "name type" or "name type DEFAULT value"
|
||||||
|
const match = param.match(
|
||||||
|
/^(?:(?:IN|OUT|INOUT)\s+)?([a-zA-Z_][a-zA-Z0-9_]*)\s+([^=\s]+)(?:\s+DEFAULT\s+(.+))?$/i,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const [, name, type, defaultValue] = match;
|
||||||
|
parameters.push({
|
||||||
|
name: name.trim(),
|
||||||
|
type: type.trim(),
|
||||||
|
defaultValue: defaultValue?.trim(),
|
||||||
|
});
|
||||||
|
} else if (param.includes(' ')) {
|
||||||
|
// Fallback for unnamed parameters
|
||||||
|
const parts = param.split(' ');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
parameters.push({
|
||||||
|
name: parts[0] || 'unnamed',
|
||||||
|
type: parts.slice(1).join(' ').trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parameters;
|
||||||
|
}
|
||||||
|
|
||||||
static async getFunctionDetails(
|
static async getFunctionDetails(
|
||||||
functionName: string,
|
functionName: string,
|
||||||
): Promise<DatabaseFunction> {
|
): Promise<DatabaseFunction> {
|
||||||
@@ -226,12 +349,13 @@ export class DatabaseTool {
|
|||||||
|
|
||||||
static async getSchemaContent(fileName: string): Promise<string> {
|
static async getSchemaContent(fileName: string): Promise<string> {
|
||||||
const schemasPath = join(
|
const schemasPath = join(
|
||||||
process.cwd(),
|
DatabaseTool.ROOT_PATH,
|
||||||
'apps',
|
'apps',
|
||||||
'web',
|
'web',
|
||||||
'supabase',
|
'supabase',
|
||||||
'schemas',
|
'schemas',
|
||||||
);
|
);
|
||||||
|
|
||||||
const filePath = join(schemasPath, fileName);
|
const filePath = join(schemasPath, fileName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -265,24 +389,61 @@ export class DatabaseTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async getAllProjectTables(): Promise<ProjectTable[]> {
|
static async getAllProjectTables(): Promise<ProjectTable[]> {
|
||||||
|
// Query database directly for table information
|
||||||
|
const tables = await sql`
|
||||||
|
SELECT
|
||||||
|
t.table_name,
|
||||||
|
t.table_schema,
|
||||||
|
obj_description(c.oid, 'pg_class') as description
|
||||||
|
FROM information_schema.tables t
|
||||||
|
LEFT JOIN pg_class c ON c.relname = t.table_name
|
||||||
|
LEFT JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = t.table_schema
|
||||||
|
WHERE t.table_schema IN ('public', 'kit')
|
||||||
|
AND t.table_type = 'BASE TABLE'
|
||||||
|
ORDER BY t.table_schema, t.table_name
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Get schema files to map tables to source files
|
||||||
const schemaFiles = await this.getSchemaFiles();
|
const schemaFiles = await this.getSchemaFiles();
|
||||||
const tables: ProjectTable[] = [];
|
const fileMapping = this.createTableFileMapping(schemaFiles);
|
||||||
|
|
||||||
|
return tables.map((table: any) => ({
|
||||||
|
name: table.table_name,
|
||||||
|
schema: table.table_schema,
|
||||||
|
sourceFile:
|
||||||
|
fileMapping[`${table.table_schema}.${table.table_name}`] ||
|
||||||
|
fileMapping[table.table_name] ||
|
||||||
|
'database',
|
||||||
|
topic: this.getTableTopic(table.table_name, schemaFiles),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static createTableFileMapping(
|
||||||
|
schemaFiles: SchemaFile[],
|
||||||
|
): Record<string, string> {
|
||||||
|
const mapping: Record<string, string> = {};
|
||||||
|
|
||||||
for (const file of schemaFiles) {
|
for (const file of schemaFiles) {
|
||||||
const content = await readFile(file.path, 'utf8');
|
for (const tableName of file.tables) {
|
||||||
const extractedTables = this.extractTablesWithSchema(content);
|
mapping[`public.${tableName}`] = file.name;
|
||||||
|
mapping[`kit.${tableName}`] = file.name;
|
||||||
for (const table of extractedTables) {
|
mapping[tableName] = file.name;
|
||||||
tables.push({
|
|
||||||
name: table.name,
|
|
||||||
schema: table.schema || 'public',
|
|
||||||
sourceFile: file.name,
|
|
||||||
topic: file.topic,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return tables;
|
return mapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getTableTopic(
|
||||||
|
tableName: string,
|
||||||
|
schemaFiles: SchemaFile[],
|
||||||
|
): string {
|
||||||
|
for (const file of schemaFiles) {
|
||||||
|
if (file.tables.includes(tableName)) {
|
||||||
|
return file.topic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'general';
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getAllEnums(): Promise<Record<string, EnumInfo>> {
|
static async getAllEnums(): Promise<Record<string, EnumInfo>> {
|
||||||
@@ -675,78 +836,59 @@ export class DatabaseTool {
|
|||||||
return `Custom database function: ${description}`;
|
return `Custom database function: ${description}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static extractTables(content: string): string[] {
|
// Fallback regex methods (simplified and more reliable)
|
||||||
const tables: string[] = [];
|
private static extractTablesRegex(content: string): string[] {
|
||||||
const tableRegex =
|
const tableMatches = content.match(
|
||||||
/create\s+table\s+(?:if\s+not\s+exists\s+)?(?:public\.)?([a-zA-Z_][a-zA-Z0-9_]*)/gi;
|
/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 extractTablesWithSchema(content: string): Array<{
|
|
||||||
name: string;
|
|
||||||
schema: string;
|
|
||||||
}> {
|
|
||||||
const tables: Array<{ name: string; schema: string }> = [];
|
|
||||||
const tableRegex =
|
|
||||||
/create\s+table\s+(?:if\s+not\s+exists\s+)?(?:([a-zA-Z_][a-zA-Z0-9_]*)\.)?([a-zA-Z_][a-zA-Z0-9_]*)/gi;
|
|
||||||
let match;
|
|
||||||
|
|
||||||
while ((match = tableRegex.exec(content)) !== null) {
|
|
||||||
if (match[2]) {
|
|
||||||
tables.push({
|
|
||||||
schema: match[1] || 'public',
|
|
||||||
name: match[2],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tables.filter(
|
|
||||||
(table, index, arr) =>
|
|
||||||
arr.findIndex(
|
|
||||||
(t) => t.name === table.name && t.schema === table.schema,
|
|
||||||
) === index,
|
|
||||||
);
|
);
|
||||||
|
if (!tableMatches) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
|
tableMatches
|
||||||
|
.map((match) => {
|
||||||
|
const nameMatch = match.match(/([a-zA-Z_][a-zA-Z0-9_]*)$/i);
|
||||||
|
return nameMatch ? nameMatch[1] : '';
|
||||||
|
})
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static extractFunctionNames(content: string): string[] {
|
private static extractFunctionNamesRegex(content: string): string[] {
|
||||||
const functions: string[] = [];
|
const functionMatches = content.match(
|
||||||
const functionRegex =
|
/create\s+(?:or\s+replace\s+)?function\s+(?:public\.)?([a-zA-Z_][a-zA-Z0-9_]*)/gi,
|
||||||
/create\s+(?:or\s+replace\s+)?function\s+(?:public\.)?([a-zA-Z_][a-zA-Z0-9_]*)/gi;
|
);
|
||||||
let match;
|
if (!functionMatches) return [];
|
||||||
|
|
||||||
while ((match = functionRegex.exec(content)) !== null) {
|
return [
|
||||||
if (match[1]) {
|
...new Set(
|
||||||
functions.push(match[1]);
|
functionMatches
|
||||||
}
|
.map((match) => {
|
||||||
|
const nameMatch = match.match(/([a-zA-Z_][a-zA-Z0-9_]*)$/i);
|
||||||
|
return nameMatch ? nameMatch[1] : '';
|
||||||
|
})
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...new Set(functions)]; // Remove duplicates
|
private static extractDependenciesRegex(content: string): string[] {
|
||||||
}
|
const refMatches = content.match(
|
||||||
|
/references\s+(?:public\.)?([a-zA-Z_][a-zA-Z0-9_]*)/gi,
|
||||||
|
);
|
||||||
|
if (!refMatches) return [];
|
||||||
|
|
||||||
private static extractDependencies(content: string): string[] {
|
return [
|
||||||
const dependencies: string[] = [];
|
...new Set(
|
||||||
|
refMatches
|
||||||
// Look for references to other tables
|
.map((match) => {
|
||||||
const referencesRegex =
|
const nameMatch = match.match(/([a-zA-Z_][a-zA-Z0-9_]*)$/i);
|
||||||
/references\s+(?:public\.)?([a-zA-Z_][a-zA-Z0-9_]*)/gi;
|
return nameMatch && nameMatch[1] !== 'users' ? nameMatch[1] : '';
|
||||||
let match;
|
})
|
||||||
|
.filter(Boolean),
|
||||||
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 extractTableDefinition(
|
private static extractTableDefinition(
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ export class MigrationsTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static CreateMigration(name: string) {
|
static CreateMigration(name: string) {
|
||||||
return promisify(exec)(`pnpm --filter web supabase migration new ${name}`);
|
return promisify(exec)(`pnpm --filter web supabase migrations new ${name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Diff() {
|
static Diff() {
|
||||||
return promisify(exec)(`supabase migration diff`);
|
return promisify(exec)(`supabase db diff`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
985
packages/mcp-server/src/tools/prd-manager.ts
Normal file
985
packages/mcp-server/src/tools/prd-manager.ts
Normal file
@@ -0,0 +1,985 @@
|
|||||||
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Custom phase for organizing user stories
|
||||||
|
interface CustomPhase {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
color: string; // Tailwind color class
|
||||||
|
order: number;
|
||||||
|
userStoryIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Business-focused user story following ChatPRD best practices
|
||||||
|
interface UserStory {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
userStory: string;
|
||||||
|
businessValue: string;
|
||||||
|
acceptanceCriteria: string[];
|
||||||
|
status:
|
||||||
|
| 'not_started'
|
||||||
|
| 'research'
|
||||||
|
| 'in_progress'
|
||||||
|
| 'review'
|
||||||
|
| 'completed'
|
||||||
|
| 'blocked';
|
||||||
|
priority: 'P0' | 'P1' | 'P2' | 'P3';
|
||||||
|
estimatedComplexity: 'XS' | 'S' | 'M' | 'L' | 'XL';
|
||||||
|
dependencies: string[];
|
||||||
|
notes?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Structured PRD following ChatPRD format
|
||||||
|
interface StructuredPRD {
|
||||||
|
introduction: {
|
||||||
|
title: string;
|
||||||
|
overview: string;
|
||||||
|
lastUpdated: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
problemStatement: {
|
||||||
|
problem: string;
|
||||||
|
marketOpportunity: string;
|
||||||
|
targetUsers: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
solutionOverview: {
|
||||||
|
description: string;
|
||||||
|
keyFeatures: string[];
|
||||||
|
successMetrics: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
userStories: UserStory[];
|
||||||
|
customPhases?: CustomPhase[];
|
||||||
|
|
||||||
|
technicalRequirements: {
|
||||||
|
constraints: string[];
|
||||||
|
integrationNeeds: string[];
|
||||||
|
complianceRequirements: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
acceptanceCriteria: {
|
||||||
|
global: string[];
|
||||||
|
qualityStandards: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
constraints: {
|
||||||
|
timeline: string;
|
||||||
|
budget?: string;
|
||||||
|
resources: string[];
|
||||||
|
nonNegotiables: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
metadata: {
|
||||||
|
version: string;
|
||||||
|
created: string;
|
||||||
|
lastUpdated: string;
|
||||||
|
approver: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
progress: {
|
||||||
|
overall: number;
|
||||||
|
completed: number;
|
||||||
|
total: number;
|
||||||
|
blocked: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PRDManager {
|
||||||
|
private static ROOT_PATH = process.cwd();
|
||||||
|
|
||||||
|
private static get PRDS_DIR() {
|
||||||
|
return join(this.ROOT_PATH, '.prds');
|
||||||
|
}
|
||||||
|
|
||||||
|
static setRootPath(path: string) {
|
||||||
|
this.ROOT_PATH = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async ensurePRDsDirectory(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await mkdir(this.PRDS_DIR, { recursive: true });
|
||||||
|
} catch {
|
||||||
|
// Directory exists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async createStructuredPRD(
|
||||||
|
title: string,
|
||||||
|
overview: string,
|
||||||
|
problemStatement: string,
|
||||||
|
marketOpportunity: string,
|
||||||
|
targetUsers: string[],
|
||||||
|
solutionDescription: string,
|
||||||
|
keyFeatures: string[],
|
||||||
|
successMetrics: string[],
|
||||||
|
): Promise<string> {
|
||||||
|
await this.ensurePRDsDirectory();
|
||||||
|
|
||||||
|
const filename = `${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.json`;
|
||||||
|
const now = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const prd: StructuredPRD = {
|
||||||
|
introduction: {
|
||||||
|
title,
|
||||||
|
overview,
|
||||||
|
lastUpdated: now,
|
||||||
|
},
|
||||||
|
problemStatement: {
|
||||||
|
problem: problemStatement,
|
||||||
|
marketOpportunity,
|
||||||
|
targetUsers,
|
||||||
|
},
|
||||||
|
solutionOverview: {
|
||||||
|
description: solutionDescription,
|
||||||
|
keyFeatures,
|
||||||
|
successMetrics,
|
||||||
|
},
|
||||||
|
userStories: [],
|
||||||
|
technicalRequirements: {
|
||||||
|
constraints: [],
|
||||||
|
integrationNeeds: [],
|
||||||
|
complianceRequirements: [],
|
||||||
|
},
|
||||||
|
acceptanceCriteria: {
|
||||||
|
global: [],
|
||||||
|
qualityStandards: [],
|
||||||
|
},
|
||||||
|
constraints: {
|
||||||
|
timeline: '',
|
||||||
|
resources: [],
|
||||||
|
nonNegotiables: [],
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
version: '1.0',
|
||||||
|
created: now,
|
||||||
|
lastUpdated: now,
|
||||||
|
approver: '',
|
||||||
|
},
|
||||||
|
progress: {
|
||||||
|
overall: 0,
|
||||||
|
completed: 0,
|
||||||
|
total: 0,
|
||||||
|
blocked: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const filePath = join(this.PRDS_DIR, filename);
|
||||||
|
await writeFile(filePath, JSON.stringify(prd, null, 2), 'utf8');
|
||||||
|
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async addUserStory(
|
||||||
|
filename: string,
|
||||||
|
userType: string,
|
||||||
|
action: string,
|
||||||
|
benefit: string,
|
||||||
|
acceptanceCriteria: string[],
|
||||||
|
priority: UserStory['priority'] = 'P2',
|
||||||
|
): Promise<string> {
|
||||||
|
const prd = await this.loadPRD(filename);
|
||||||
|
|
||||||
|
const userStory = `As a ${userType}, I want to ${action} so that ${benefit}`;
|
||||||
|
const title = this.extractTitleFromAction(action);
|
||||||
|
const complexity = this.assessComplexity(acceptanceCriteria);
|
||||||
|
|
||||||
|
const storyNumber = prd.userStories.length + 1;
|
||||||
|
const storyId = `US${storyNumber.toString().padStart(3, '0')}`;
|
||||||
|
|
||||||
|
const newStory: UserStory = {
|
||||||
|
id: storyId,
|
||||||
|
title,
|
||||||
|
userStory,
|
||||||
|
businessValue: benefit,
|
||||||
|
acceptanceCriteria,
|
||||||
|
status: 'not_started',
|
||||||
|
priority,
|
||||||
|
estimatedComplexity: complexity,
|
||||||
|
dependencies: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
prd.userStories.push(newStory);
|
||||||
|
this.updateProgress(prd);
|
||||||
|
|
||||||
|
await this.savePRD(filename, prd);
|
||||||
|
|
||||||
|
return `User story ${storyId} added: "${title}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateStoryStatus(
|
||||||
|
filename: string,
|
||||||
|
storyId: string,
|
||||||
|
status: UserStory['status'],
|
||||||
|
notes?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const prd = await this.loadPRD(filename);
|
||||||
|
const story = prd.userStories.find((s) => s.id === storyId);
|
||||||
|
|
||||||
|
if (!story) {
|
||||||
|
throw new Error(`Story ${storyId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
story.status = status;
|
||||||
|
if (notes) {
|
||||||
|
story.notes = notes;
|
||||||
|
}
|
||||||
|
if (status === 'completed') {
|
||||||
|
story.completedAt = new Date().toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateProgress(prd);
|
||||||
|
await this.savePRD(filename, prd);
|
||||||
|
|
||||||
|
return `Story "${story.title}" updated to ${status}. Progress: ${prd.progress.overall}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async exportAsMarkdown(filename: string): Promise<string> {
|
||||||
|
const prd = await this.loadPRD(filename);
|
||||||
|
const content = this.formatPRDMarkdown(prd);
|
||||||
|
|
||||||
|
const markdownFile = filename.replace('.json', '.md');
|
||||||
|
const markdownPath = join(this.PRDS_DIR, markdownFile);
|
||||||
|
await writeFile(markdownPath, content, 'utf8');
|
||||||
|
|
||||||
|
return markdownFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async generateImplementationPrompts(
|
||||||
|
filename: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const prd = await this.loadPRD(filename);
|
||||||
|
const prompts: string[] = [];
|
||||||
|
|
||||||
|
prompts.push(
|
||||||
|
`Implement "${prd.introduction.title}" based on the PRD. ` +
|
||||||
|
`Goal: ${prd.solutionOverview.description}. ` +
|
||||||
|
`Key features: ${prd.solutionOverview.keyFeatures.join(', ')}. ` +
|
||||||
|
`You must research and decide all technical implementation details.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const readyStories = prd.userStories.filter(
|
||||||
|
(s) => s.status === 'not_started',
|
||||||
|
);
|
||||||
|
readyStories.slice(0, 3).forEach((story) => {
|
||||||
|
prompts.push(
|
||||||
|
`Implement ${story.id}: "${story.userStory}". ` +
|
||||||
|
`Business value: ${story.businessValue}. ` +
|
||||||
|
`Acceptance criteria: ${story.acceptanceCriteria.join(' | ')}. ` +
|
||||||
|
`Research technical approach and implement.`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return prompts;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getImprovementSuggestions(filename: string): Promise<string[]> {
|
||||||
|
const prd = await this.loadPRD(filename);
|
||||||
|
const suggestions: string[] = [];
|
||||||
|
|
||||||
|
if (prd.userStories.length === 0) {
|
||||||
|
suggestions.push('Add user stories to define specific functionality');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prd.solutionOverview.successMetrics.length === 0) {
|
||||||
|
suggestions.push('Define success metrics to measure progress');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prd.acceptanceCriteria.global.length === 0) {
|
||||||
|
suggestions.push('Add global acceptance criteria for quality standards');
|
||||||
|
}
|
||||||
|
|
||||||
|
const vagueStories = prd.userStories.filter(
|
||||||
|
(s) => s.acceptanceCriteria.length < 2,
|
||||||
|
);
|
||||||
|
if (vagueStories.length > 0) {
|
||||||
|
suggestions.push(
|
||||||
|
`${vagueStories.length} stories need more detailed acceptance criteria`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockedStories = prd.userStories.filter(
|
||||||
|
(s) => s.status === 'blocked',
|
||||||
|
);
|
||||||
|
if (blockedStories.length > 0) {
|
||||||
|
suggestions.push(
|
||||||
|
`${blockedStories.length} stories are blocked and need attention`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async listPRDs(): Promise<string[]> {
|
||||||
|
await this.ensurePRDsDirectory();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await readdir(this.PRDS_DIR);
|
||||||
|
|
||||||
|
return files.filter((file) => file.endsWith('.json'));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getPRDContent(filename: string): Promise<string> {
|
||||||
|
const filePath = join(this.PRDS_DIR, filename);
|
||||||
|
try {
|
||||||
|
return await readFile(filePath, 'utf8');
|
||||||
|
} catch {
|
||||||
|
throw new Error(`PRD file "${filename}" not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getProjectStatus(filename: string): Promise<{
|
||||||
|
progress: number;
|
||||||
|
summary: string;
|
||||||
|
nextSteps: string[];
|
||||||
|
blockers: UserStory[];
|
||||||
|
}> {
|
||||||
|
const prd = await this.loadPRD(filename);
|
||||||
|
|
||||||
|
const blockers = prd.userStories.filter((s) => s.status === 'blocked');
|
||||||
|
const inProgress = prd.userStories.filter(
|
||||||
|
(s) => s.status === 'in_progress',
|
||||||
|
);
|
||||||
|
const nextPending = prd.userStories
|
||||||
|
.filter((s) => s.status === 'not_started')
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
const nextSteps = [
|
||||||
|
...inProgress.map((s) => `Continue: ${s.title}`),
|
||||||
|
...nextPending.map((s) => `Start: ${s.title}`),
|
||||||
|
];
|
||||||
|
|
||||||
|
const summary = `${prd.progress.completed}/${prd.progress.total} stories completed (${prd.progress.overall}%). Total stories: ${prd.userStories.length}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
progress: prd.progress.overall,
|
||||||
|
summary,
|
||||||
|
nextSteps,
|
||||||
|
blockers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom Phase Management
|
||||||
|
static async createCustomPhase(
|
||||||
|
filename: string,
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
color: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const prd = await this.loadPRD(filename);
|
||||||
|
|
||||||
|
// Initialize customPhases if it doesn't exist
|
||||||
|
if (!prd.customPhases) {
|
||||||
|
prd.customPhases = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for unique name
|
||||||
|
if (prd.customPhases.some((p) => p.name === name)) {
|
||||||
|
throw new Error(`Phase with name "${name}" already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const phaseId = `PHASE${(prd.customPhases.length + 1).toString().padStart(3, '0')}`;
|
||||||
|
const order = prd.customPhases.length;
|
||||||
|
|
||||||
|
const newPhase: CustomPhase = {
|
||||||
|
id: phaseId,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
color,
|
||||||
|
order,
|
||||||
|
userStoryIds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
prd.customPhases.push(newPhase);
|
||||||
|
await this.savePRD(filename, prd);
|
||||||
|
|
||||||
|
return `Custom phase "${name}" created with ID ${phaseId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateCustomPhase(
|
||||||
|
filename: string,
|
||||||
|
phaseId: string,
|
||||||
|
updates: Partial<Pick<CustomPhase, 'name' | 'description' | 'color'>>,
|
||||||
|
): Promise<string> {
|
||||||
|
const prd = await this.loadPRD(filename);
|
||||||
|
|
||||||
|
if (!prd.customPhases) {
|
||||||
|
throw new Error('No custom phases found in this PRD');
|
||||||
|
}
|
||||||
|
|
||||||
|
const phase = prd.customPhases.find((p) => p.id === phaseId);
|
||||||
|
if (!phase) {
|
||||||
|
throw new Error(`Phase ${phaseId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for unique name if updating name
|
||||||
|
if (updates.name && updates.name !== phase.name) {
|
||||||
|
if (
|
||||||
|
prd.customPhases.some(
|
||||||
|
(p) => p.name === updates.name && p.id !== phaseId,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(`Phase with name "${updates.name}" already exists`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(phase, updates);
|
||||||
|
await this.savePRD(filename, prd);
|
||||||
|
|
||||||
|
return `Phase "${phase.name}" updated successfully`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteCustomPhase(
|
||||||
|
filename: string,
|
||||||
|
phaseId: string,
|
||||||
|
reassignToPhaseId?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const prd = await this.loadPRD(filename);
|
||||||
|
|
||||||
|
if (!prd.customPhases) {
|
||||||
|
throw new Error('No custom phases found in this PRD');
|
||||||
|
}
|
||||||
|
|
||||||
|
const phaseIndex = prd.customPhases.findIndex((p) => p.id === phaseId);
|
||||||
|
if (phaseIndex === -1) {
|
||||||
|
throw new Error(`Phase ${phaseId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const phase = prd.customPhases[phaseIndex];
|
||||||
|
|
||||||
|
// Handle story reassignment
|
||||||
|
if (phase.userStoryIds.length > 0) {
|
||||||
|
if (reassignToPhaseId) {
|
||||||
|
const targetPhase = prd.customPhases.find(
|
||||||
|
(p) => p.id === reassignToPhaseId,
|
||||||
|
);
|
||||||
|
if (!targetPhase) {
|
||||||
|
throw new Error(
|
||||||
|
`Target phase ${reassignToPhaseId} not found for reassignment`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
targetPhase.userStoryIds.push(...phase.userStoryIds);
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
`Phase "${phase.name}" contains ${phase.userStoryIds.length} user stories. Provide reassignToPhaseId or move stories first.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prd.customPhases.splice(phaseIndex, 1);
|
||||||
|
await this.savePRD(filename, prd);
|
||||||
|
|
||||||
|
return `Phase "${phase.name}" deleted successfully`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async assignStoryToPhase(
|
||||||
|
filename: string,
|
||||||
|
storyId: string,
|
||||||
|
phaseId: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const prd = await this.loadPRD(filename);
|
||||||
|
|
||||||
|
if (!prd.customPhases) {
|
||||||
|
throw new Error('No custom phases found in this PRD');
|
||||||
|
}
|
||||||
|
|
||||||
|
const story = prd.userStories.find((s) => s.id === storyId);
|
||||||
|
if (!story) {
|
||||||
|
throw new Error(`Story ${storyId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPhase = prd.customPhases.find((p) => p.id === phaseId);
|
||||||
|
if (!targetPhase) {
|
||||||
|
throw new Error(`Phase ${phaseId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove story from all phases first
|
||||||
|
prd.customPhases.forEach((phase) => {
|
||||||
|
phase.userStoryIds = phase.userStoryIds.filter((id) => id !== storyId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add to target phase
|
||||||
|
if (!targetPhase.userStoryIds.includes(storyId)) {
|
||||||
|
targetPhase.userStoryIds.push(storyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.savePRD(filename, prd);
|
||||||
|
|
||||||
|
return `Story "${story.title}" assigned to phase "${targetPhase.name}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getCustomPhases(filename: string): Promise<CustomPhase[]> {
|
||||||
|
const prd = await this.loadPRD(filename);
|
||||||
|
return prd.customPhases || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private methods
|
||||||
|
private static async loadPRD(filename: string): Promise<StructuredPRD> {
|
||||||
|
const filePath = join(this.PRDS_DIR, filename);
|
||||||
|
try {
|
||||||
|
const content = await readFile(filePath, 'utf8');
|
||||||
|
return JSON.parse(content);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`PRD file "${filename}" not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async savePRD(
|
||||||
|
filename: string,
|
||||||
|
prd: StructuredPRD,
|
||||||
|
): Promise<void> {
|
||||||
|
prd.metadata.lastUpdated = new Date().toISOString().split('T')[0];
|
||||||
|
const filePath = join(this.PRDS_DIR, filename);
|
||||||
|
await writeFile(filePath, JSON.stringify(prd, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static extractTitleFromAction(action: string): string {
|
||||||
|
const cleaned = action.trim().toLowerCase();
|
||||||
|
const words = cleaned.split(' ').slice(0, 4);
|
||||||
|
return words
|
||||||
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static assessComplexity(
|
||||||
|
criteria: string[],
|
||||||
|
): UserStory['estimatedComplexity'] {
|
||||||
|
const count = criteria.length;
|
||||||
|
if (count <= 2) return 'XS';
|
||||||
|
if (count <= 3) return 'S';
|
||||||
|
if (count <= 5) return 'M';
|
||||||
|
if (count <= 8) return 'L';
|
||||||
|
return 'XL';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static updateProgress(prd: StructuredPRD): void {
|
||||||
|
const completed = prd.userStories.filter(
|
||||||
|
(s) => s.status === 'completed',
|
||||||
|
).length;
|
||||||
|
const blocked = prd.userStories.filter(
|
||||||
|
(s) => s.status === 'blocked',
|
||||||
|
).length;
|
||||||
|
|
||||||
|
prd.progress.completed = completed;
|
||||||
|
prd.progress.total = prd.userStories.length;
|
||||||
|
prd.progress.blocked = blocked;
|
||||||
|
prd.progress.overall =
|
||||||
|
prd.userStories.length > 0
|
||||||
|
? Math.round((completed / prd.userStories.length) * 100)
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static formatPRDMarkdown(prd: StructuredPRD): string {
|
||||||
|
let content = `# ${prd.introduction.title}\n\n`;
|
||||||
|
|
||||||
|
content += `## Introduction\n\n`;
|
||||||
|
content += `${prd.introduction.overview}\n\n`;
|
||||||
|
content += `**Last Updated:** ${prd.introduction.lastUpdated}\n`;
|
||||||
|
content += `**Version:** ${prd.metadata.version}\n\n`;
|
||||||
|
|
||||||
|
content += `## Problem Statement\n\n`;
|
||||||
|
content += `${prd.problemStatement.problem}\n\n`;
|
||||||
|
content += `### Market Opportunity\n${prd.problemStatement.marketOpportunity}\n\n`;
|
||||||
|
content += `### Target Users\n`;
|
||||||
|
prd.problemStatement.targetUsers.forEach((user) => {
|
||||||
|
content += `- ${user}\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
content += `\n## Solution Overview\n\n`;
|
||||||
|
content += `${prd.solutionOverview.description}\n\n`;
|
||||||
|
content += `### Key Features\n`;
|
||||||
|
prd.solutionOverview.keyFeatures.forEach((feature) => {
|
||||||
|
content += `- ${feature}\n`;
|
||||||
|
});
|
||||||
|
content += `\n### Success Metrics\n`;
|
||||||
|
prd.solutionOverview.successMetrics.forEach((metric) => {
|
||||||
|
content += `- ${metric}\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
content += `\n## User Stories\n\n`;
|
||||||
|
|
||||||
|
const priorities: UserStory['priority'][] = ['P0', 'P1', 'P2', 'P3'];
|
||||||
|
priorities.forEach((priority) => {
|
||||||
|
const stories = prd.userStories.filter((s) => s.priority === priority);
|
||||||
|
if (stories.length > 0) {
|
||||||
|
content += `### Priority ${priority}\n\n`;
|
||||||
|
|
||||||
|
stories.forEach((story) => {
|
||||||
|
const statusIcon = this.getStatusIcon(story.status);
|
||||||
|
content += `#### ${story.id}: ${story.title} ${statusIcon} [${story.estimatedComplexity}]\n\n`;
|
||||||
|
content += `**User Story:** ${story.userStory}\n\n`;
|
||||||
|
content += `**Business Value:** ${story.businessValue}\n\n`;
|
||||||
|
content += `**Acceptance Criteria:**\n`;
|
||||||
|
story.acceptanceCriteria.forEach((criterion) => {
|
||||||
|
content += `- ${criterion}\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (story.dependencies.length > 0) {
|
||||||
|
content += `\n**Dependencies:** ${story.dependencies.join(', ')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
content += '\n';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
content += `\n## Progress\n\n`;
|
||||||
|
content += `**Overall:** ${prd.progress.overall}% (${prd.progress.completed}/${prd.progress.total} stories)\n`;
|
||||||
|
if (prd.progress.blocked > 0) {
|
||||||
|
content += `**Blocked:** ${prd.progress.blocked} stories need attention\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
content += `\n---\n\n`;
|
||||||
|
content += `*Approver: ${prd.metadata.approver || 'TBD'}*\n`;
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getStatusIcon(status: UserStory['status']): string {
|
||||||
|
const icons = {
|
||||||
|
not_started: '⏳',
|
||||||
|
research: '🔍',
|
||||||
|
in_progress: '🚧',
|
||||||
|
review: '👀',
|
||||||
|
completed: '✅',
|
||||||
|
blocked: '🚫',
|
||||||
|
};
|
||||||
|
return icons[status];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCP Server Tool Registration
|
||||||
|
export function registerPRDTools(server: McpServer) {
|
||||||
|
createListPRDsTool(server);
|
||||||
|
createGetPRDTool(server);
|
||||||
|
createCreatePRDTool(server);
|
||||||
|
createAddUserStoryTool(server);
|
||||||
|
createUpdateStoryStatusTool(server);
|
||||||
|
createExportMarkdownTool(server);
|
||||||
|
createGetImplementationPromptsTool(server);
|
||||||
|
createGetImprovementSuggestionsTool(server);
|
||||||
|
createGetProjectStatusTool(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createListPRDsTool(server: McpServer) {
|
||||||
|
return server.tool(
|
||||||
|
'list_prds',
|
||||||
|
'List all Product Requirements Documents',
|
||||||
|
async () => {
|
||||||
|
const prds = await PRDManager.listPRDs();
|
||||||
|
|
||||||
|
if (prds.length === 0) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'No PRD files found in .prds folder',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const prdList = prds.map((prd) => `- ${prd}`).join('\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `Found ${prds.length} PRD files:\n\n${prdList}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGetPRDTool(server: McpServer) {
|
||||||
|
return server.tool(
|
||||||
|
'get_prd',
|
||||||
|
'Get the contents of a specific PRD file',
|
||||||
|
{
|
||||||
|
state: z.object({
|
||||||
|
filename: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ state }) => {
|
||||||
|
const content = await PRDManager.getPRDContent(state.filename);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: content,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCreatePRDTool(server: McpServer) {
|
||||||
|
return server.tool(
|
||||||
|
'create_prd',
|
||||||
|
'Create a new structured PRD following ChatPRD best practices',
|
||||||
|
{
|
||||||
|
state: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
overview: z.string(),
|
||||||
|
problemStatement: z.string(),
|
||||||
|
marketOpportunity: z.string(),
|
||||||
|
targetUsers: z.array(z.string()),
|
||||||
|
solutionDescription: z.string(),
|
||||||
|
keyFeatures: z.array(z.string()),
|
||||||
|
successMetrics: z.array(z.string()),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ state }) => {
|
||||||
|
const filename = await PRDManager.createStructuredPRD(
|
||||||
|
state.title,
|
||||||
|
state.overview,
|
||||||
|
state.problemStatement,
|
||||||
|
state.marketOpportunity,
|
||||||
|
state.targetUsers,
|
||||||
|
state.solutionDescription,
|
||||||
|
state.keyFeatures,
|
||||||
|
state.successMetrics,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `PRD created successfully: ${filename}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAddUserStoryTool(server: McpServer) {
|
||||||
|
return server.tool(
|
||||||
|
'add_user_story',
|
||||||
|
'Add a new user story to an existing PRD',
|
||||||
|
{
|
||||||
|
state: z.object({
|
||||||
|
filename: z.string(),
|
||||||
|
userType: z.string(),
|
||||||
|
action: z.string(),
|
||||||
|
benefit: z.string(),
|
||||||
|
acceptanceCriteria: z.array(z.string()),
|
||||||
|
priority: z.enum(['P0', 'P1', 'P2', 'P3']).default('P2'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ state }) => {
|
||||||
|
const result = await PRDManager.addUserStory(
|
||||||
|
state.filename,
|
||||||
|
state.userType,
|
||||||
|
state.action,
|
||||||
|
state.benefit,
|
||||||
|
state.acceptanceCriteria,
|
||||||
|
state.priority,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: result,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createUpdateStoryStatusTool(server: McpServer) {
|
||||||
|
return server.tool(
|
||||||
|
'update_story_status',
|
||||||
|
'Update the status of a specific user story',
|
||||||
|
{
|
||||||
|
state: z.object({
|
||||||
|
filename: z.string(),
|
||||||
|
storyId: z.string(),
|
||||||
|
status: z.enum([
|
||||||
|
'not_started',
|
||||||
|
'research',
|
||||||
|
'in_progress',
|
||||||
|
'review',
|
||||||
|
'completed',
|
||||||
|
'blocked',
|
||||||
|
]),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ state }) => {
|
||||||
|
const result = await PRDManager.updateStoryStatus(
|
||||||
|
state.filename,
|
||||||
|
state.storyId,
|
||||||
|
state.status,
|
||||||
|
state.notes,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: result,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createExportMarkdownTool(server: McpServer) {
|
||||||
|
return server.tool(
|
||||||
|
'export_prd_markdown',
|
||||||
|
'Export PRD as markdown for visualization and sharing',
|
||||||
|
{
|
||||||
|
state: z.object({
|
||||||
|
filename: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ state }) => {
|
||||||
|
const markdownFile = await PRDManager.exportAsMarkdown(state.filename);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `PRD exported as markdown: ${markdownFile}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGetImplementationPromptsTool(server: McpServer) {
|
||||||
|
return server.tool(
|
||||||
|
'get_implementation_prompts',
|
||||||
|
'Generate Claude Code implementation prompts from PRD',
|
||||||
|
{
|
||||||
|
state: z.object({
|
||||||
|
filename: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ state }) => {
|
||||||
|
const prompts = await PRDManager.generateImplementationPrompts(
|
||||||
|
state.filename,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (prompts.length === 0) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'No implementation prompts available. Add user stories first.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const promptsList = prompts.map((p, i) => `${i + 1}. ${p}`).join('\n\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `Implementation prompts:\n\n${promptsList}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGetImprovementSuggestionsTool(server: McpServer) {
|
||||||
|
return server.tool(
|
||||||
|
'get_improvement_suggestions',
|
||||||
|
'Get AI-powered suggestions to improve the PRD',
|
||||||
|
{
|
||||||
|
state: z.object({
|
||||||
|
filename: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ state }) => {
|
||||||
|
const suggestions = await PRDManager.getImprovementSuggestions(
|
||||||
|
state.filename,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (suggestions.length === 0) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'PRD looks good! No specific improvements suggested at this time.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestionsList = suggestions.map((s) => `- ${s}`).join('\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `Improvement suggestions:\n\n${suggestionsList}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGetProjectStatusTool(server: McpServer) {
|
||||||
|
return server.tool(
|
||||||
|
'get_project_status',
|
||||||
|
'Get comprehensive status overview of the PRD project',
|
||||||
|
{
|
||||||
|
state: z.object({
|
||||||
|
filename: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ state }) => {
|
||||||
|
const status = await PRDManager.getProjectStatus(state.filename);
|
||||||
|
|
||||||
|
let result = `**Project Status**\n\n`;
|
||||||
|
result += `${status.summary}\n\n`;
|
||||||
|
|
||||||
|
if (status.nextSteps.length > 0) {
|
||||||
|
result += `**Next Steps:**\n`;
|
||||||
|
status.nextSteps.forEach((step) => {
|
||||||
|
result += `- ${step}\n`;
|
||||||
|
});
|
||||||
|
result += '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.blockers.length > 0) {
|
||||||
|
result += `**Blockers:**\n`;
|
||||||
|
status.blockers.forEach((blocker) => {
|
||||||
|
result += `- ${blocker.title}: ${blocker.notes || 'No details provided'}\n`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: result,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -124,250 +124,6 @@ export class PromptsManager {
|
|||||||
'Performance analysis of database queries',
|
'Performance analysis of database queries',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'component_implementation',
|
|
||||||
title: 'Component Implementation Guide',
|
|
||||||
description:
|
|
||||||
'Generate implementation guidance for creating new UI components',
|
|
||||||
category: 'development',
|
|
||||||
arguments: [
|
|
||||||
{
|
|
||||||
name: 'component_description',
|
|
||||||
description: 'Description of the component to implement',
|
|
||||||
required: true,
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'component_type',
|
|
||||||
description: 'Type of component to create',
|
|
||||||
required: true,
|
|
||||||
type: 'enum',
|
|
||||||
options: ['shadcn', 'makerkit', 'page', 'form', 'table', 'modal'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'features',
|
|
||||||
description: 'Specific features or functionality needed',
|
|
||||||
required: false,
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
template: `Help me implement a {{component_type}} component: {{component_description}}
|
|
||||||
|
|
||||||
{{#if features}}
|
|
||||||
**Required Features:**
|
|
||||||
{{features}}
|
|
||||||
{{/if}}
|
|
||||||
|
|
||||||
**Please provide:**
|
|
||||||
1. **Component Design:** Architecture and structure recommendations
|
|
||||||
2. **Code Implementation:** Full TypeScript/React code with proper typing
|
|
||||||
3. **Styling Approach:** Tailwind CSS classes and variants (use CVA if applicable)
|
|
||||||
4. **Props Interface:** Complete TypeScript interface definition
|
|
||||||
5. **Usage Examples:** How to use the component in different scenarios
|
|
||||||
6. **Testing Strategy:** Unit tests and accessibility considerations
|
|
||||||
7. **Makerkit Integration:** How this fits with existing patterns
|
|
||||||
|
|
||||||
**Makerkit Implementation Requirements:**
|
|
||||||
|
|
||||||
**TypeScript Standards:**
|
|
||||||
- Strict TypeScript with no 'any' types
|
|
||||||
- Use implicit type inference unless impossible
|
|
||||||
- Proper error handling with typed errors
|
|
||||||
- Clean code without unnecessary comments
|
|
||||||
|
|
||||||
**Component Architecture:**
|
|
||||||
- Functional components with proper 'use client' directive
|
|
||||||
- Use existing @kit/ui components (shadcn + makerkit customs)
|
|
||||||
- Follow established patterns: enhanced-data-table, if, trans, page
|
|
||||||
- Implement proper conditional rendering with <If> component
|
|
||||||
- Display loading indicators with LoadingSpinner component where appropriate
|
|
||||||
- Encapsulate repeated blocks of code into reusable local components
|
|
||||||
|
|
||||||
**Styling & UI Standards:**
|
|
||||||
- Tailwind CSS 4 with CVA (Class Variance Authority) for variants
|
|
||||||
- Responsive design with mobile-first approach
|
|
||||||
- Proper accessibility with ARIA attributes and data-test for E2E
|
|
||||||
- Use shadcn components as base, extend with makerkit patterns
|
|
||||||
|
|
||||||
**State & Data Management:**
|
|
||||||
- Single state objects over multiple useState
|
|
||||||
- Server-side data fetching with RSC preferred
|
|
||||||
- Supabase client integration with proper error handling
|
|
||||||
- Account-based data access with proper RLS policies
|
|
||||||
|
|
||||||
**File Structure:**
|
|
||||||
- Follow monorepo structure: packages/features/* for feature packages
|
|
||||||
- Use established naming conventions and folder organization
|
|
||||||
- Import from @kit/* packages appropriately`,
|
|
||||||
examples: [
|
|
||||||
'Create a data table component with sorting and filtering',
|
|
||||||
'Build a multi-step form component',
|
|
||||||
'Design a notification center component',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'architecture_guidance',
|
|
||||||
title: 'Architecture Guidance',
|
|
||||||
description: 'Provide architectural recommendations for complex features',
|
|
||||||
category: 'architecture',
|
|
||||||
arguments: [
|
|
||||||
{
|
|
||||||
name: 'feature_scope',
|
|
||||||
description: 'Description of the feature or system to architect',
|
|
||||||
required: true,
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'scale_requirements',
|
|
||||||
description: 'Expected scale and performance requirements',
|
|
||||||
required: false,
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'constraints',
|
|
||||||
description: 'Technical constraints or requirements',
|
|
||||||
required: false,
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
template: `Provide architectural guidance for: {{feature_scope}}
|
|
||||||
|
|
||||||
{{#if scale_requirements}}
|
|
||||||
**Scale Requirements:** {{scale_requirements}}
|
|
||||||
{{/if}}
|
|
||||||
|
|
||||||
{{#if constraints}}
|
|
||||||
**Constraints:** {{constraints}}
|
|
||||||
{{/if}}
|
|
||||||
|
|
||||||
**Please provide:**
|
|
||||||
1. **Architecture Overview:** High-level system design and components
|
|
||||||
2. **Data Architecture:** Database design and data flow patterns
|
|
||||||
3. **API Design:** RESTful endpoints and GraphQL considerations
|
|
||||||
4. **State Management:** Client-side state architecture
|
|
||||||
5. **Security Architecture:** Authentication, authorization, and data protection
|
|
||||||
6. **Performance Strategy:** Caching, optimization, and scaling approaches
|
|
||||||
7. **Integration Patterns:** How this fits with existing Makerkit architecture
|
|
||||||
|
|
||||||
**Makerkit Architecture Standards:**
|
|
||||||
|
|
||||||
**Multi-Tenant Patterns:**
|
|
||||||
- Account-based data isolation with proper foreign key relationships
|
|
||||||
- Personal vs Team account architecture (auth.users.id = accounts.id for personal)
|
|
||||||
- Role-based access control with roles, memberships, and permissions tables
|
|
||||||
- RLS policies that enforce account boundaries at database level
|
|
||||||
|
|
||||||
**Technology Stack Integration:**
|
|
||||||
- Next.js 15 App Router with React Server Components
|
|
||||||
- Supabase for database, auth, storage, and real-time features
|
|
||||||
- TypeScript strict mode with no 'any' types
|
|
||||||
- Tailwind CSS 4 with shadcn/ui and custom Makerkit components
|
|
||||||
- Turborepo monorepo with proper package organization
|
|
||||||
|
|
||||||
**Performance & Security:**
|
|
||||||
- Server-side data fetching preferred over client-side
|
|
||||||
- Proper error boundaries and graceful error handling
|
|
||||||
- Account-level data access patterns with efficient queries
|
|
||||||
- Use of existing database functions for complex operations
|
|
||||||
|
|
||||||
**Code Organization:**
|
|
||||||
- For simplicity, place feature directly in the application (apps/web) unless you're asked to create a separate package for it
|
|
||||||
- Shared utilities in packages/* (ui, auth, billing, etc.)
|
|
||||||
- Consistent naming conventions and file structure
|
|
||||||
- Proper import patterns from @kit/* packages`,
|
|
||||||
examples: [
|
|
||||||
'Design a real-time notification system',
|
|
||||||
'Architect a file upload and processing system',
|
|
||||||
'Design a reporting and analytics feature',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'makerkit_feature_implementation',
|
|
||||||
title: 'Makerkit Feature Implementation Guide',
|
|
||||||
description:
|
|
||||||
'Complete guide for implementing new features following Makerkit patterns',
|
|
||||||
category: 'development',
|
|
||||||
arguments: [
|
|
||||||
{
|
|
||||||
name: 'feature_name',
|
|
||||||
description: 'Name of the feature to implement',
|
|
||||||
required: true,
|
|
||||||
type: 'string',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'feature_type',
|
|
||||||
description: 'Type of feature being implemented',
|
|
||||||
required: true,
|
|
||||||
type: 'enum',
|
|
||||||
options: [
|
|
||||||
'billing',
|
|
||||||
'auth',
|
|
||||||
'team-management',
|
|
||||||
'data-management',
|
|
||||||
'api',
|
|
||||||
'ui-component',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'user_stories',
|
|
||||||
description: 'User stories or requirements for the feature',
|
|
||||||
required: false,
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
template: `Implement a {{feature_type}} feature: {{feature_name}}
|
|
||||||
|
|
||||||
{{#if user_stories}}
|
|
||||||
**User Requirements:**
|
|
||||||
{{user_stories}}
|
|
||||||
{{/if}}
|
|
||||||
|
|
||||||
**Please provide a complete Makerkit implementation including:**
|
|
||||||
|
|
||||||
**1. Database Design:**
|
|
||||||
- Schema changes following multi-tenant patterns
|
|
||||||
- RLS policies for account-based access control
|
|
||||||
- Database functions if needed (SECURITY DEFINER/INVOKER)
|
|
||||||
- Proper foreign key relationships with account_id
|
|
||||||
- Schema uses constraints/triggers where required for data integrity and business rules
|
|
||||||
- Schema prevents invalid data from being inserted or updated
|
|
||||||
|
|
||||||
**2. Backend Implementation:**
|
|
||||||
- Server Actions or API routes following Next.js 15 patterns
|
|
||||||
- Proper error handling with typed responses
|
|
||||||
- Integration with existing Supabase auth and database
|
|
||||||
- Account-level data access patterns
|
|
||||||
- Redirect using Server Actions/API Routes instead of client-side navigation
|
|
||||||
|
|
||||||
**3. Frontend Components:**
|
|
||||||
- React Server Components where possible
|
|
||||||
- Use of @kit/ui components (shadcn + makerkit)
|
|
||||||
- Small, composable, explicit, reusable, well-named components
|
|
||||||
- Proper TypeScript interfaces and types
|
|
||||||
- Single state objects over multiple useState
|
|
||||||
- Conditional rendering with <If> component
|
|
||||||
|
|
||||||
**4. Package Organization:**
|
|
||||||
- If reusable, create feature package in packages/features/{{feature_name}}
|
|
||||||
- Proper exports and package.json configuration
|
|
||||||
- Integration with existing packages (@kit/auth, @kit/ui, etc.)
|
|
||||||
|
|
||||||
**5. Code Quality:**
|
|
||||||
- TypeScript strict mode with no 'any' types
|
|
||||||
- Proper error boundaries and handling
|
|
||||||
- Follow established file structure and naming conventions
|
|
||||||
|
|
||||||
**Makerkit Standards:**
|
|
||||||
- Multi-tenant architecture with account-based access
|
|
||||||
- Use existing database functions where applicable
|
|
||||||
- Follow monorepo patterns and package organization
|
|
||||||
- Implement proper security and performance best practices`,
|
|
||||||
examples: [
|
|
||||||
'Implement team collaboration features',
|
|
||||||
'Build a subscription management system',
|
|
||||||
'Create a file sharing feature with permissions',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'supabase_rls_policy_design',
|
name: 'supabase_rls_policy_design',
|
||||||
title: 'Supabase RLS Policy Design',
|
title: 'Supabase RLS Policy Design',
|
||||||
|
|||||||
@@ -9,6 +9,6 @@
|
|||||||
"module": "nodenext",
|
"module": "nodenext",
|
||||||
"moduleResolution": "nodenext"
|
"moduleResolution": "nodenext"
|
||||||
},
|
},
|
||||||
"files": ["src/index.ts"],
|
"include": ["src"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"./config/server": "./src/sentry.client.server.ts"
|
"./config/server": "./src/sentry.client.server.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/nextjs": "^10.12.0",
|
"@sentry/nextjs": "^10.14.0",
|
||||||
"import-in-the-middle": "1.14.2"
|
"import-in-the-middle": "1.14.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -2,28 +2,45 @@
|
|||||||
|
|
||||||
This file contains instructions for working with Next.js utilities including server actions and route handlers.
|
This file contains instructions for working with Next.js utilities including server actions and route handlers.
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
- Don't use Server Actions for data-fetching, use for mutations only
|
||||||
|
- Best Practice: Keep actions light, move business logic to ad-hoc services
|
||||||
|
- Authorization logic must be defined in RLS and DB, not Server Actions or application code (unless using the admin client, use sparinlgy!)
|
||||||
|
- Do not expose sensitive data
|
||||||
|
- Log async operations
|
||||||
|
- Validate body with Zod
|
||||||
|
- Use 'use server' at the top of the file. No need for 'server only';
|
||||||
|
|
||||||
## Server Actions Implementation
|
## Server Actions Implementation
|
||||||
|
|
||||||
Always use `enhanceAction` from `@packages/next/src/actions/index.ts`:
|
Always use `enhanceAction` from `@packages/next/src/actions/index.ts`.
|
||||||
|
|
||||||
```typescript
|
Define a schema:
|
||||||
'use server';
|
|
||||||
|
|
||||||
import { enhanceAction } from '@kit/next/actions';
|
```tsx
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
// Define your schema
|
// Define your schema in its own file
|
||||||
const CreateNoteSchema = z.object({
|
export const CreateNoteSchema = z.object({
|
||||||
title: z.string().min(1, 'Title is required'),
|
title: z.string().min(1, 'Title is required'),
|
||||||
content: z.string().min(1, 'Content is required'),
|
content: z.string().min(1, 'Content is required'),
|
||||||
accountId: z.string().uuid('Invalid account ID'),
|
accountId: z.string().uuid('Invalid account ID'),
|
||||||
});
|
});
|
||||||
|
```
|
||||||
|
|
||||||
export const createNoteAction = enhanceAction(
|
Then we define a service for crossing the network boundary:
|
||||||
async function (data, user) {
|
|
||||||
// data is automatically validated against the schema
|
|
||||||
// user is automatically authenticated if auth: true
|
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { CreateNoteSchema } from '../schemas/notes.schemas.ts';
|
||||||
|
import * as z from 'zod';
|
||||||
|
|
||||||
|
export function createNotesService() {
|
||||||
|
return new NotesService();
|
||||||
|
}
|
||||||
|
|
||||||
|
class NotesService {
|
||||||
|
createNote(data: z.infer<CreateNoteSchema>) {
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
|
||||||
const { data: note, error } = await client
|
const { data: note, error } = await client
|
||||||
@@ -40,11 +57,51 @@ export const createNoteAction = enhanceAction(
|
|||||||
if (error) {
|
if (error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
return { success: true, note };
|
Finally, we use Server Actions for exposing POST handlers:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import { enhanceAction } from '@kit/next/actions';
|
||||||
|
import { createNotesService } from '../notes.service.ts';
|
||||||
|
|
||||||
|
export const createNoteAction = enhanceAction(
|
||||||
|
async function (data, user) {
|
||||||
|
// data is automatically validated against the schema
|
||||||
|
// user is automatically authenticated if auth: true
|
||||||
|
|
||||||
|
const service = createNotesService();
|
||||||
|
const logger = await getLogger();
|
||||||
|
|
||||||
|
logger.info({
|
||||||
|
userId: user.id,
|
||||||
|
}, `Creating note...`);
|
||||||
|
|
||||||
|
const { data: note, error } = await service.createNote(data);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
logger.error({
|
||||||
|
error: error.message
|
||||||
|
}, `Error creating note`);
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({
|
||||||
|
noteId: note.id
|
||||||
|
}, `Note successfully created`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
note
|
||||||
|
};
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
auth: true, // Require authentication
|
auth: true, // Require authentication (true by default, can omit)
|
||||||
schema: CreateNoteSchema, // Validate input with Zod
|
schema: CreateNoteSchema, // Validate input with Zod
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -75,7 +132,15 @@ export const myAction = enhanceAction(
|
|||||||
|
|
||||||
## Route Handlers (API Routes)
|
## Route Handlers (API Routes)
|
||||||
|
|
||||||
Use `enhanceRouteHandler` from `@packages/next/src/routes/index.ts`:
|
Use `enhanceRouteHandler` from `@packages/next/src/routes/index.ts`.
|
||||||
|
|
||||||
|
### Guidelines
|
||||||
|
|
||||||
|
- Use when data must be exposed to externally
|
||||||
|
- Use for receiving requests from external clients (such as webhooks)
|
||||||
|
- Can be used for fetching data to client side fetchers (such as React Query) if cannot use client-side Supabase queries
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { enhanceRouteHandler } from '@kit/next/routes';
|
import { enhanceRouteHandler } from '@kit/next/routes';
|
||||||
@@ -370,44 +435,6 @@ const handleCreateItem = async (data) => {
|
|||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
### Authorization Checks
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|||||||
@@ -2,28 +2,45 @@
|
|||||||
|
|
||||||
This file contains instructions for working with Next.js utilities including server actions and route handlers.
|
This file contains instructions for working with Next.js utilities including server actions and route handlers.
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
- Don't use Server Actions for data-fetching, use for mutations only
|
||||||
|
- Best Practice: Keep actions light, move business logic to ad-hoc services
|
||||||
|
- Authorization logic must be defined in RLS and DB, not Server Actions or application code (unless using the admin client, use sparinlgy!)
|
||||||
|
- Do not expose sensitive data
|
||||||
|
- Log async operations
|
||||||
|
- Validate body with Zod
|
||||||
|
- Use 'use server' at the top of the file. No need for 'server only';
|
||||||
|
|
||||||
## Server Actions Implementation
|
## Server Actions Implementation
|
||||||
|
|
||||||
Always use `enhanceAction` from `@packages/next/src/actions/index.ts`:
|
Always use `enhanceAction` from `@packages/next/src/actions/index.ts`.
|
||||||
|
|
||||||
```typescript
|
Define a schema:
|
||||||
'use server';
|
|
||||||
|
|
||||||
import { enhanceAction } from '@kit/next/actions';
|
```tsx
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
// Define your schema
|
// Define your schema in its own file
|
||||||
const CreateNoteSchema = z.object({
|
export const CreateNoteSchema = z.object({
|
||||||
title: z.string().min(1, 'Title is required'),
|
title: z.string().min(1, 'Title is required'),
|
||||||
content: z.string().min(1, 'Content is required'),
|
content: z.string().min(1, 'Content is required'),
|
||||||
accountId: z.string().uuid('Invalid account ID'),
|
accountId: z.string().uuid('Invalid account ID'),
|
||||||
});
|
});
|
||||||
|
```
|
||||||
|
|
||||||
export const createNoteAction = enhanceAction(
|
Then we define a service for crossing the network boundary:
|
||||||
async function (data, user) {
|
|
||||||
// data is automatically validated against the schema
|
|
||||||
// user is automatically authenticated if auth: true
|
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { CreateNoteSchema } from '../schemas/notes.schemas.ts';
|
||||||
|
import * as z from 'zod';
|
||||||
|
|
||||||
|
export function createNotesService() {
|
||||||
|
return new NotesService();
|
||||||
|
}
|
||||||
|
|
||||||
|
class NotesService {
|
||||||
|
createNote(data: z.infer<CreateNoteSchema>) {
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
|
||||||
const { data: note, error } = await client
|
const { data: note, error } = await client
|
||||||
@@ -40,11 +57,51 @@ export const createNoteAction = enhanceAction(
|
|||||||
if (error) {
|
if (error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
return { success: true, note };
|
Finally, we use Server Actions for exposing POST handlers:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import { enhanceAction } from '@kit/next/actions';
|
||||||
|
import { createNotesService } from '../notes.service.ts';
|
||||||
|
|
||||||
|
export const createNoteAction = enhanceAction(
|
||||||
|
async function (data, user) {
|
||||||
|
// data is automatically validated against the schema
|
||||||
|
// user is automatically authenticated if auth: true
|
||||||
|
|
||||||
|
const service = createNotesService();
|
||||||
|
const logger = await getLogger();
|
||||||
|
|
||||||
|
logger.info({
|
||||||
|
userId: user.id,
|
||||||
|
}, `Creating note...`);
|
||||||
|
|
||||||
|
const { data: note, error } = await service.createNote(data);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
logger.error({
|
||||||
|
error: error.message
|
||||||
|
}, `Error creating note`);
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({
|
||||||
|
noteId: note.id
|
||||||
|
}, `Note successfully created`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
note
|
||||||
|
};
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
auth: true, // Require authentication
|
auth: true, // Require authentication (true by default, can omit)
|
||||||
schema: CreateNoteSchema, // Validate input with Zod
|
schema: CreateNoteSchema, // Validate input with Zod
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -75,7 +132,15 @@ export const myAction = enhanceAction(
|
|||||||
|
|
||||||
## Route Handlers (API Routes)
|
## Route Handlers (API Routes)
|
||||||
|
|
||||||
Use `enhanceRouteHandler` from `@packages/next/src/routes/index.ts`:
|
Use `enhanceRouteHandler` from `@packages/next/src/routes/index.ts`.
|
||||||
|
|
||||||
|
### Guidelines
|
||||||
|
|
||||||
|
- Use when data must be exposed to externally
|
||||||
|
- Use for receiving requests from external clients (such as webhooks)
|
||||||
|
- Can be used for fetching data to client side fetchers (such as React Query) if cannot use client-side Supabase queries
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { enhanceRouteHandler } from '@kit/next/routes';
|
import { enhanceRouteHandler } from '@kit/next/routes';
|
||||||
@@ -370,44 +435,6 @@ const handleCreateItem = async (data) => {
|
|||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
### Authorization Checks
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"@kit/supabase": "workspace:*",
|
"@kit/supabase": "workspace:*",
|
||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@supabase/supabase-js": "2.57.4",
|
"@supabase/supabase-js": "2.57.4",
|
||||||
"next": "15.5.3",
|
"next": "15.5.4",
|
||||||
"zod": "^3.25.74"
|
"zod": "^3.25.74"
|
||||||
},
|
},
|
||||||
"typesVersions": {
|
"typesVersions": {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ This file contains instructions for working with Supabase, database security, an
|
|||||||
1. **Edit schema file** (e.g., `supabase/schemas/18-projects.sql`)
|
1. **Edit schema file** (e.g., `supabase/schemas/18-projects.sql`)
|
||||||
2. **Generate migration**: `pnpm --filter web supabase:db:diff -f migration_name`
|
2. **Generate migration**: `pnpm --filter web supabase:db:diff -f migration_name`
|
||||||
- This compares your schema against the current database and creates a migration
|
- This compares your schema against the current database and creates a migration
|
||||||
3. **Apply migration**: `pnpm --filter web supabase migration up`
|
3. **Apply migration**: `pnpm --filter web supabase migrations up`
|
||||||
- This actually executes the SQL changes in the database
|
- This actually executes the SQL changes in the database
|
||||||
|
|
||||||
**⚠️ CRITICAL**: Editing a schema file alone does NOTHING to your database. You MUST generate and apply a migration for changes to take effect. Schema files are templates - migrations are the actual database operations.
|
**⚠️ CRITICAL**: Editing a schema file alone does NOTHING to your database. You MUST generate and apply a migration for changes to take effect. Schema files are templates - migrations are the actual database operations.
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ This file contains instructions for working with Supabase, database security, an
|
|||||||
### The Required Workflow
|
### The Required Workflow
|
||||||
|
|
||||||
1. **Edit schema file** (e.g., `supabase/schemas/18-projects.sql`)
|
1. **Edit schema file** (e.g., `supabase/schemas/18-projects.sql`)
|
||||||
2. **Generate migration**: `pnpm --filter web supabase:db:diff -f migration_name`
|
2. **Generate migration**:
|
||||||
|
- Either copy content from schema to migration with `pnpm --filter web supabase migrations new my-feature` and `cp apps/web/supabase/schemas/18-my-new-feature.sql apps/web/supabase/migrations/$(ls -t apps/web/supabase/migrations/ | head -n1)`. Ideal for **new** tables, enums, etc.
|
||||||
|
- or use `pnpm --filter web supabase:db:diff -f migration_name` (ideal when modifying existing entities)
|
||||||
- This compares your schema against the current database and creates a migration
|
- This compares your schema against the current database and creates a migration
|
||||||
3. **Apply migration**: `pnpm --filter web supabase migration up`
|
3. **Apply migration**: `pnpm --filter web supabase migrations up`
|
||||||
- This actually executes the SQL changes in the database
|
- This actually executes the SQL changes in the database
|
||||||
|
|
||||||
**⚠️ CRITICAL**: Editing a schema file alone does NOTHING to your database. You MUST generate and apply a migration for changes to take effect. Schema files are templates - migrations are the actual database operations.
|
**⚠️ CRITICAL**: Editing a schema file alone does NOTHING to your database. You MUST generate and apply a migration for changes to take effect. Schema files are templates - migrations are the actual database operations.
|
||||||
|
|||||||
@@ -27,9 +27,9 @@
|
|||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@supabase/ssr": "^0.7.0",
|
"@supabase/ssr": "^0.7.0",
|
||||||
"@supabase/supabase-js": "2.57.4",
|
"@supabase/supabase-js": "2.57.4",
|
||||||
"@tanstack/react-query": "5.89.0",
|
"@tanstack/react-query": "5.90.2",
|
||||||
"@types/react": "19.1.13",
|
"@types/react": "19.1.13",
|
||||||
"next": "15.5.3",
|
"next": "15.5.4",
|
||||||
"react": "19.1.1",
|
"react": "19.1.1",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"zod": "^3.25.74"
|
"zod": "^3.25.74"
|
||||||
|
|||||||
@@ -26,14 +26,14 @@
|
|||||||
"@kit/prettier-config": "workspace:*",
|
"@kit/prettier-config": "workspace:*",
|
||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@supabase/supabase-js": "2.57.4",
|
"@supabase/supabase-js": "2.57.4",
|
||||||
"@tanstack/react-query": "5.89.0",
|
"@tanstack/react-query": "5.90.2",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@types/react": "19.1.13",
|
"@types/react": "19.1.13",
|
||||||
"@types/react-dom": "19.1.9",
|
"@types/react-dom": "19.1.9",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"eslint": "^9.35.0",
|
"eslint": "^9.35.0",
|
||||||
"next": "15.5.3",
|
"next": "15.5.4",
|
||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"react-day-picker": "^9.11.0",
|
"react-day-picker": "^9.11.0",
|
||||||
|
|||||||
1071
pnpm-lock.yaml
generated
1071
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -13,11 +13,11 @@
|
|||||||
"format": "prettier --check \"**/*.{js,json}\""
|
"format": "prettier --check \"**/*.{js,json}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/eslint-plugin-next": "15.5.3",
|
"@next/eslint-plugin-next": "15.5.4",
|
||||||
"@types/eslint": "9.6.1",
|
"@types/eslint": "9.6.1",
|
||||||
"eslint-config-next": "15.5.3",
|
"eslint-config-next": "15.5.4",
|
||||||
"eslint-config-turbo": "^2.5.6",
|
"eslint-config-turbo": "^2.5.8",
|
||||||
"typescript-eslint": "8.44.0"
|
"typescript-eslint": "8.44.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@kit/prettier-config": "workspace:*",
|
"@kit/prettier-config": "workspace:*",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export function checkPendingMigrations() {
|
|||||||
try {
|
try {
|
||||||
console.info('\x1b[34m%s\x1b[0m', 'Checking for pending migrations...');
|
console.info('\x1b[34m%s\x1b[0m', 'Checking for pending migrations...');
|
||||||
|
|
||||||
const output = execSync('pnpm --filter web supabase migration list', {
|
const output = execSync('pnpm --filter web supabase migrations list', {
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
stdio: 'pipe',
|
stdio: 'pipe',
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user